Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 439c743fd1 | |||
| 74144f1fc6 | |||
| 99a0376657 | |||
| 21b1e79672 | |||
| cfaee103b6 | |||
| c0e346992d | |||
| 04f45a16c9 | |||
| efad1da74d | |||
| 0a2b010332 | |||
| 6906c8bcf7 | |||
| ac48bfdd0d | |||
| ec99243b6f | |||
| 9932621e14 | |||
| 0fb17c78fa | |||
| 4bcca58a11 | |||
| 9fa5ba1cf7 | |||
| 88919fa0d3 | |||
| 5e43fc9cd9 | |||
| f053f42b6d |
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -21,8 +21,9 @@ jobs:
|
|||||||
|
|
||||||
- name: Create filtered branch
|
- name: Create filtered branch
|
||||||
run: |
|
run: |
|
||||||
|
|
||||||
# Create a new branch for GitHub
|
# Create a new branch for GitHub
|
||||||
git checkout -b github-mirror
|
git checkout --orphan -b github-mirror
|
||||||
|
|
||||||
# Remove sensitive files/directories
|
# Remove sensitive files/directories
|
||||||
# Example: Remove .env files, private configs, etc.
|
# Example: Remove .env files, private configs, etc.
|
||||||
@@ -49,7 +50,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Push to GitHub
|
- name: Push to GitHub
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUBTOKEN }}
|
GITHUBTOKEN: ${{ secrets.GITHUBTOKEN }}
|
||||||
run: |
|
run: |
|
||||||
# Add GitHub remote
|
# Add GitHub remote
|
||||||
git remote add github https://x-access-token:${GITHUBTOKEN}@github.com/the-luap/picpeak.git
|
git remote add github https://x-access-token:${GITHUBTOKEN}@github.com/the-luap/picpeak.git
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
# 📸 PicPeak - Open Source Photo Sharing for Events
|
# 📸 PicPeak - Open Source Photo Sharing for Events
|
||||||
|
|
||||||
[](https://opensource.org/licenses/MIT)
|
<div align="center">
|
||||||
[](https://www.docker.com/)
|
<img src="docs/picpeak-logo.png" alt="PicPeak Logo" width="300" />
|
||||||
[](https://nodejs.org/)
|
|
||||||
[](https://reactjs.org/)
|
[](https://opensource.org/licenses/MIT)
|
||||||
|
[](https://www.docker.com/)
|
||||||
|
[](https://nodejs.org/)
|
||||||
|
[](https://reactjs.org/)
|
||||||
|
</div>
|
||||||
|
|
||||||
**PicPeak** is a powerful, self-hosted open-source alternative to commercial photo-sharing platforms like PicDrop.com and Scrapbook.de. Designed specifically for photographers and event organizers, PicPeak makes it simple to share beautiful, time-limited photo galleries with clients while maintaining full control over your data and branding.
|
**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.
|
||||||
|
|
||||||

|

|
||||||
|
> 📸 *Gallery preview will be updated soon with latest interface*
|
||||||
|
|
||||||
## 🌟 Why Choose PicPeak?
|
## 🌟 Why Choose PicPeak?
|
||||||
|
|
||||||
@@ -124,17 +129,34 @@ Found a security issue? Please email security@example.com
|
|||||||
|
|
||||||
## 📸 Screenshots
|
## 📸 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>
|
<details>
|
||||||
<summary>View Gallery Examples</summary>
|
<summary>👆 Click to see more interface details</summary>
|
||||||
|
|
||||||
### Admin Dashboard
|
#### What makes PicPeak's interface special:
|
||||||

|
|
||||||
|
|
||||||
### Client Gallery View
|
- **🎨 Clean Design**: Modern, photographer-friendly interface
|
||||||

|
- **📱 Responsive**: Perfect on desktop, tablet, and mobile
|
||||||
|
- **⚡ Fast Loading**: Optimized for quick photo browsing
|
||||||
### Mobile Experience
|
- **🔒 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>
|
</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();
|
||||||
|
};
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-backend",
|
"name": "picpeak-backend",
|
||||||
"version": "1.0.26",
|
"version": "1.0.34",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "picpeak-backend",
|
"name": "picpeak-backend",
|
||||||
"version": "1.0.26",
|
"version": "1.0.34",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"adm-zip": "^0.5.16",
|
"adm-zip": "^0.5.16",
|
||||||
"archiver": "^5.3.1",
|
"archiver": "^5.3.1",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-backend",
|
"name": "picpeak-backend",
|
||||||
"version": "1.0.26",
|
"version": "1.0.34",
|
||||||
"description": "Backend for PicPeak event photo sharing platform",
|
"description": "Backend for PicPeak event photo sharing platform",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -36,7 +36,8 @@ async function createTestEvent() {
|
|||||||
await db('events').where('slug', eventData.slug).delete();
|
await db('events').where('slug', eventData.slug).delete();
|
||||||
|
|
||||||
// Insert new event
|
// 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('Event created with ID:', eventId);
|
||||||
|
|
||||||
console.log('\nTest event created successfully!');
|
console.log('\nTest event created successfully!');
|
||||||
|
|||||||
+9
-6
@@ -125,9 +125,9 @@ const authLimiter = rateLimit({
|
|||||||
app.use('/api/', limiter);
|
app.use('/api/', limiter);
|
||||||
app.use('/api/auth', authLimiter);
|
app.use('/api/auth', authLimiter);
|
||||||
|
|
||||||
// Body parsing middleware
|
// Body parsing middleware with increased limits for large uploads
|
||||||
app.use(express.json());
|
app.use(express.json({ limit: '100mb' }));
|
||||||
app.use(express.urlencoded({ extended: true }));
|
app.use(express.urlencoded({ extended: true, limit: '100mb' }));
|
||||||
|
|
||||||
// Maintenance mode middleware - add after body parsing but before routes
|
// Maintenance mode middleware - add after body parsing but before routes
|
||||||
app.use(maintenanceMiddleware);
|
app.use(maintenanceMiddleware);
|
||||||
@@ -146,14 +146,17 @@ const setCorsHeaders = (req, res, next) => {
|
|||||||
// Import secure static middleware
|
// Import secure static middleware
|
||||||
const secureStatic = require('./src/middleware/secureStatic');
|
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)
|
// 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)
|
// 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)
|
// 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
|
// Health check endpoint
|
||||||
app.get('/health', async (req, res) => {
|
app.get('/health', async (req, res) => {
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
const { formatBoolean, isPostgreSQL, addDays, formatDateForDB, insertAndGetId } = require('../utils/dbCompat');
|
||||||
|
|
||||||
|
describe('Database Compatibility', () => {
|
||||||
|
// Save original env
|
||||||
|
const originalEnv = process.env.DATABASE_CLIENT;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// Restore original env after each test
|
||||||
|
if (originalEnv) {
|
||||||
|
process.env.DATABASE_CLIENT = originalEnv;
|
||||||
|
} else {
|
||||||
|
delete process.env.DATABASE_CLIENT;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatBoolean', () => {
|
||||||
|
test('should format boolean values correctly', () => {
|
||||||
|
// Mock for SQLite
|
||||||
|
process.env.DATABASE_CLIENT = 'sqlite3';
|
||||||
|
expect(formatBoolean(true)).toBe(1);
|
||||||
|
expect(formatBoolean(false)).toBe(0);
|
||||||
|
|
||||||
|
// Mock for PostgreSQL
|
||||||
|
process.env.DATABASE_CLIENT = 'pg';
|
||||||
|
expect(formatBoolean(true)).toBe(true);
|
||||||
|
expect(formatBoolean(false)).toBe(false);
|
||||||
|
|
||||||
|
// Default (no env var) should be SQLite
|
||||||
|
delete process.env.DATABASE_CLIENT;
|
||||||
|
expect(formatBoolean(true)).toBe(1);
|
||||||
|
expect(formatBoolean(false)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isPostgreSQL', () => {
|
||||||
|
test('should detect PostgreSQL correctly', () => {
|
||||||
|
process.env.DATABASE_CLIENT = 'pg';
|
||||||
|
expect(isPostgreSQL()).toBe(true);
|
||||||
|
|
||||||
|
process.env.DATABASE_CLIENT = 'sqlite3';
|
||||||
|
expect(isPostgreSQL()).toBe(false);
|
||||||
|
|
||||||
|
delete process.env.DATABASE_CLIENT;
|
||||||
|
expect(isPostgreSQL()).toBe(false); // Default to SQLite
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatDateForDB', () => {
|
||||||
|
test('should format dates as ISO strings', () => {
|
||||||
|
const date = new Date('2024-01-15T10:30:00Z');
|
||||||
|
expect(formatDateForDB(date)).toBe('2024-01-15T10:30:00.000Z');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('addDays', () => {
|
||||||
|
test('should add days correctly', () => {
|
||||||
|
const date = new Date('2024-01-15');
|
||||||
|
const result = addDays(date, 30);
|
||||||
|
expect(result.toISOString().split('T')[0]).toBe('2024-02-14');
|
||||||
|
|
||||||
|
const negativeResult = addDays(date, -7);
|
||||||
|
expect(negativeResult.toISOString().split('T')[0]).toBe('2024-01-08');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('insertAndGetId', () => {
|
||||||
|
test('should handle PostgreSQL result format', async () => {
|
||||||
|
const mockQuery = {
|
||||||
|
returning: jest.fn().mockResolvedValue([{ id: 123 }])
|
||||||
|
};
|
||||||
|
const result = await insertAndGetId(mockQuery);
|
||||||
|
expect(result).toBe(123);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle SQLite result format', async () => {
|
||||||
|
const mockQuery = {
|
||||||
|
returning: jest.fn().mockResolvedValue([456])
|
||||||
|
};
|
||||||
|
const result = await insertAndGetId(mockQuery);
|
||||||
|
expect(result).toBe(456);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
const { db } = require('../database/db');
|
const { db } = require('../database/db');
|
||||||
|
const { formatBoolean } = require('../utils/dbCompat');
|
||||||
const { isTokenRevoked } = require('../utils/tokenRevocation');
|
const { isTokenRevoked } = require('../utils/tokenRevocation');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
@@ -57,7 +58,7 @@ async function adminAuth(req, res, next) {
|
|||||||
|
|
||||||
// Check if admin still exists and is active
|
// Check if admin still exists and is active
|
||||||
const admin = await db('admin_users')
|
const admin = await db('admin_users')
|
||||||
.where({ id: decoded.id, is_active: true })
|
.where({ id: decoded.id, is_active: formatBoolean(true) })
|
||||||
.first();
|
.first();
|
||||||
|
|
||||||
if (!admin) {
|
if (!admin) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
const { db } = require('../database/db');
|
const { db } = require('../database/db');
|
||||||
|
const { formatBoolean } = require('../utils/dbCompat');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -50,7 +51,7 @@ async function adminAuth(req, res, next) {
|
|||||||
|
|
||||||
// Check if admin still exists and is active
|
// Check if admin still exists and is active
|
||||||
const admin = await db('admin_users')
|
const admin = await db('admin_users')
|
||||||
.where({ id: decoded.id, is_active: true })
|
.where({ id: decoded.id, is_active: formatBoolean(true) })
|
||||||
.first();
|
.first();
|
||||||
|
|
||||||
if (!admin) {
|
if (!admin) {
|
||||||
@@ -165,7 +166,7 @@ async function photoAuth(req, res, next) {
|
|||||||
// Allow both admin and gallery tokens
|
// Allow both admin and gallery tokens
|
||||||
if (decoded.type === 'admin') {
|
if (decoded.type === 'admin') {
|
||||||
const admin = await db('admin_users')
|
const admin = await db('admin_users')
|
||||||
.where({ id: decoded.id, is_active: true })
|
.where({ id: decoded.id, is_active: formatBoolean(true) })
|
||||||
.first();
|
.first();
|
||||||
|
|
||||||
if (!admin) {
|
if (!admin) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
const { db } = require('../database/db');
|
const { db } = require('../database/db');
|
||||||
|
const { formatBoolean } = require('../utils/dbCompat');
|
||||||
|
|
||||||
async function adminAuth(req, res, next) {
|
async function adminAuth(req, res, next) {
|
||||||
try {
|
try {
|
||||||
@@ -9,7 +10,7 @@ async function adminAuth(req, res, next) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
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) {
|
if (!admin) {
|
||||||
return res.status(401).json({ error: 'Invalid token' });
|
return res.status(401).json({ error: 'Invalid token' });
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
const { db } = require('../database/db');
|
const { db } = require('../database/db');
|
||||||
|
const { formatBoolean } = require('../utils/dbCompat');
|
||||||
|
|
||||||
// Middleware to verify gallery access
|
// Middleware to verify gallery access
|
||||||
async function verifyGalleryAccess(req, res, next) {
|
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 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) {
|
if (!event) {
|
||||||
return res.status(404).json({ error: 'Gallery not found or expired' });
|
return res.status(404).json({ error: 'Gallery not found or expired' });
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
const bcrypt = require('bcrypt');
|
const bcrypt = require('bcrypt');
|
||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
const { db } = require('../database/db');
|
const { db } = require('../database/db');
|
||||||
|
const { formatBoolean } = require('../utils/dbCompat');
|
||||||
|
|
||||||
async function photoAuth(req, res, next) {
|
async function photoAuth(req, res, next) {
|
||||||
try {
|
try {
|
||||||
// Extract event slug from the path
|
// Extract event slug from the path
|
||||||
let eventSlug;
|
let eventSlug;
|
||||||
|
|
||||||
|
console.log('PhotoAuth middleware - path:', req.path);
|
||||||
|
|
||||||
// For thumbnails, we need to parse the filename to get the event info
|
// For thumbnails, we need to parse the filename to get the event info
|
||||||
if (req.path.startsWith('/thumb_')) {
|
if (req.path.startsWith('/thumb_')) {
|
||||||
// For now, we'll rely on JWT token for thumbnail access
|
// 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
|
// Check if it's a gallery token
|
||||||
if (decoded.type === 'gallery') {
|
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) {
|
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) {
|
if (event) {
|
||||||
req.event = event;
|
req.event = event;
|
||||||
return next();
|
return next();
|
||||||
@@ -35,7 +51,9 @@ async function photoAuth(req, res, next) {
|
|||||||
}
|
}
|
||||||
// For regular photos, check if token matches the event
|
// For regular photos, check if token matches the event
|
||||||
else if (decoded.eventSlug === eventSlug) {
|
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) {
|
if (event) {
|
||||||
req.event = event;
|
req.event = event;
|
||||||
return next();
|
return next();
|
||||||
@@ -45,18 +63,12 @@ async function photoAuth(req, res, next) {
|
|||||||
|
|
||||||
// Check if it's an admin token (admins can view all photos)
|
// Check if it's an admin token (admins can view all photos)
|
||||||
if (decoded.type === 'admin') {
|
if (decoded.type === 'admin') {
|
||||||
if (!eventSlug) {
|
// For both thumbnails and photos with admin token, allow access
|
||||||
// For thumbnails with admin token, allow access
|
return next();
|
||||||
return next();
|
|
||||||
}
|
|
||||||
const event = await db('events').where({ slug: eventSlug }).first();
|
|
||||||
if (event) {
|
|
||||||
req.event = event;
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Token invalid, fall through to password check
|
// 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' });
|
return res.status(401).json({ error: 'Authentication required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no eventSlug (thumbnails), we require JWT token
|
// If no eventSlug (thumbnails), and we don't have valid auth yet, deny access
|
||||||
if (!eventSlug) {
|
if (!eventSlug && !password) {
|
||||||
return res.status(401).json({ error: 'Authentication required for thumbnails' });
|
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) {
|
if (!event) {
|
||||||
return res.status(404).json({ error: 'Gallery not found' });
|
return res.status(404).json({ error: 'Gallery not found' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ const express = require('express');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs').promises;
|
const fs = require('fs').promises;
|
||||||
const { db } = require('../database/db');
|
const { db } = require('../database/db');
|
||||||
|
const { formatBoolean } = require('../utils/dbCompat');
|
||||||
const { adminAuth } = require('../middleware/auth-enhanced-v2');
|
const { adminAuth } = require('../middleware/auth-enhanced-v2');
|
||||||
const archiver = require('archiver');
|
const archiver = require('archiver');
|
||||||
const AdmZip = require('adm-zip');
|
const AdmZip = require('adm-zip');
|
||||||
@@ -16,7 +17,7 @@ router.get('/', adminAuth, async (req, res) => {
|
|||||||
|
|
||||||
// Get total count
|
// Get total count
|
||||||
const totalCount = await db('events')
|
const totalCount = await db('events')
|
||||||
.where('is_archived', true)
|
.where('is_archived', formatBoolean(true))
|
||||||
.count('id as count')
|
.count('id as count')
|
||||||
.first();
|
.first();
|
||||||
|
|
||||||
@@ -28,7 +29,7 @@ router.get('/', adminAuth, async (req, res) => {
|
|||||||
db.raw('SUM(photos.size_bytes) as total_size')
|
db.raw('SUM(photos.size_bytes) as total_size')
|
||||||
)
|
)
|
||||||
.leftJoin('photos', 'events.id', 'photos.event_id')
|
.leftJoin('photos', 'events.id', 'photos.event_id')
|
||||||
.where('events.is_archived', true)
|
.where('events.is_archived', formatBoolean(true))
|
||||||
.groupBy('events.id')
|
.groupBy('events.id')
|
||||||
.orderBy('events.archived_at', 'desc')
|
.orderBy('events.archived_at', 'desc')
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
@@ -84,7 +85,7 @@ router.get('/:id', adminAuth, async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const archive = await db('events')
|
const archive = await db('events')
|
||||||
.where('id', req.params.id)
|
.where('id', req.params.id)
|
||||||
.where('is_archived', true)
|
.where('is_archived', formatBoolean(true))
|
||||||
.first();
|
.first();
|
||||||
|
|
||||||
if (!archive) {
|
if (!archive) {
|
||||||
@@ -140,7 +141,7 @@ router.post('/:id/restore', adminAuth, async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const archive = await db('events')
|
const archive = await db('events')
|
||||||
.where('id', req.params.id)
|
.where('id', req.params.id)
|
||||||
.where('is_archived', true)
|
.where('is_archived', formatBoolean(true))
|
||||||
.first();
|
.first();
|
||||||
|
|
||||||
if (!archive) {
|
if (!archive) {
|
||||||
@@ -211,12 +212,14 @@ router.post('/:id/restore', adminAuth, async (req, res) => {
|
|||||||
categoriesMap.set(categoryName, existingCategory.id);
|
categoriesMap.set(categoryName, existingCategory.id);
|
||||||
} else {
|
} else {
|
||||||
// Create the category if it doesn't exist
|
// 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,
|
event_id: archive.id,
|
||||||
name: categoryName,
|
name: categoryName,
|
||||||
slug: categoryName.toLowerCase().replace(/[^a-z0-9]/g, '-'),
|
slug: categoryName.toLowerCase().replace(/[^a-z0-9]/g, '-'),
|
||||||
created_at: new Date()
|
created_at: new Date()
|
||||||
});
|
}).returning('id');
|
||||||
|
|
||||||
|
const newCategoryId = insertResult[0]?.id || insertResult[0];
|
||||||
categoriesMap.set(categoryName, newCategoryId);
|
categoriesMap.set(categoryName, newCategoryId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -266,6 +269,9 @@ router.post('/:id/restore', adminAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update event status
|
// Update event status
|
||||||
|
const thirtyDaysFromNow = new Date();
|
||||||
|
thirtyDaysFromNow.setDate(thirtyDaysFromNow.getDate() + 30);
|
||||||
|
|
||||||
await db('events')
|
await db('events')
|
||||||
.where('id', req.params.id)
|
.where('id', req.params.id)
|
||||||
.update({
|
.update({
|
||||||
@@ -273,7 +279,7 @@ router.post('/:id/restore', adminAuth, async (req, res) => {
|
|||||||
is_active: true,
|
is_active: true,
|
||||||
archive_path: null,
|
archive_path: null,
|
||||||
archived_at: 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
|
// Log activity
|
||||||
@@ -298,7 +304,7 @@ router.get('/:id/download', adminAuth, async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const archive = await db('events')
|
const archive = await db('events')
|
||||||
.where('id', req.params.id)
|
.where('id', req.params.id)
|
||||||
.where('is_archived', true)
|
.where('is_archived', formatBoolean(true))
|
||||||
.first();
|
.first();
|
||||||
|
|
||||||
if (!archive) {
|
if (!archive) {
|
||||||
@@ -347,7 +353,7 @@ router.delete('/:id', adminAuth, async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const archive = await db('events')
|
const archive = await db('events')
|
||||||
.where('id', req.params.id)
|
.where('id', req.params.id)
|
||||||
.where('is_archived', true)
|
.where('is_archived', formatBoolean(true))
|
||||||
.first();
|
.first();
|
||||||
|
|
||||||
if (!archive) {
|
if (!archive) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { body, validationResult } = require('express-validator');
|
const { body, validationResult } = require('express-validator');
|
||||||
const { db, logActivity } = require('../database/db');
|
const { db, logActivity } = require('../database/db');
|
||||||
|
const { formatBoolean } = require('../utils/dbCompat');
|
||||||
const { adminAuth } = require('../middleware/auth-enhanced-v2');
|
const { adminAuth } = require('../middleware/auth-enhanced-v2');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -8,7 +9,7 @@ const router = express.Router();
|
|||||||
router.get('/global', adminAuth, async (req, res) => {
|
router.get('/global', adminAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const categories = await db('photo_categories')
|
const categories = await db('photo_categories')
|
||||||
.where('is_global', true)
|
.where('is_global', formatBoolean(true))
|
||||||
.orderBy('name', 'asc');
|
.orderBy('name', 'asc');
|
||||||
|
|
||||||
res.json(categories);
|
res.json(categories);
|
||||||
@@ -25,7 +26,7 @@ router.get('/event/:eventId', adminAuth, async (req, res) => {
|
|||||||
|
|
||||||
const categories = await db('photo_categories')
|
const categories = await db('photo_categories')
|
||||||
.where(function() {
|
.where(function() {
|
||||||
this.where('is_global', true)
|
this.where('is_global', formatBoolean(true))
|
||||||
.orWhere('event_id', eventId);
|
.orWhere('event_id', eventId);
|
||||||
})
|
})
|
||||||
.orderBy('is_global', 'desc')
|
.orderBy('is_global', 'desc')
|
||||||
@@ -65,7 +66,7 @@ router.post('/', adminAuth, [
|
|||||||
.where('slug', categorySlug)
|
.where('slug', categorySlug)
|
||||||
.where(function() {
|
.where(function() {
|
||||||
if (is_global) {
|
if (is_global) {
|
||||||
this.where('is_global', true);
|
this.where('is_global', formatBoolean(true));
|
||||||
} else {
|
} else {
|
||||||
this.where('event_id', event_id);
|
this.where('event_id', event_id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ const express = require('express');
|
|||||||
const { db } = require('../database/db');
|
const { db } = require('../database/db');
|
||||||
const { adminAuth } = require('../middleware/auth-enhanced-v2');
|
const { adminAuth } = require('../middleware/auth-enhanced-v2');
|
||||||
const { sanitizeDays, addDateRangeCondition } = require('../utils/sqlSecurity');
|
const { sanitizeDays, addDateRangeCondition } = require('../utils/sqlSecurity');
|
||||||
|
const { formatBoolean } = require('../utils/dbCompat');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// Get dashboard statistics
|
// Get dashboard statistics
|
||||||
@@ -9,8 +10,8 @@ router.get('/stats', adminAuth, async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
// Get active events count
|
// Get active events count
|
||||||
const activeEvents = await db('events')
|
const activeEvents = await db('events')
|
||||||
.where('is_active', true)
|
.where('is_active', formatBoolean(true))
|
||||||
.where('is_archived', false)
|
.where('is_archived', formatBoolean(false))
|
||||||
.count('id as count')
|
.count('id as count')
|
||||||
.first();
|
.first();
|
||||||
|
|
||||||
@@ -20,8 +21,8 @@ router.get('/stats', adminAuth, async (req, res) => {
|
|||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
const expiringEvents = await db('events')
|
const expiringEvents = await db('events')
|
||||||
.where('is_active', true)
|
.where('is_active', formatBoolean(true))
|
||||||
.where('is_archived', false)
|
.where('is_archived', formatBoolean(false))
|
||||||
.where('expires_at', '<=', sevenDaysFromNow.toISOString())
|
.where('expires_at', '<=', sevenDaysFromNow.toISOString())
|
||||||
.where('expires_at', '>', now.toISOString())
|
.where('expires_at', '>', now.toISOString())
|
||||||
.count('id as count')
|
.count('id as count')
|
||||||
@@ -56,7 +57,7 @@ router.get('/stats', adminAuth, async (req, res) => {
|
|||||||
|
|
||||||
// Get archived events count
|
// Get archived events count
|
||||||
const archivedEvents = await db('events')
|
const archivedEvents = await db('events')
|
||||||
.where('is_archived', true)
|
.where('is_archived', formatBoolean(true))
|
||||||
.count('id as count')
|
.count('id as count')
|
||||||
.first();
|
.first();
|
||||||
|
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ router.post('/', adminAuth, [
|
|||||||
await fs.mkdir(path.join(eventPath, 'individual'), { recursive: true });
|
await fs.mkdir(path.join(eventPath, 'individual'), { recursive: true });
|
||||||
|
|
||||||
// Insert into database
|
// Insert into database
|
||||||
const [eventId] = await db('events').insert({
|
const insertResult = await db('events').insert({
|
||||||
slug,
|
slug,
|
||||||
event_type,
|
event_type,
|
||||||
event_name,
|
event_name,
|
||||||
@@ -99,7 +99,10 @@ router.post('/', adminAuth, [
|
|||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
allow_user_uploads,
|
allow_user_uploads,
|
||||||
upload_category_id
|
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
|
// Log activity
|
||||||
await logActivity('event_created',
|
await logActivity('event_created',
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const { archiveEvent } = require('../services/archiveService');
|
|||||||
const { escapeLikePattern } = require('../utils/sqlSecurity');
|
const { escapeLikePattern } = require('../utils/sqlSecurity');
|
||||||
const { formatDate } = require('../utils/dateFormatter');
|
const { formatDate } = require('../utils/dateFormatter');
|
||||||
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
|
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
|
||||||
|
const { formatBoolean } = require('../utils/dbCompat');
|
||||||
|
|
||||||
// Create new event
|
// Create new event
|
||||||
router.post('/', adminAuth, [
|
router.post('/', adminAuth, [
|
||||||
@@ -183,17 +184,17 @@ router.get('/', adminAuth, async (req, res) => {
|
|||||||
|
|
||||||
// Apply status filter
|
// Apply status filter
|
||||||
if (status === 'active') {
|
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') {
|
} else if (status === 'archived') {
|
||||||
query = query.where('is_archived', true);
|
query = query.where('is_archived', formatBoolean(true));
|
||||||
} else if (status === 'inactive') {
|
} 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') {
|
} else if (status === 'expiring') {
|
||||||
const sevenDaysFromNow = new Date();
|
const sevenDaysFromNow = new Date();
|
||||||
sevenDaysFromNow.setDate(sevenDaysFromNow.getDate() + 7);
|
sevenDaysFromNow.setDate(sevenDaysFromNow.getDate() + 7);
|
||||||
query = query
|
query = query
|
||||||
.where('is_active', true)
|
.where('is_active', formatBoolean(true))
|
||||||
.where('is_archived', false)
|
.where('is_archived', formatBoolean(false))
|
||||||
.where('expires_at', '<=', sevenDaysFromNow.toISOString())
|
.where('expires_at', '<=', sevenDaysFromNow.toISOString())
|
||||||
.where('expires_at', '>', new Date().toISOString());
|
.where('expires_at', '>', new Date().toISOString());
|
||||||
}
|
}
|
||||||
@@ -554,7 +555,7 @@ router.post('/bulk-archive', adminAuth, [
|
|||||||
// Get all events to archive
|
// Get all events to archive
|
||||||
const events = await db('events')
|
const events = await db('events')
|
||||||
.whereIn('id', eventIds)
|
.whereIn('id', eventIds)
|
||||||
.where('is_archived', false);
|
.where('is_archived', formatBoolean(false));
|
||||||
|
|
||||||
if (events.length === 0) {
|
if (events.length === 0) {
|
||||||
return res.status(400).json({ error: 'No valid events found to archive' });
|
return res.status(400).json({ error: 'No valid events found to archive' });
|
||||||
|
|||||||
@@ -61,7 +61,10 @@ const { validateFileType } = require('../utils/fileSecurityUtils');
|
|||||||
const upload = multer({
|
const upload = multer({
|
||||||
storage: storage,
|
storage: storage,
|
||||||
limits: {
|
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) => {
|
fileFilter: (req, file, cb) => {
|
||||||
// Accept images only with proper validation
|
// Accept images only with proper validation
|
||||||
@@ -85,13 +88,17 @@ const validateUploadContent = createFileUploadValidator({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Upload photos for an event
|
// 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) => {
|
router.post('/:eventId/upload', adminAuth, (req, res, next) => {
|
||||||
upload.array('photos', 20)(req, res, (err) => {
|
upload.array('photos', 500)(req, res, (err) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error('Multer error:', err);
|
console.error('Multer error:', err);
|
||||||
if (err instanceof multer.MulterError) {
|
if (err instanceof multer.MulterError) {
|
||||||
if (err.code === 'LIMIT_FILE_SIZE') {
|
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}` });
|
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 uploadedPhotos = [];
|
||||||
|
const errors = [];
|
||||||
|
|
||||||
// Process each uploaded file
|
// Process files in batches to optimize database operations
|
||||||
for (const file of req.files) {
|
const BATCH_SIZE = 10; // Process 10 files at a time for database operations
|
||||||
let trx;
|
|
||||||
|
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 {
|
try {
|
||||||
// Start transaction for atomic counter update
|
// Get initial counter for this batch
|
||||||
trx = await db.transaction();
|
let batchCounter = 1;
|
||||||
|
|
||||||
// Get and increment the counter for this category
|
|
||||||
let counter = 1;
|
|
||||||
if (category) {
|
if (category) {
|
||||||
// Lock the category row and get current counter
|
|
||||||
const categoryData = await trx('photo_categories')
|
const categoryData = await trx('photo_categories')
|
||||||
.where({ id: parsedCategoryId })
|
.where({ id: parsedCategoryId })
|
||||||
.forUpdate()
|
.forUpdate()
|
||||||
.first();
|
.first();
|
||||||
|
batchCounter = (categoryData.photo_counter || 0) + 1;
|
||||||
counter = (categoryData.photo_counter || 0) + 1;
|
|
||||||
|
|
||||||
// Update counter
|
|
||||||
await trx('photo_categories')
|
|
||||||
.where({ id: parsedCategoryId })
|
|
||||||
.update({ photo_counter: counter });
|
|
||||||
} else {
|
} else {
|
||||||
// For uncategorized photos, count existing uncategorized photos
|
|
||||||
const uncategorizedCount = await trx('photos')
|
const uncategorizedCount = await trx('photos')
|
||||||
.where({ event_id: eventId })
|
.where({ event_id: eventId })
|
||||||
.whereNull('category_id')
|
.whereNull('category_id')
|
||||||
.count('id as count')
|
.count('id as count')
|
||||||
.first();
|
.first();
|
||||||
|
batchCounter = (uncategorizedCount.count || 0) + 1;
|
||||||
counter = (uncategorizedCount.count || 0) + 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate new filename
|
const batchPhotos = [];
|
||||||
const extension = path.extname(file.originalname);
|
|
||||||
const newFilename = generatePhotoFilename(
|
|
||||||
event.event_name,
|
|
||||||
category ? category.name : 'uncategorized',
|
|
||||||
counter,
|
|
||||||
extension
|
|
||||||
);
|
|
||||||
|
|
||||||
// Rename the file
|
for (let fileIndex = 0; fileIndex < batch.length; fileIndex++) {
|
||||||
const oldPath = file.path;
|
const file = batch[fileIndex];
|
||||||
const newPath = path.join(path.dirname(oldPath), newFilename);
|
const counter = batchCounter + fileIndex;
|
||||||
await fs.rename(oldPath, newPath);
|
|
||||||
|
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
|
// Batch insert all photos from this batch
|
||||||
file.filename = newFilename;
|
if (batchPhotos.length > 0) {
|
||||||
file.path = newPath;
|
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
|
// Commit the batch transaction
|
||||||
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
|
|
||||||
await trx.commit();
|
await trx.commit();
|
||||||
|
|
||||||
uploadedPhotos.push({
|
|
||||||
id: photoId,
|
|
||||||
filename: file.filename,
|
|
||||||
size: file.size,
|
|
||||||
category_id: parsedCategoryId || null
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error processing file ${file.filename}:`, error);
|
console.error(`Error processing batch starting at index ${i}:`, error);
|
||||||
if (trx) await trx.rollback();
|
await trx.rollback();
|
||||||
// Continue with other files
|
|
||||||
|
// 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 }
|
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||||
);
|
);
|
||||||
|
|
||||||
res.json({
|
// Prepare response
|
||||||
|
const response = {
|
||||||
message: `Successfully uploaded ${uploadedPhotos.length} photos`,
|
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) {
|
} catch (error) {
|
||||||
console.error('Error uploading photos:', error);
|
console.error('Error uploading photos:', error);
|
||||||
res.status(500).json({ error: 'Failed to upload photos' });
|
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 => ({
|
photos: photos.map(photo => ({
|
||||||
id: photo.id,
|
id: photo.id,
|
||||||
filename: photo.filename,
|
filename: photo.filename,
|
||||||
url: `/api/admin/events/${eventId}/photo/${photo.id}`,
|
url: `/admin/events/${eventId}/photo/${photo.id}`,
|
||||||
thumbnail_url: photo.thumbnail_path ? `/api/admin/events/${eventId}/thumbnail/${photo.id}` : null,
|
thumbnail_url: photo.thumbnail_path ? `/admin/events/${eventId}/thumbnail/${photo.id}` : null,
|
||||||
type: photo.type,
|
type: photo.type,
|
||||||
category_id: photo.category_id,
|
category_id: photo.category_id,
|
||||||
category_name: photo.category_name,
|
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;
|
module.exports = router;
|
||||||
@@ -4,6 +4,7 @@ const path = require('path');
|
|||||||
const fs = require('fs').promises;
|
const fs = require('fs').promises;
|
||||||
const { body, validationResult } = require('express-validator');
|
const { body, validationResult } = require('express-validator');
|
||||||
const { db, logActivity } = require('../database/db');
|
const { db, logActivity } = require('../database/db');
|
||||||
|
const { formatBoolean } = require('../utils/dbCompat');
|
||||||
const { adminAuth } = require('../middleware/auth');
|
const { adminAuth } = require('../middleware/auth');
|
||||||
const { clearMaintenanceCache } = require('../middleware/maintenance');
|
const { clearMaintenanceCache } = require('../middleware/maintenance');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@@ -513,7 +514,7 @@ router.get('/storage/info', adminAuth, async (req, res) => {
|
|||||||
|
|
||||||
// Get archive storage
|
// Get archive storage
|
||||||
const archives = await db('events')
|
const archives = await db('events')
|
||||||
.where('is_archived', true)
|
.where('is_archived', formatBoolean(true))
|
||||||
.whereNotNull('archive_path')
|
.whereNotNull('archive_path')
|
||||||
.select('archive_path');
|
.select('archive_path');
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ const bcrypt = require('bcrypt');
|
|||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
const { body, validationResult } = require('express-validator');
|
const { body, validationResult } = require('express-validator');
|
||||||
const { db } = require('../database/db');
|
const { db } = require('../database/db');
|
||||||
|
const { formatBoolean } = require('../utils/dbCompat');
|
||||||
const { verifyRecaptcha } = require('../services/recaptcha');
|
const { verifyRecaptcha } = require('../services/recaptcha');
|
||||||
const {
|
const {
|
||||||
trackFailedAttempt,
|
trackFailedAttempt,
|
||||||
@@ -248,7 +249,7 @@ router.post('/gallery/verify', [
|
|||||||
return res.status(400).json({ error: 'reCAPTCHA verification failed' });
|
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) {
|
if (!event) {
|
||||||
// Don't reveal if gallery exists
|
// Don't reveal if gallery exists
|
||||||
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
|
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ const bcrypt = require('bcrypt');
|
|||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
const { body, validationResult } = require('express-validator');
|
const { body, validationResult } = require('express-validator');
|
||||||
const { db } = require('../database/db');
|
const { db } = require('../database/db');
|
||||||
|
const { formatBoolean } = require('../utils/dbCompat');
|
||||||
const { verifyRecaptcha } = require('../services/recaptcha');
|
const { verifyRecaptcha } = require('../services/recaptcha');
|
||||||
const {
|
const {
|
||||||
trackFailedAttempt,
|
trackFailedAttempt,
|
||||||
@@ -167,7 +168,7 @@ router.post('/gallery/verify', [
|
|||||||
return res.status(400).json({ error: 'reCAPTCHA verification failed' });
|
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) {
|
if (!event) {
|
||||||
// Don't reveal if gallery exists
|
// Don't reveal if gallery exists
|
||||||
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
|
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ const bcrypt = require('bcrypt');
|
|||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
const { body, validationResult } = require('express-validator');
|
const { body, validationResult } = require('express-validator');
|
||||||
const { db } = require('../database/db');
|
const { db } = require('../database/db');
|
||||||
|
const { formatBoolean } = require('../utils/dbCompat');
|
||||||
const { verifyRecaptcha } = require('../services/recaptcha');
|
const { verifyRecaptcha } = require('../services/recaptcha');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -76,7 +77,7 @@ router.post('/gallery/verify', [
|
|||||||
return res.status(400).json({ error: 'reCAPTCHA verification failed' });
|
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) {
|
if (!event) {
|
||||||
return res.status(404).json({ error: 'Gallery not found or expired' });
|
return res.status(404).json({ error: 'Gallery not found or expired' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ const { body, validationResult } = require('express-validator');
|
|||||||
const bcrypt = require('bcrypt');
|
const bcrypt = require('bcrypt');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const { db } = require('../database/db');
|
const { db } = require('../database/db');
|
||||||
|
const { formatBoolean } = require('../utils/dbCompat');
|
||||||
const { adminAuth } = require('../middleware/auth-enhanced-v2');
|
const { adminAuth } = require('../middleware/auth-enhanced-v2');
|
||||||
const fs = require('fs').promises;
|
const fs = require('fs').promises;
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
@@ -64,7 +65,7 @@ router.post('/', adminAuth, [
|
|||||||
await fs.mkdir(path.join(eventPath, 'individual'), { recursive: true });
|
await fs.mkdir(path.join(eventPath, 'individual'), { recursive: true });
|
||||||
|
|
||||||
// Insert into database
|
// Insert into database
|
||||||
const [eventId] = await db('events').insert({
|
const insertResult = await db('events').insert({
|
||||||
slug,
|
slug,
|
||||||
event_type,
|
event_type,
|
||||||
event_name,
|
event_name,
|
||||||
@@ -76,7 +77,10 @@ router.post('/', adminAuth, [
|
|||||||
color_theme,
|
color_theme,
|
||||||
share_link: shareLink,
|
share_link: shareLink,
|
||||||
expires_at
|
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
|
// Queue creation email
|
||||||
const { queueEmail } = require('../services/emailProcessor');
|
const { queueEmail } = require('../services/emailProcessor');
|
||||||
@@ -110,9 +114,9 @@ router.get('/', adminAuth, async (req, res) => {
|
|||||||
let query = db('events').select('*');
|
let query = db('events').select('*');
|
||||||
|
|
||||||
if (status === 'active') {
|
if (status === 'active') {
|
||||||
query = query.where('is_active', true);
|
query = query.where('is_active', formatBoolean(true));
|
||||||
} else if (status === 'archived') {
|
} 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');
|
const events = await query.orderBy('created_at', 'desc');
|
||||||
@@ -159,7 +163,7 @@ router.delete('/:id', adminAuth, async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
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 });
|
res.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -185,7 +189,7 @@ router.post('/:id/extend', adminAuth, [
|
|||||||
|
|
||||||
await db('events').where('id', id).update({
|
await db('events').where('id', id).update({
|
||||||
expires_at: newExpiration,
|
expires_at: newExpiration,
|
||||||
is_active: true // Reactivate if expired
|
is_active: formatBoolean(true) // Reactivate if expired
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({ expires_at: newExpiration });
|
res.json({ expires_at: newExpiration });
|
||||||
|
|||||||
@@ -1,46 +1,23 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
const { db } = require('../database/db');
|
const { db } = require('../database/db');
|
||||||
|
const { formatBoolean } = require('../utils/dbCompat');
|
||||||
const archiver = require('archiver');
|
const archiver = require('archiver');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const watermarkService = require('../services/watermarkService');
|
const watermarkService = require('../services/watermarkService');
|
||||||
|
const { verifyGalleryAccess } = require('../middleware/gallery');
|
||||||
|
|
||||||
// Get storage path from environment or default
|
// Get storage path from environment or default
|
||||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
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
|
// Verify share token
|
||||||
router.get('/:slug/verify-token/:token', async (req, res) => {
|
router.get('/:slug/verify-token/:token', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { slug, token } = req.params;
|
const { slug, token } = req.params;
|
||||||
|
|
||||||
const event = await db('events')
|
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')
|
.select('id', 'share_link')
|
||||||
.first();
|
.first();
|
||||||
|
|
||||||
@@ -83,7 +60,11 @@ router.get('/:slug/info', async (req, res) => {
|
|||||||
|
|
||||||
// If token provided, verify it matches the share link
|
// If token provided, verify it matches the share link
|
||||||
if (token) {
|
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) {
|
if (token !== expectedToken) {
|
||||||
return res.status(404).json({ error: 'Invalid gallery link' });
|
return res.status(404).json({ error: 'Invalid gallery link' });
|
||||||
}
|
}
|
||||||
@@ -121,7 +102,7 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
|
|||||||
// Get all categories for this event
|
// Get all categories for this event
|
||||||
const categories = await db('photo_categories')
|
const categories = await db('photo_categories')
|
||||||
.where(function() {
|
.where(function() {
|
||||||
this.where('is_global', true)
|
this.where('is_global', formatBoolean(true))
|
||||||
.orWhere('event_id', req.event.id);
|
.orWhere('event_id', req.event.id);
|
||||||
})
|
})
|
||||||
.orderBy('is_global', 'desc')
|
.orderBy('is_global', 'desc')
|
||||||
@@ -156,7 +137,7 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
|
|||||||
id: photo.id,
|
id: photo.id,
|
||||||
filename: photo.filename,
|
filename: photo.filename,
|
||||||
url: `/photos/${photo.path}`,
|
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,
|
type: photo.type,
|
||||||
category_id: photo.category_id,
|
category_id: photo.category_id,
|
||||||
category_name: photo.category_name,
|
category_name: photo.category_name,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { db } = require('../database/db');
|
const { db } = require('../database/db');
|
||||||
|
const { formatBoolean } = require('../utils/dbCompat');
|
||||||
const { verifyGalleryAccess } = require('../middleware/gallery');
|
const { verifyGalleryAccess } = require('../middleware/gallery');
|
||||||
const watermarkService = require('../services/watermarkService');
|
const watermarkService = require('../services/watermarkService');
|
||||||
const { getStoragePath } = require('../config/storage');
|
const { getStoragePath } = require('../config/storage');
|
||||||
@@ -141,7 +142,7 @@ router.get('/:slug/photo/:photoId/signed/:token', async (req, res) => {
|
|||||||
// Get event
|
// Get event
|
||||||
const event = await db('events')
|
const event = await db('events')
|
||||||
.where({ slug })
|
.where({ slug })
|
||||||
.where('is_active', true)
|
.where('is_active', formatBoolean(true))
|
||||||
.first();
|
.first();
|
||||||
|
|
||||||
if (!event) {
|
if (!event) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ const { archiveEvent } = require('./archiveService');
|
|||||||
const { queueEmail } = require('./emailProcessor');
|
const { queueEmail } = require('./emailProcessor');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
const { formatDate } = require('../utils/dateFormatter');
|
const { formatDate } = require('../utils/dateFormatter');
|
||||||
|
const { formatBoolean } = require('../utils/dbCompat');
|
||||||
|
|
||||||
function startExpirationChecker() {
|
function startExpirationChecker() {
|
||||||
// Check every hour for expired events and warnings
|
// Check every hour for expired events and warnings
|
||||||
@@ -21,8 +22,8 @@ async function checkExpirations() {
|
|||||||
|
|
||||||
// Check for events needing warning emails
|
// Check for events needing warning emails
|
||||||
const eventsNeedingWarning = await db('events')
|
const eventsNeedingWarning = await db('events')
|
||||||
.where('is_active', true)
|
.where('is_active', formatBoolean(true))
|
||||||
.where('is_archived', false)
|
.where('is_archived', formatBoolean(false))
|
||||||
.where('expires_at', '<=', warningDate)
|
.where('expires_at', '<=', warningDate)
|
||||||
.where('expires_at', '>', now);
|
.where('expires_at', '>', now);
|
||||||
|
|
||||||
@@ -40,8 +41,8 @@ async function checkExpirations() {
|
|||||||
|
|
||||||
// Check for expired events
|
// Check for expired events
|
||||||
const expiredEvents = await db('events')
|
const expiredEvents = await db('events')
|
||||||
.where('is_active', true)
|
.where('is_active', formatBoolean(true))
|
||||||
.where('is_archived', false)
|
.where('is_archived', formatBoolean(false))
|
||||||
.where('expires_at', '<=', now);
|
.where('expires_at', '<=', now);
|
||||||
|
|
||||||
for (const event of expiredEvents) {
|
for (const event of expiredEvents) {
|
||||||
@@ -74,7 +75,7 @@ async function queueExpirationWarning(event) {
|
|||||||
async function handleExpiredEvent(event) {
|
async function handleExpiredEvent(event) {
|
||||||
try {
|
try {
|
||||||
// Mark as inactive
|
// 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
|
// Queue expiration emails
|
||||||
await queueEmail(event.id, event.host_email, 'gallery_expired', {
|
await queueEmail(event.id, event.host_email, 'gallery_expired', {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ const chokidar = require('chokidar');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs').promises;
|
const fs = require('fs').promises;
|
||||||
const { db } = require('../database/db');
|
const { db } = require('../database/db');
|
||||||
|
const { formatBoolean } = require('../utils/dbCompat');
|
||||||
const { generateThumbnail } = require('./imageProcessor');
|
const { generateThumbnail } = require('./imageProcessor');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
@@ -51,7 +52,7 @@ async function processNewPhoto(filePath) {
|
|||||||
if (!['.jpg', '.jpeg', '.png', '.webp'].includes(ext)) return;
|
if (!['.jpg', '.jpeg', '.png', '.webp'].includes(ext)) return;
|
||||||
|
|
||||||
// Find the event
|
// 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;
|
if (!event) return;
|
||||||
|
|
||||||
// Get file stats
|
// Get file stats
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ const sharp = require('sharp');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs').promises;
|
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 THUMBNAIL_WIDTH = 300;
|
||||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||||
const getThumbnailPath = () => path.join(getStoragePath(), 'thumbnails');
|
const getThumbnailPath = () => path.join(getStoragePath(), 'thumbnails');
|
||||||
@@ -15,16 +19,29 @@ async function generateThumbnail(imagePath) {
|
|||||||
// Ensure thumbnail directory exists
|
// Ensure thumbnail directory exists
|
||||||
await fs.mkdir(thumbnailDir, { recursive: true });
|
await fs.mkdir(thumbnailDir, { recursive: true });
|
||||||
|
|
||||||
// Generate thumbnail
|
try {
|
||||||
await sharp(imagePath)
|
// Generate thumbnail with memory-efficient settings
|
||||||
.resize(THUMBNAIL_WIDTH, null, {
|
await sharp(imagePath, {
|
||||||
withoutEnlargement: true,
|
limitInputPixels: 268402689, // ~16k x 16k max
|
||||||
fit: 'inside'
|
sequentialRead: true // More memory efficient for large images
|
||||||
})
|
})
|
||||||
.jpeg({ quality: 80 })
|
.resize(THUMBNAIL_WIDTH, null, {
|
||||||
.toFile(thumbnailPath);
|
withoutEnlargement: true,
|
||||||
|
fit: 'inside'
|
||||||
return path.relative(getStoragePath(), thumbnailPath);
|
})
|
||||||
|
.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 };
|
module.exports = { generateThumbnail };
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const { db } = require('../database/db');
|
const { db } = require('../database/db');
|
||||||
|
const { formatBoolean } = require('./dbCompat');
|
||||||
const logger = require('./logger');
|
const logger = require('./logger');
|
||||||
|
|
||||||
// Configuration constants
|
// Configuration constants
|
||||||
@@ -59,7 +60,7 @@ async function trackSuccessfulLogin(identifier, ipAddress, userAgent) {
|
|||||||
const cutoffTime = new Date(Date.now() - ATTEMPT_WINDOW);
|
const cutoffTime = new Date(Date.now() - ATTEMPT_WINDOW);
|
||||||
await db('login_attempts')
|
await db('login_attempts')
|
||||||
.where('identifier', identifier)
|
.where('identifier', identifier)
|
||||||
.where('success', false)
|
.where('success', formatBoolean(false))
|
||||||
.where('attempt_time', '<', cutoffTime.toISOString())
|
.where('attempt_time', '<', cutoffTime.toISOString())
|
||||||
.delete();
|
.delete();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -79,7 +80,7 @@ async function checkAccountLockout(identifier) {
|
|||||||
// Get recent failed attempts
|
// Get recent failed attempts
|
||||||
const failedAttempts = await db('login_attempts')
|
const failedAttempts = await db('login_attempts')
|
||||||
.where('identifier', identifier)
|
.where('identifier', identifier)
|
||||||
.where('success', false)
|
.where('success', formatBoolean(false))
|
||||||
.where('attempt_time', '>=', recentWindow.toISOString())
|
.where('attempt_time', '>=', recentWindow.toISOString())
|
||||||
.orderBy('attempt_time', 'desc')
|
.orderBy('attempt_time', 'desc')
|
||||||
.limit(MAX_LOGIN_ATTEMPTS);
|
.limit(MAX_LOGIN_ATTEMPTS);
|
||||||
|
|||||||
@@ -11,7 +11,21 @@ async function formatDate(date, language = 'en') {
|
|||||||
try {
|
try {
|
||||||
// Get date format setting from database
|
// Get date format setting from database
|
||||||
const setting = await db('app_settings').where('setting_key', 'general_date_format').first();
|
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);
|
const dateObj = date instanceof Date ? date : new Date(date);
|
||||||
|
|
||||||
|
|||||||
@@ -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 |
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-frontend",
|
"name": "picpeak-frontend",
|
||||||
"version": "1.0.26",
|
"version": "1.0.34",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "picpeak-frontend",
|
"name": "picpeak-frontend",
|
||||||
"version": "1.0.26",
|
"version": "1.0.34",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.0.0",
|
"@tanstack/react-query": "^5.0.0",
|
||||||
"@tiptap/extension-link": "^2.25.0",
|
"@tiptap/extension-link": "^2.25.0",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-frontend",
|
"name": "picpeak-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.26",
|
"version": "1.0.34",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
|||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
||||||
const [uploadProgress, setUploadProgress] = useState(0);
|
const [uploadProgress, setUploadProgress] = useState(0);
|
||||||
|
const [currentChunk, setCurrentChunk] = useState(0);
|
||||||
|
const [totalChunks, setTotalChunks] = useState(0);
|
||||||
const [selectedCategoryId, setSelectedCategoryId] = useState<number | null>(null);
|
const [selectedCategoryId, setSelectedCategoryId] = useState<number | null>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
@@ -32,6 +34,20 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
|||||||
const imageFiles = files.filter(file =>
|
const imageFiles = files.filter(file =>
|
||||||
['image/jpeg', 'image/png', 'image/webp'].includes(file.type)
|
['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]);
|
setSelectedFiles(prev => [...prev, ...imageFiles]);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -41,38 +57,64 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
|||||||
|
|
||||||
const handleUpload = async () => {
|
const handleUpload = async () => {
|
||||||
if (selectedFiles.length === 0) return;
|
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);
|
setIsUploading(true);
|
||||||
setUploadProgress(0);
|
setUploadProgress(0);
|
||||||
|
|
||||||
const formData = new FormData();
|
// For large uploads, chunk the files to prevent memory issues
|
||||||
selectedFiles.forEach((file, index) => {
|
const CHUNK_SIZE = 50; // Upload 50 files at a time
|
||||||
console.log(`Adding file ${index}: ${file.name}, size: ${file.size}`);
|
const chunks = [];
|
||||||
formData.append('photos', file);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (selectedCategoryId) {
|
for (let i = 0; i < selectedFiles.length; i += CHUNK_SIZE) {
|
||||||
formData.append('category_id', selectedCategoryId.toString());
|
chunks.push(selectedFiles.slice(i, i + CHUNK_SIZE));
|
||||||
}
|
|
||||||
|
|
||||||
// Debug: Log FormData contents
|
|
||||||
console.log('FormData entries:');
|
|
||||||
for (let pair of formData.entries()) {
|
|
||||||
console.log(pair[0], pair[1]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setTotalChunks(chunks.length);
|
||||||
|
let totalUploaded = 0;
|
||||||
|
let failedFiles = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await api.post(`/admin/events/${eventId}/upload`, formData, {
|
for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
|
||||||
// Don't set Content-Type header - axios will set it with the boundary
|
setCurrentChunk(chunkIndex + 1);
|
||||||
onUploadProgress: (progressEvent) => {
|
const chunk = chunks[chunkIndex];
|
||||||
if (progressEvent.total) {
|
const formData = new FormData();
|
||||||
const progress = Math.round((progressEvent.loaded * 100) / progressEvent.total);
|
|
||||||
setUploadProgress(progress);
|
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
|
// Clear selected files
|
||||||
setSelectedFiles([]);
|
setSelectedFiles([]);
|
||||||
@@ -80,8 +122,15 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
|||||||
fileInputRef.current.value = '';
|
fileInputRef.current.value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show success message
|
// Show appropriate message
|
||||||
toast.success(t('toast.uploadSuccess'));
|
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
|
// Call callback
|
||||||
if (onUploadComplete) {
|
if (onUploadComplete) {
|
||||||
@@ -93,6 +142,8 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
|||||||
} finally {
|
} finally {
|
||||||
setIsUploading(false);
|
setIsUploading(false);
|
||||||
setUploadProgress(0);
|
setUploadProgress(0);
|
||||||
|
setCurrentChunk(0);
|
||||||
|
setTotalChunks(0);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -203,7 +254,10 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
|||||||
{isUploading && (
|
{isUploading && (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<div className="flex justify-between text-sm text-neutral-600 mb-1">
|
<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>
|
<span>{uploadProgress}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full bg-neutral-200 rounded-full h-2">
|
<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}%` }}
|
style={{ width: `${uploadProgress}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
|||||||
const { watermarkEnabled } = useWatermarkSettings();
|
const { watermarkEnabled } = useWatermarkSettings();
|
||||||
|
|
||||||
// Fetch photos
|
// Fetch photos
|
||||||
const { data, isLoading, error } = useGalleryPhotos(slug);
|
const { data, isLoading, error, refetch } = useGalleryPhotos(slug);
|
||||||
|
|
||||||
// Debug logging
|
// Debug logging
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -294,11 +294,20 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (error || !data) {
|
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 (
|
return (
|
||||||
<div className="min-h-screen bg-neutral-50 flex items-center justify-center">
|
<div className="min-h-screen bg-neutral-50 flex items-center justify-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<p className="text-lg text-neutral-600">{t('gallery.failedToLoad')}</p>
|
<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')}
|
{t('gallery.tryAgain')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+37
-14
@@ -32,14 +32,24 @@ api.interceptors.request.use(
|
|||||||
config.headers.Authorization = `Bearer ${token}`;
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// For gallery routes, get the slug from the URL path
|
// For gallery routes, try to extract slug from the request URL first
|
||||||
const pathParts = window.location.pathname.split('/');
|
const galleryMatch = config.url?.match(/\/gallery\/([^\/]+)/);
|
||||||
if (pathParts[1] === 'gallery' && pathParts[2]) {
|
if (galleryMatch && galleryMatch[1]) {
|
||||||
const gallerySlug = pathParts[2];
|
const gallerySlug = galleryMatch[1];
|
||||||
const token = localStorage.getItem(`gallery_token_${gallerySlug}`);
|
const token = localStorage.getItem(`gallery_token_${gallerySlug}`);
|
||||||
if (token) {
|
if (token) {
|
||||||
config.headers.Authorization = `Bearer ${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) {
|
if (error.response?.status === 401) {
|
||||||
// Redirect to appropriate login
|
// Check if it's an admin route
|
||||||
const isAdminRoute = error.config?.url?.includes('/admin');
|
const isAdminRoute = error.config?.url?.includes('/admin');
|
||||||
|
const currentPath = window.location.pathname;
|
||||||
|
|
||||||
if (isAdminRoute) {
|
if (isAdminRoute) {
|
||||||
// Clear admin token on unauthorized
|
// Clear admin token on unauthorized
|
||||||
Cookies.remove(ADMIN_TOKEN_KEY);
|
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 {
|
} else {
|
||||||
// For gallery routes, clear gallery-specific token and redirect
|
// For gallery routes, check if the error is from a gallery API call
|
||||||
const currentPath = window.location.pathname;
|
const galleryMatch = error.config?.url?.match(/\/gallery\/([^\/]+)/);
|
||||||
const pathParts = currentPath.split('/');
|
|
||||||
if (pathParts[1] === 'gallery' && pathParts[2]) {
|
// Don't redirect if we're on any gallery page (to avoid redirect loops during login)
|
||||||
const gallerySlug = pathParts[2];
|
if (currentPath.startsWith('/gallery/')) {
|
||||||
localStorage.removeItem(`gallery_token_${gallerySlug}`);
|
// If we have a gallery match from the API URL, clear that specific gallery's token
|
||||||
localStorage.removeItem(`gallery_event_${gallerySlug}`);
|
if (galleryMatch && galleryMatch[1]) {
|
||||||
window.location.href = `/gallery/${gallerySlug}`;
|
const gallerySlug = galleryMatch[1];
|
||||||
|
localStorage.removeItem(`gallery_token_${gallerySlug}`);
|
||||||
|
localStorage.removeItem(`gallery_event_${gallerySlug}`);
|
||||||
|
}
|
||||||
|
// Don't redirect - let the component handle the auth state
|
||||||
|
} else {
|
||||||
|
// We're not on a gallery page but got a 401 from a gallery API
|
||||||
|
// This shouldn't happen in normal flow, but if it does, redirect to homepage
|
||||||
|
window.location.href = '/';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ export const useGalleryPhotos = (slug: string, enabled: boolean = true) => {
|
|||||||
enabled,
|
enabled,
|
||||||
retry: 1,
|
retry: 1,
|
||||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||||
|
// Add a small delay to ensure auth token is properly set
|
||||||
|
retryDelay: 100,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,10 @@
|
|||||||
"uploadComplete": "Upload abgeschlossen!",
|
"uploadComplete": "Upload abgeschlossen!",
|
||||||
"uploadFailed": "Upload fehlgeschlagen",
|
"uploadFailed": "Upload fehlgeschlagen",
|
||||||
"someFilesFailed": "Einige Dateien konnten nicht hochgeladen werden",
|
"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": {
|
"navigation": {
|
||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
|
|||||||
@@ -47,7 +47,10 @@
|
|||||||
"uploadComplete": "Upload complete!",
|
"uploadComplete": "Upload complete!",
|
||||||
"uploadFailed": "Upload failed",
|
"uploadFailed": "Upload failed",
|
||||||
"someFilesFailed": "Some files failed to upload",
|
"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": {
|
"navigation": {
|
||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
|
|||||||
Reference in New Issue
Block a user