Fix language setting not being saved to database on admin settings page

- Added default_language field to general settings state in SettingsPage
- Replaced LanguageSelector component with simple select dropdown on settings page
- Fixed public settings endpoint to read general_default_language from database
- Language setting now properly saved when clicking Save Settings button
- Setting is correctly used by gallery login page and legal pages

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

Co-Authored-By: Claude <[email protected]>
This commit is contained in:
2025-07-08 09:49:45 +02:00
co-authored by Claude
parent cfa0b0da69
commit 2012b0bab9
91 changed files with 6183 additions and 577 deletions
Binary file not shown.
View File
@@ -0,0 +1,101 @@
const { db } = require('../src/database/db');
async function up() {
console.log('Adding photo categories and CMS tables...');
// Create photo_categories table
await db.schema.createTable('photo_categories', (table) => {
table.increments('id').primary();
table.string('name', 100).notNullable();
table.string('slug', 100).notNullable();
table.boolean('is_global').defaultTo(true);
table.integer('event_id').references('id').inTable('events').onDelete('CASCADE');
table.timestamp('created_at').defaultTo(db.fn.now());
// Unique constraint for slug within event scope
table.unique(['slug', 'event_id']);
});
// Create cms_pages table
await db.schema.createTable('cms_pages', (table) => {
table.increments('id').primary();
table.string('slug', 100).unique().notNullable();
table.text('title_en');
table.text('title_de');
table.text('content_en');
table.text('content_de');
table.timestamp('updated_at').defaultTo(db.fn.now());
});
// Add category_id to photos table
await db.schema.alterTable('photos', (table) => {
table.integer('category_id').references('id').inTable('photo_categories');
});
// Add language preference to admin_users
await db.schema.alterTable('admin_users', (table) => {
table.string('language', 2).defaultTo('en');
});
// Add language preference to app_settings for global default
await db('app_settings').insert({
setting_key: 'default_language',
setting_value: 'en',
setting_type: 'general',
updated_at: new Date()
});
// Insert default global categories
const defaultCategories = [
{ name: 'Ceremony', slug: 'ceremony', is_global: true },
{ name: 'Reception', slug: 'reception', is_global: true },
{ name: 'Portraits', slug: 'portraits', is_global: true },
{ name: 'Group Photos', slug: 'group-photos', is_global: true },
{ name: 'Details', slug: 'details', is_global: true },
{ name: 'Party', slug: 'party', is_global: true }
];
await db('photo_categories').insert(defaultCategories);
// Insert default legal pages
await db('cms_pages').insert([
{
slug: 'impressum',
title_en: 'Legal Notice',
title_de: 'Impressum',
content_en: '<h2>Legal Notice</h2><p>Please edit this content in the admin panel.</p>',
content_de: '<h2>Impressum</h2><p>Bitte bearbeiten Sie diesen Inhalt im Admin-Panel.</p>',
updated_at: new Date()
},
{
slug: 'datenschutz',
title_en: 'Privacy Policy',
title_de: 'Datenschutzerklärung',
content_en: '<h2>Privacy Policy</h2><p>Please edit this content in the admin panel.</p>',
content_de: '<h2>Datenschutzerklärung</h2><p>Bitte bearbeiten Sie diesen Inhalt im Admin-Panel.</p>',
updated_at: new Date()
}
]);
console.log('Photo categories and CMS tables created successfully');
}
async function down() {
// Remove language from app_settings
await db('app_settings').where('setting_key', 'default_language').delete();
// Drop columns
await db.schema.alterTable('admin_users', (table) => {
table.dropColumn('language');
});
await db.schema.alterTable('photos', (table) => {
table.dropColumn('category_id');
});
// Drop tables
await db.schema.dropTableIfExists('cms_pages');
await db.schema.dropTableIfExists('photo_categories');
}
module.exports = { up, down };
@@ -0,0 +1,28 @@
exports.up = async function(knex) {
// Add photo_counter column to photo_categories table
await knex.schema.alterTable('photo_categories', function(table) {
table.integer('photo_counter').defaultTo(0).notNullable();
});
// Initialize counters based on existing photos
const categories = await knex('photo_categories').select('id');
for (const category of categories) {
const photoCount = await knex('photos')
.where('category_id', category.id)
.count('id as count')
.first();
if (photoCount && photoCount.count > 0) {
await knex('photo_categories')
.where('id', category.id)
.update({ photo_counter: photoCount.count });
}
}
};
exports.down = async function(knex) {
await knex.schema.alterTable('photo_categories', function(table) {
table.dropColumn('photo_counter');
});
};
+91
View File
@@ -0,0 +1,91 @@
const fs = require('fs').promises;
const path = require('path');
const { db } = require('../src/database/db');
// Create migrations table if it doesn't exist
async function createMigrationsTable() {
const tableExists = await db.schema.hasTable('migrations');
if (!tableExists) {
await db.schema.createTable('migrations', (table) => {
table.increments('id').primary();
table.string('filename').unique().notNullable();
table.timestamp('applied_at').defaultTo(db.fn.now());
});
console.log('Created migrations table');
}
}
// Get list of applied migrations
async function getAppliedMigrations() {
const migrations = await db('migrations').select('filename');
return migrations.map(m => m.filename);
}
// Run a single migration
async function runMigration(filename) {
const migrationPath = path.join(__dirname, filename);
const migration = require(migrationPath);
if (migration.up) {
console.log(`Running migration: ${filename}`);
await migration.up();
await db('migrations').insert({ filename });
console.log(`Migration ${filename} completed`);
}
}
// Main migration runner
async function runMigrations() {
try {
console.log('Starting database migrations...');
// First run the init.js if it exists
const initPath = path.join(__dirname, 'init.js');
if (require.resolve(initPath)) {
console.log('Running initial setup...');
require(initPath);
// Wait a bit for init to complete
await new Promise(resolve => setTimeout(resolve, 2000));
}
// Create migrations table
await createMigrationsTable();
// Get all migration files
const files = await fs.readdir(__dirname);
const migrationFiles = files
.filter(f => f.match(/^\d{3}_.*\.js$/))
.sort();
// Get applied migrations
const appliedMigrations = await getAppliedMigrations();
// Run pending migrations
let pendingCount = 0;
for (const file of migrationFiles) {
if (!appliedMigrations.includes(file)) {
await runMigration(file);
pendingCount++;
}
}
if (pendingCount === 0) {
console.log('No pending migrations');
} else {
console.log(`Applied ${pendingCount} migration(s)`);
}
console.log('All migrations completed successfully');
process.exit(0);
} catch (error) {
console.error('Migration failed:', error);
process.exit(1);
}
}
// Only run if called directly
if (require.main === module) {
runMigrations();
}
module.exports = { runMigrations };
+143 -4
View File
@@ -17,14 +17,19 @@
"express-rate-limit": "^6.7.0",
"express-validator": "^7.0.1",
"helmet": "^7.0.0",
"i18next": "^25.3.1",
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",
"joi": "^17.9.1",
"jsonwebtoken": "^9.0.0",
"knex": "^2.4.2",
"multer": "^1.4.5-lts.1",
"node-cron": "^3.0.2",
"nodemailer": "^6.9.1",
"react-i18next": "^15.6.0",
"sharp": "^0.32.0",
"sqlite3": "^5.1.6",
"uuid": "^11.1.0",
"winston": "^3.8.2"
},
"devDependencies": {
@@ -509,6 +514,15 @@
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/runtime": {
"version": "7.27.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz",
"integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": {
"version": "7.27.2",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
@@ -2777,6 +2791,15 @@
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
"node_modules/cross-fetch": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz",
"integrity": "sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==",
"license": "MIT",
"dependencies": {
"node-fetch": "^2.6.12"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -4034,6 +4057,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/html-parse-stringify": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
"license": "MIT",
"dependencies": {
"void-elements": "3.1.0"
}
},
"node_modules/http-cache-semantics": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
@@ -4105,6 +4137,55 @@
"ms": "^2.0.0"
}
},
"node_modules/i18next": {
"version": "25.3.1",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.3.1.tgz",
"integrity": "sha512-S4CPAx8LfMOnURnnJa8jFWvur+UX/LWcl6+61p9VV7SK2m0445JeBJ6tLD0D5SR0H29G4PYfWkEhivKG5p4RDg==",
"funding": [
{
"type": "individual",
"url": "https://locize.com"
},
{
"type": "individual",
"url": "https://locize.com/i18next.html"
},
{
"type": "individual",
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
}
],
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.27.6"
},
"peerDependencies": {
"typescript": "^5"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/i18next-browser-languagedetector": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.0.tgz",
"integrity": "sha512-P+3zEKLnOF0qmiesW383vsLdtQVyKtCNA9cjSoKCppTKPQVfKd2W8hbVo5ZhNJKDqeM7BOcvNoKJOjpHh4Js9g==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.23.2"
}
},
"node_modules/i18next-http-backend": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-3.0.2.tgz",
"integrity": "sha512-PdlvPnvIp4E1sYi46Ik4tBYh/v/NbYfFFgTjkwFl0is8A18s7/bx9aXqsrOax9WUbeNS6mD2oix7Z0yGGf6m5g==",
"license": "MIT",
"dependencies": {
"cross-fetch": "4.0.0"
}
},
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
@@ -5926,6 +6007,15 @@
"node": ">=6.0.0"
}
},
"node_modules/node-cron/node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
@@ -6762,6 +6852,42 @@
"node": ">=0.10.0"
}
},
"node_modules/react": {
"version": "19.1.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-i18next": {
"version": "15.6.0",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.6.0.tgz",
"integrity": "sha512-W135dB0rDfiFmbMipC17nOhGdttO5mzH8BivY+2ybsQBbXvxWIwl3cmeH3T9d+YPBSJu/ouyJKFJTtkK7rJofw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.27.6",
"html-parse-stringify": "^3.0.1"
},
"peerDependencies": {
"i18next": ">= 23.2.3",
"react": ">= 16.8.0",
"typescript": "^5"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"node_modules/react-is": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
@@ -8014,12 +8140,16 @@
}
},
"node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
"uuid": "dist/esm/bin/uuid"
}
},
"node_modules/v8-to-istanbul": {
@@ -8055,6 +8185,15 @@
"node": ">= 0.8"
}
},
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/walker": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz",
+20 -15
View File
@@ -6,34 +6,39 @@
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js",
"migrate": "node migrations/init.js",
"migrate": "node migrations/run-migrations.js",
"test": "jest",
"lint": "eslint src/"
},
"dependencies": {
"express": "^4.18.2",
"express-rate-limit": "^6.7.0",
"helmet": "^7.0.0",
"archiver": "^5.3.1",
"bcrypt": "^5.1.0",
"chokidar": "^3.5.3",
"cors": "^2.8.5",
"dotenv": "^16.0.3",
"bcrypt": "^5.1.0",
"express": "^4.18.2",
"express-rate-limit": "^6.7.0",
"express-validator": "^7.0.1",
"helmet": "^7.0.0",
"i18next": "^25.3.1",
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",
"joi": "^17.9.1",
"jsonwebtoken": "^9.0.0",
"knex": "^2.4.2",
"multer": "^1.4.5-lts.1",
"sharp": "^0.32.0",
"chokidar": "^3.5.3",
"node-cron": "^3.0.2",
"nodemailer": "^6.9.1",
"archiver": "^5.3.1",
"react-i18next": "^15.6.0",
"sharp": "^0.32.0",
"sqlite3": "^5.1.6",
"knex": "^2.4.2",
"joi": "^17.9.1",
"winston": "^3.8.2",
"express-validator": "^7.0.1"
"uuid": "^11.1.0",
"winston": "^3.8.2"
},
"devDependencies": {
"nodemon": "^2.0.22",
"eslint": "^8.40.0",
"jest": "^29.5.0",
"supertest": "^6.3.3",
"eslint": "^8.40.0"
"nodemon": "^2.0.22",
"supertest": "^6.3.3"
}
}
+57
View File
@@ -0,0 +1,57 @@
const path = require('path');
require('dotenv').config({ path: path.join(__dirname, '../.env') });
const { db } = require('../src/database/db');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { v4: uuidv4 } = require('uuid');
async function createTestEvent() {
try {
console.log('Creating test event...');
// Hash a simple password
const passwordHash = await bcrypt.hash('test123', 10);
// Generate share token
const shareToken = uuidv4().replace(/-/g, '');
const shareLink = `http://localhost:3005/gallery/wedding-test123-2025-07-07/${shareToken}`;
// Create event
const eventData = {
slug: 'wedding-test123-2025-07-07',
event_type: 'wedding',
event_name: 'Test Wedding',
event_date: '2025-07-07',
host_email: '[email protected]',
admin_email: '[email protected]',
password_hash: passwordHash,
welcome_message: 'Welcome to our test wedding gallery!',
color_theme: null, // Use global theme
is_active: 1,
expires_at: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
share_link: shareLink
};
// Delete existing event if it exists
await db('events').where('slug', eventData.slug).delete();
// Insert new event
const [eventId] = await db('events').insert(eventData);
console.log('Event created with ID:', eventId);
console.log('\nTest event created successfully!');
console.log('Event details:');
console.log('- Name:', eventData.event_name);
console.log('- Slug:', eventData.slug);
console.log('- Password:', 'test123');
console.log('- Share link:', shareLink);
console.log('\nYou can now access the gallery at the share link above');
process.exit(0);
} catch (error) {
console.error('Error creating test event:', error);
process.exit(1);
}
}
createTestEvent();
+37
View File
@@ -0,0 +1,37 @@
const path = require('path');
require('dotenv').config({ path: path.join(__dirname, '../.env') });
const { db } = require('../src/database/db');
async function fixDefaultThemes() {
try {
console.log('Fixing events with "default" theme...');
// Find all events with "default" as color_theme
const eventsToFix = await db('events')
.where('color_theme', 'default')
.select('id', 'event_name');
console.log(`Found ${eventsToFix.length} events to fix`);
if (eventsToFix.length > 0) {
// Update them to null so they use the global theme
await db('events')
.where('color_theme', 'default')
.update({ color_theme: null });
console.log('Updated events to use global theme');
eventsToFix.forEach(event => {
console.log(`- Fixed event: ${event.event_name} (ID: ${event.id})`);
});
}
console.log('Theme fix completed successfully');
process.exit(0);
} catch (error) {
console.error('Error fixing themes:', error);
process.exit(1);
}
}
fixDefaultThemes();
+47
View File
@@ -0,0 +1,47 @@
const path = require('path');
require('dotenv').config({ path: path.join(__dirname, '../.env') });
const { db } = require('../src/database/db');
async function seedCategories() {
try {
console.log('Seeding default categories...');
const defaultCategories = [
{ name: 'Portraits', slug: 'portraits' },
{ name: 'Group Photos', slug: 'group-photos' },
{ name: 'Ceremony', slug: 'ceremony' },
{ name: 'Reception', slug: 'reception' },
{ name: 'Dancing', slug: 'dancing' },
{ name: 'Candids', slug: 'candids' },
{ name: 'Details', slug: 'details' },
{ name: 'Getting Ready', slug: 'getting-ready' }
];
for (const category of defaultCategories) {
// Check if category already exists
const existing = await db('photo_categories')
.where({ slug: category.slug, is_global: true })
.first();
if (!existing) {
await db('photo_categories').insert({
name: category.name,
slug: category.slug,
is_global: true,
event_id: null
});
console.log(`Created category: ${category.name}`);
} else {
console.log(`Category already exists: ${category.name}`);
}
}
console.log('Default categories seeded successfully');
process.exit(0);
} catch (error) {
console.error('Error seeding categories:', error);
process.exit(1);
}
}
seedCategories();
+17 -1
View File
@@ -79,8 +79,22 @@ app.use('/api/auth', authLimiter);
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Middleware to set CORS headers for static files
const setCorsHeaders = (req, res, next) => {
res.header('Access-Control-Allow-Origin', req.headers.origin || '*');
res.header('Access-Control-Allow-Credentials', 'true');
res.header('Cross-Origin-Resource-Policy', 'cross-origin');
next();
};
// Static file serving for photos (protected)
app.use('/photos', require('./src/middleware/photoAuth'), express.static(path.join(__dirname, 'storage/events/active')));
app.use('/photos', require('./src/middleware/photoAuth'), setCorsHeaders, express.static(path.join(__dirname, 'storage/events/active')));
// Static file serving for thumbnails (protected)
app.use('/thumbnails', require('./src/middleware/photoAuth'), setCorsHeaders, express.static(path.join(__dirname, 'storage/thumbnails')));
// Static file serving for uploads (public - logos, favicons)
app.use('/uploads', setCorsHeaders, express.static(path.join(__dirname, 'storage/uploads')));
// Health check endpoint
app.get('/api/health', (req, res) => {
@@ -94,6 +108,8 @@ app.use('/api/gallery', galleryRoutes);
app.use('/api/admin', adminRoutes);
app.use('/api/admin/auth', adminAuthRoutes);
app.use('/api/public/settings', require('./src/routes/publicSettings'));
app.use('/api/public', require('./src/routes/publicCMS'));
app.use('/api/images', require('./src/routes/protectedImages'));
// Error handling middleware
app.use((err, req, res, next) => {
+8
View File
@@ -0,0 +1,8 @@
const path = require('path');
// Get storage path from environment or default
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
module.exports = {
getStoragePath
};
+29
View File
@@ -0,0 +1,29 @@
const jwt = require('jsonwebtoken');
const { db } = require('../database/db');
// 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 }).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 });
}
}
module.exports = {
verifyGalleryAccess
};
+49 -11
View File
@@ -1,13 +1,45 @@
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { db } = require('../database/db');
async function photoAuth(req, res, next) {
try {
const eventSlug = req.path.split('/')[1];
// First check for JWT token (from gallery access)
const authHeader = req.headers.authorization;
if (authHeader && authHeader.startsWith('Bearer ')) {
const token = authHeader.replace('Bearer ', '');
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
// Check if it's a gallery token for this event
if (decoded.type === 'gallery' && decoded.eventSlug === eventSlug) {
const event = await db('events').where({ slug: eventSlug, is_active: true }).first();
if (event) {
req.event = event;
return next();
}
}
// Check if it's an admin token (admins can view all photos)
if (decoded.type === 'admin') {
const event = await db('events').where({ slug: eventSlug }).first();
if (event) {
req.event = event;
return next();
}
}
} catch (err) {
// Token invalid, fall through to password check
}
}
// Check for password header (legacy support)
const password = req.headers['x-gallery-password'];
if (!password) {
return res.status(401).json({ error: 'Password required' });
if (!password && !authHeader) {
return res.status(401).json({ error: 'Authentication required' });
}
const event = await db('events').where({ slug: eventSlug, is_active: true }).first();
@@ -15,20 +47,26 @@ async function photoAuth(req, res, next) {
return res.status(404).json({ error: 'Gallery not found' });
}
const validPassword = await bcrypt.compare(password, event.password_hash);
if (!validPassword) {
await db('access_logs').insert({
event_id: event.id,
ip_address: req.ip,
user_agent: req.headers['user-agent'],
action: 'login_fail'
});
return res.status(401).json({ error: 'Invalid password' });
if (password) {
const validPassword = await bcrypt.compare(password, event.password_hash);
if (!validPassword) {
await db('access_logs').insert({
event_id: event.id,
ip_address: req.ip,
user_agent: req.headers['user-agent'],
action: 'login_fail'
});
return res.status(401).json({ error: 'Invalid password' });
}
} else {
// No valid authentication
return res.status(401).json({ error: 'Invalid authentication' });
}
req.event = event;
next();
} catch (error) {
console.error('Photo auth error:', error);
res.status(500).json({ error: 'Authentication error' });
}
}
+4
View File
@@ -8,6 +8,8 @@ const emailRoutes = require('./adminEmail');
const settingsRoutes = require('./adminSettings');
const eventsRoutes = require('./adminEvents');
const photosRoutes = require('./adminPhotos');
const categoriesRoutes = require('./adminCategories');
const cmsRoutes = require('./adminCMS');
// Mount sub-routers
router.use('/dashboard', dashboardRoutes);
@@ -16,5 +18,7 @@ router.use('/email', emailRoutes);
router.use('/settings', settingsRoutes);
router.use('/events', eventsRoutes);
router.use('/events', photosRoutes);
router.use('/categories', categoriesRoutes);
router.use('/cms', cmsRoutes);
module.exports = router;
+83
View File
@@ -0,0 +1,83 @@
const express = require('express');
const { body, validationResult } = require('express-validator');
const { db, logActivity } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const router = express.Router();
// Get all CMS pages
router.get('/pages', adminAuth, async (req, res) => {
try {
const pages = await db('cms_pages').select('*').orderBy('slug', 'asc');
res.json(pages);
} catch (error) {
console.error('Error fetching CMS pages:', error);
res.status(500).json({ error: 'Failed to fetch pages' });
}
});
// Get a single CMS page
router.get('/pages/:slug', adminAuth, async (req, res) => {
try {
const { slug } = req.params;
const page = await db('cms_pages').where('slug', slug).first();
if (!page) {
return res.status(404).json({ error: 'Page not found' });
}
res.json(page);
} catch (error) {
console.error('Error fetching CMS page:', error);
res.status(500).json({ error: 'Failed to fetch page' });
}
});
// Update a CMS page
router.put('/pages/:slug', adminAuth, [
body('title_en').optional().isString(),
body('title_de').optional().isString(),
body('content_en').optional().isString(),
body('content_de').optional().isString()
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { slug } = req.params;
const { title_en, title_de, content_en, content_de } = req.body;
const page = await db('cms_pages').where('slug', slug).first();
if (!page) {
return res.status(404).json({ error: 'Page not found' });
}
// Update the page
await db('cms_pages')
.where('slug', slug)
.update({
title_en,
title_de,
content_en,
content_de,
updated_at: new Date()
});
const updated = await db('cms_pages').where('slug', slug).first();
// Log activity
await logActivity('cms_page_updated',
{ page: slug },
null,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.json(updated);
} catch (error) {
console.error('Error updating CMS page:', error);
res.status(500).json({ error: 'Failed to update page' });
}
});
module.exports = router;
+182
View File
@@ -0,0 +1,182 @@
const express = require('express');
const { body, validationResult } = require('express-validator');
const { db, logActivity } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const router = express.Router();
// Get all global categories
router.get('/global', adminAuth, async (req, res) => {
try {
const categories = await db('photo_categories')
.where('is_global', true)
.orderBy('name', 'asc');
res.json(categories);
} catch (error) {
console.error('Error fetching categories:', error);
res.status(500).json({ error: 'Failed to fetch categories' });
}
});
// Get categories for a specific event (global + event-specific)
router.get('/event/:eventId', adminAuth, async (req, res) => {
try {
const { eventId } = req.params;
const categories = await db('photo_categories')
.where(function() {
this.where('is_global', true)
.orWhere('event_id', eventId);
})
.orderBy('is_global', 'desc')
.orderBy('name', 'asc');
res.json(categories);
} catch (error) {
console.error('Error fetching event categories:', error);
res.status(500).json({ error: 'Failed to fetch categories' });
}
});
// Create a new category
router.post('/', adminAuth, [
body('name').notEmpty().withMessage('Category name is required'),
body('slug').optional(),
body('is_global').optional().isBoolean(),
body('event_id').optional().isInt()
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { name, slug, is_global = true, event_id = null } = req.body;
// Generate slug if not provided
const categorySlug = slug || name.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.trim();
// Check if slug already exists for this scope
const existing = await db('photo_categories')
.where('slug', categorySlug)
.where(function() {
if (is_global) {
this.where('is_global', true);
} else {
this.where('event_id', event_id);
}
})
.first();
if (existing) {
return res.status(400).json({ error: 'Category with this slug already exists' });
}
// Create category
const [categoryId] = await db('photo_categories').insert({
name,
slug: categorySlug,
is_global,
event_id: is_global ? null : event_id
});
const category = await db('photo_categories').where('id', categoryId).first();
// Log activity
await logActivity('category_created',
{ categoryName: name, isGlobal: is_global },
event_id,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.json(category);
} catch (error) {
console.error('Error creating category:', error);
res.status(500).json({ error: 'Failed to create category' });
}
});
// Update a category
router.put('/:id', adminAuth, [
body('name').notEmpty().withMessage('Category name is required')
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { id } = req.params;
const { name } = req.body;
const category = await db('photo_categories').where('id', id).first();
if (!category) {
return res.status(404).json({ error: 'Category not found' });
}
await db('photo_categories')
.where('id', id)
.update({
name,
slug: name.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.trim()
});
const updated = await db('photo_categories').where('id', id).first();
// Log activity
await logActivity('category_updated',
{ categoryName: name },
category.event_id,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.json(updated);
} catch (error) {
console.error('Error updating category:', error);
res.status(500).json({ error: 'Failed to update category' });
}
});
// Delete a category
router.delete('/:id', adminAuth, async (req, res) => {
try {
const { id } = req.params;
const category = await db('photo_categories').where('id', id).first();
if (!category) {
return res.status(404).json({ error: 'Category not found' });
}
// Check if category has photos
const photoCount = await db('photos').where('category_id', id).count('id as count').first();
if (photoCount.count > 0) {
return res.status(400).json({
error: 'Cannot delete category with photos. Please reassign photos first.'
});
}
await db('photo_categories').where('id', id).delete();
// Log activity
await logActivity('category_deleted',
{ categoryName: category.name },
category.event_id,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.json({ message: 'Category deleted successfully' });
} catch (error) {
console.error('Error deleting category:', error);
res.status(500).json({ error: 'Failed to delete category' });
}
});
module.exports = router;
+1 -1
View File
@@ -34,7 +34,7 @@ router.post('/', adminAuth, [
admin_email,
password,
welcome_message = '',
color_theme = 'default',
color_theme = null,
expiration_days = 30
} = req.body;
+109 -22
View File
@@ -5,6 +5,7 @@ const fs = require('fs').promises;
const { db, logActivity } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { generateThumbnail } = require('../services/imageProcessor');
const { generatePhotoFilename } = require('../utils/filenameSanitizer');
const router = express.Router();
// Get storage path from environment or default
@@ -14,7 +15,6 @@ const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '.
const storage = multer.diskStorage({
destination: async (req, file, cb) => {
const { eventId } = req.params;
const { type = 'individual' } = req.body;
try {
// Get event details
@@ -23,9 +23,11 @@ const storage = multer.diskStorage({
return cb(new Error('Event not found'));
}
// Create destination path
const photoType = type === 'collage' ? 'collages' : 'individual';
const destPath = path.join(getStoragePath(), 'events/active', event.slug, photoType);
// Store event in request for use in filename generation
req.eventData = event;
// Create destination path - now just event folder, no type subfolder
const destPath = path.join(getStoragePath(), 'events/active', event.slug);
// Ensure directory exists
await fs.mkdir(destPath, { recursive: true });
@@ -35,12 +37,14 @@ const storage = multer.diskStorage({
cb(error);
}
},
filename: (req, file, cb) => {
// Generate unique filename
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
const ext = path.extname(file.originalname);
const name = path.basename(file.originalname, ext);
cb(null, `${name}-${uniqueSuffix}${ext}`);
filename: async (req, file, cb) => {
try {
// Use temporary filename for now, will rename after getting category info
const tempName = `temp_${Date.now()}_${Math.round(Math.random() * 1E9)}${path.extname(file.originalname)}`;
cb(null, tempName);
} catch (error) {
cb(error);
}
}
});
@@ -67,7 +71,12 @@ const upload = multer({
router.post('/:eventId/upload', adminAuth, upload.array('photos', 20), async (req, res) => {
try {
const { eventId } = req.params;
const { type = 'individual' } = req.body;
const { category_id } = req.body;
console.log('Upload request received:');
console.log('Body:', req.body);
console.log('Files:', req.files ? req.files.length : 'none');
console.log('Headers:', req.headers);
// Verify event exists and admin has access
const event = await db('events').where({ id: eventId }).first();
@@ -76,40 +85,103 @@ router.post('/:eventId/upload', adminAuth, upload.array('photos', 20), async (re
}
if (!req.files || req.files.length === 0) {
console.log('No files in request. req.files:', req.files);
return res.status(400).json({ error: 'No files uploaded' });
}
// Get category details if provided
let category = null;
if (category_id) {
category = await db('photo_categories').where({ id: category_id }).first();
if (!category) {
return res.status(400).json({ error: 'Invalid category' });
}
}
const uploadedPhotos = [];
// Process each uploaded file
for (const file of req.files) {
let trx;
try {
// Generate thumbnail
// Start transaction for atomic counter update
trx = await db.transaction();
// Get and increment the counter for this category
let counter = 1;
if (category) {
// Lock the category row and get current counter
const categoryData = await trx('photo_categories')
.where({ id: category_id })
.forUpdate()
.first();
counter = (categoryData.photo_counter || 0) + 1;
// Update counter
await trx('photo_categories')
.where({ id: category_id })
.update({ photo_counter: counter });
} else {
// For uncategorized photos, count existing uncategorized photos
const uncategorizedCount = await trx('photos')
.where({ event_id: eventId })
.whereNull('category_id')
.count('id as count')
.first();
counter = (uncategorizedCount.count || 0) + 1;
}
// 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 ? path.relative(path.join(storagePath, 'events/active'), thumbnailPath) : null;
const relativeThumbPath = thumbnailPath; // thumbnailPath is already relative to storage root
// Add to database
const [photoId] = await db('photos').insert({
const [photoId] = await trx('photos').insert({
event_id: eventId,
filename: file.filename,
path: relativePath,
thumbnail_path: relativeThumbPath,
type: type === 'collage' ? 'collage' : 'individual',
category_id: category_id || null,
type: 'individual', // Keep for backwards compatibility
size_bytes: file.size
});
// Commit transaction
await trx.commit();
uploadedPhotos.push({
id: photoId,
filename: file.filename,
size: file.size,
type
category_id: category_id || null
});
} catch (error) {
console.error(`Error processing file ${file.filename}:`, error);
if (trx) await trx.rollback();
// Continue with other files
}
}
@@ -187,23 +259,38 @@ router.delete('/:eventId/photos/:photoId', adminAuth, async (req, res) => {
router.get('/:eventId/photos', adminAuth, async (req, res) => {
try {
const { eventId } = req.params;
const { type } = req.query;
const { category_id, type } = req.query;
let query = db('photos').where({ event_id: eventId });
let query = db('photos')
.leftJoin('photo_categories', 'photos.category_id', 'photo_categories.id')
.where({ 'photos.event_id': eventId })
.select(
'photos.*',
'photo_categories.name as category_name',
'photo_categories.slug as category_slug'
);
if (type) {
query = query.where({ type });
if (category_id) {
query = query.where({ 'photos.category_id': category_id });
}
const photos = await query.orderBy('uploaded_at', 'desc');
// Keep type filter for backwards compatibility
if (type) {
query = query.where({ 'photos.type': type });
}
const photos = await query.orderBy('photos.uploaded_at', 'desc');
res.json({
photos: photos.map(photo => ({
id: photo.id,
filename: photo.filename,
url: `/photos/${photo.path}`,
thumbnail_url: photo.thumbnail_path ? `/photos/${photo.thumbnail_path}` : null,
thumbnail_url: photo.thumbnail_path ? `/thumbnails/${photo.thumbnail_path}` : null,
type: photo.type,
category_id: photo.category_id,
category_name: photo.category_name,
category_slug: photo.category_slug,
size: photo.size_bytes,
uploaded_at: photo.uploaded_at
}))
+160 -12
View File
@@ -3,7 +3,7 @@ const multer = require('multer');
const path = require('path');
const fs = require('fs').promises;
const { body, validationResult } = require('express-validator');
const { db } = require('../database/db');
const { db, logActivity } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const router = express.Router();
@@ -36,6 +36,32 @@ const upload = multer({
}
});
// Configure multer for favicon uploads
const faviconStorage = multer.diskStorage({
destination: async (req, file, cb) => {
const uploadDir = path.join(__dirname, '../../storage/uploads/favicons');
await fs.mkdir(uploadDir, { recursive: true });
cb(null, uploadDir);
},
filename: (req, file, cb) => {
const ext = path.extname(file.originalname);
cb(null, `favicon-${Date.now()}${ext}`);
}
});
const faviconUpload = multer({
storage: faviconStorage,
limits: { fileSize: 1 * 1024 * 1024 }, // 1MB
fileFilter: (req, file, cb) => {
const allowedTypes = ['image/png', 'image/x-icon', 'image/vnd.microsoft.icon'];
if (allowedTypes.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error('Favicon must be PNG or ICO format'));
}
}
});
// Get all settings
router.get('/', adminAuth, async (req, res) => {
try {
@@ -44,9 +70,17 @@ router.get('/', adminAuth, async (req, res) => {
// Convert to object format
const settingsObject = {};
settings.forEach(setting => {
settingsObject[setting.setting_key] = setting.setting_value
? JSON.parse(setting.setting_value)
: null;
if (setting.setting_value) {
try {
// Try to parse as JSON first
settingsObject[setting.setting_key] = JSON.parse(setting.setting_value);
} catch (e) {
// If it's not valid JSON, use the raw value
settingsObject[setting.setting_key] = setting.setting_value;
}
} else {
settingsObject[setting.setting_key] = null;
}
});
res.json(settingsObject);
@@ -67,9 +101,17 @@ router.get('/:type', adminAuth, async (req, res) => {
// Convert to object format
const settingsObject = {};
settings.forEach(setting => {
settingsObject[setting.setting_key] = setting.setting_value
? JSON.parse(setting.setting_value)
: null;
if (setting.setting_value) {
try {
// Try to parse as JSON first
settingsObject[setting.setting_key] = JSON.parse(setting.setting_value);
} catch (e) {
// If it's not valid JSON, use the raw value
settingsObject[setting.setting_key] = setting.setting_value;
}
} else {
settingsObject[setting.setting_key] = null;
}
});
res.json(settingsObject);
@@ -87,7 +129,10 @@ router.put('/branding', adminAuth, async (req, res) => {
company_tagline,
support_email,
footer_text,
watermark_enabled
watermark_enabled,
watermark_position,
watermark_opacity,
watermark_size
} = req.body;
const brandingSettings = {
@@ -95,7 +140,10 @@ router.put('/branding', adminAuth, async (req, res) => {
company_tagline,
support_email,
footer_text,
watermark_enabled
watermark_enabled,
watermark_position,
watermark_opacity,
watermark_size
};
// Update or insert each setting
@@ -172,19 +220,19 @@ router.post('/logo', adminAuth, upload.single('logo'), async (req, res) => {
await db('app_settings')
.insert({
setting_key: 'branding_logo_url',
setting_value: JSON.stringify(publicPath),
setting_value: publicPath,
setting_type: 'branding',
updated_at: new Date()
})
.onConflict('setting_key')
.merge({
setting_value: JSON.stringify(publicPath),
setting_value: publicPath,
updated_at: new Date()
});
res.json({
message: 'Logo uploaded successfully',
logo_url: publicPath
logoUrl: publicPath
});
} catch (error) {
console.error('Logo upload error:', error);
@@ -192,6 +240,68 @@ router.post('/logo', adminAuth, upload.single('logo'), async (req, res) => {
}
});
// Upload watermark logo
router.post('/branding/watermark-logo', adminAuth, upload.single('watermarkLogo'), async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded' });
}
// Delete old watermark logo if exists
const oldWatermarkLogoSetting = await db('app_settings')
.where('setting_key', 'branding_watermark_logo_path')
.first();
if (oldWatermarkLogoSetting && oldWatermarkLogoSetting.setting_value) {
const oldPath = JSON.parse(oldWatermarkLogoSetting.setting_value);
try {
await fs.unlink(oldPath);
} catch (error) {
console.error('Failed to delete old watermark logo:', error);
}
}
// Save new watermark logo path
const logoPath = req.file.path;
const publicPath = `/uploads/logos/${req.file.filename}`;
await db('app_settings')
.insert({
setting_key: 'branding_watermark_logo_path',
setting_value: JSON.stringify(logoPath),
setting_type: 'branding',
updated_at: new Date()
})
.onConflict('setting_key')
.merge({
setting_value: JSON.stringify(logoPath),
updated_at: new Date()
});
// Save public URL
await db('app_settings')
.insert({
setting_key: 'branding_watermark_logo_url',
setting_value: publicPath,
setting_type: 'branding',
updated_at: new Date()
})
.onConflict('setting_key')
.merge({
setting_value: publicPath,
updated_at: new Date()
});
res.json({
message: 'Watermark logo uploaded successfully',
watermarkLogoUrl: publicPath
});
} catch (error) {
console.error('Watermark logo upload error:', error);
res.status(500).json({ error: 'Failed to upload watermark logo' });
}
});
// Update theme settings
router.put('/theme', adminAuth, async (req, res) => {
try {
@@ -346,4 +456,42 @@ router.get('/storage/info', adminAuth, async (req, res) => {
}
});
// Upload favicon endpoint
router.post('/favicon', adminAuth, faviconUpload.single('favicon'), async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'No favicon file provided' });
}
// The file is already in the correct location from multer
const faviconUrl = `/uploads/favicons/${req.file.filename}`;
// Save to database
await db('app_settings')
.insert({
setting_key: 'branding_favicon_url',
setting_value: faviconUrl,
setting_type: 'branding',
updated_at: new Date()
})
.onConflict('setting_key')
.merge({
setting_value: faviconUrl,
updated_at: new Date()
});
// Log activity
await logActivity('favicon_uploaded',
{ faviconUrl },
null,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.json({ faviconUrl });
} catch (error) {
console.error('Error uploading favicon:', error);
res.status(500).json({ error: 'Failed to upload favicon' });
}
});
module.exports = router;
+5 -1
View File
@@ -87,7 +87,11 @@ router.post('/gallery/verify', [
});
// Generate session token
const token = jwt.sign({ eventId: event.id }, process.env.JWT_SECRET, { expiresIn: '24h' });
const token = jwt.sign({
eventId: event.id,
eventSlug: event.slug,
type: 'gallery'
}, process.env.JWT_SECRET, { expiresIn: '24h' });
res.json({
token,
+68 -10
View File
@@ -4,6 +4,7 @@ const { db } = require('../database/db');
const archiver = require('archiver');
const path = require('path');
const router = express.Router();
const watermarkService = require('../services/watermarkService');
// Get storage path from environment or default
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
@@ -26,7 +27,8 @@ async function verifyGalleryAccess(req, res, next) {
req.event = event;
next();
} catch (error) {
res.status(401).json({ error: 'Invalid token' });
console.error('Error verifying gallery access:', error);
res.status(401).json({ error: 'Invalid token', details: error.message });
}
}
@@ -52,7 +54,8 @@ router.get('/:slug/verify-token/:token', async (req, res) => {
res.json({ valid: true });
} catch (error) {
res.status(500).json({ error: 'Failed to verify token' });
console.error('Error verifying token:', error);
res.status(500).json({ error: 'Failed to verify token', details: error.message });
}
});
@@ -89,7 +92,8 @@ router.get('/:slug/info', async (req, res) => {
requires_password: true
});
} catch (error) {
res.status(500).json({ error: 'Failed to fetch gallery info' });
console.error('Error fetching gallery info:', error);
res.status(500).json({ error: 'Failed to fetch gallery info', details: error.message });
}
});
@@ -97,8 +101,23 @@ router.get('/:slug/info', async (req, res) => {
router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
try {
const photos = await db('photos')
.where('event_id', req.event.id)
.orderBy('uploaded_at', 'desc');
.leftJoin('photo_categories', 'photos.category_id', 'photo_categories.id')
.where('photos.event_id', req.event.id)
.select(
'photos.*',
'photo_categories.name as category_name',
'photo_categories.slug as category_slug'
)
.orderBy('photos.uploaded_at', 'desc');
// Get all categories for this event
const categories = await db('photo_categories')
.where(function() {
this.where('is_global', true)
.orWhere('event_id', req.event.id);
})
.orderBy('is_global', 'desc')
.orderBy('name', 'asc');
// Log view
await db('access_logs').insert({
@@ -118,18 +137,28 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
color_theme: req.event.color_theme,
expires_at: req.event.expires_at
},
categories: categories.map(cat => ({
id: cat.id,
name: cat.name,
slug: cat.slug,
is_global: cat.is_global
})),
photos: photos.map(photo => ({
id: photo.id,
filename: photo.filename,
url: `/photos/${req.event.slug}/${photo.path}`,
thumbnail_url: photo.thumbnail_path ? `/photos/${photo.thumbnail_path}` : null,
url: `/photos/${photo.path}`,
thumbnail_url: photo.thumbnail_path ? `/${photo.thumbnail_path}` : null,
type: photo.type,
category_id: photo.category_id,
category_name: photo.category_name,
category_slug: photo.category_slug,
size: photo.size_bytes,
uploaded_at: photo.uploaded_at
}))
});
} catch (error) {
res.status(500).json({ error: 'Failed to fetch photos' });
console.error('Error fetching photos:', error);
res.status(500).json({ error: 'Failed to fetch photos', details: error.message });
}
});
@@ -159,7 +188,25 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, async (req, res) =>
});
const filePath = path.join(getStoragePath(), 'events/active', req.event.slug, photo.path);
res.download(filePath, photo.filename);
// Get watermark settings
const watermarkSettings = await watermarkService.getWatermarkSettings();
if (watermarkSettings && watermarkSettings.enabled) {
// Apply watermark and send
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings);
res.set({
'Content-Type': photo.mime_type || 'image/jpeg',
'Content-Disposition': `attachment; filename="${photo.filename}"`,
'Content-Length': watermarkedBuffer.length
});
res.send(watermarkedBuffer);
} else {
// Send original file
res.download(filePath, photo.filename);
}
} catch (error) {
res.status(500).json({ error: 'Failed to download photo' });
}
@@ -184,10 +231,21 @@ router.get('/:slug/download-all', verifyGalleryAccess, async (req, res) => {
archive.pipe(res);
// Get watermark settings
const watermarkSettings = await watermarkService.getWatermarkSettings();
// Add photos to archive
for (const photo of photos) {
const filePath = path.join(getStoragePath(), 'events/active', req.event.slug, photo.path);
archive.file(filePath, { name: photo.path });
if (watermarkSettings && watermarkSettings.enabled) {
// Apply watermark
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings);
archive.append(watermarkedBuffer, { name: photo.path });
} else {
// Add original file
archive.file(filePath, { name: photo.path });
}
}
await archive.finalize();
+189
View File
@@ -0,0 +1,189 @@
const express = require('express');
const path = require('path');
const { db } = require('../database/db');
const { verifyGalleryAccess } = require('../middleware/gallery');
const watermarkService = require('../services/watermarkService');
const { getStoragePath } = require('../config/storage');
const crypto = require('crypto');
const router = express.Router();
/**
* Generate a signed URL token for image access
*/
function generateImageToken(photoId, expiresIn = 3600) {
const secret = process.env.JWT_SECRET || 'your-secret-key';
const expires = Date.now() + (expiresIn * 1000);
const data = `${photoId}:${expires}`;
const signature = crypto.createHmac('sha256', secret).update(data).digest('hex');
return `${Buffer.from(data).toString('base64')}.${signature}`;
}
/**
* Verify image token
*/
function verifyImageToken(token) {
try {
const secret = process.env.JWT_SECRET || 'your-secret-key';
const [data, signature] = token.split('.');
const decoded = Buffer.from(data, 'base64').toString();
const [photoId, expires] = decoded.split(':');
// Verify signature
const expectedSignature = crypto.createHmac('sha256', secret).update(decoded).digest('hex');
if (signature !== expectedSignature) {
return null;
}
// Check expiration
if (Date.now() > parseInt(expires)) {
return null;
}
return { photoId: parseInt(photoId), expires: parseInt(expires) };
} catch (error) {
return null;
}
}
/**
* Serve watermarked image
*/
router.get('/:slug/photo/:photoId/view', verifyGalleryAccess, async (req, res) => {
try {
const { photoId } = req.params;
// Get photo details
const photo = await db('photos')
.where({
id: photoId,
event_id: req.event.id
})
.first();
if (!photo) {
return res.status(404).json({ error: 'Photo not found' });
}
// Get watermark settings
const watermarkSettings = await watermarkService.getWatermarkSettings();
// Build full path to photo
const photoPath = path.join(getStoragePath(), 'events/active', req.event.slug, photo.path);
// Apply watermark if enabled
const imageBuffer = await watermarkService.applyWatermark(photoPath, watermarkSettings);
// Set appropriate headers
res.set({
'Content-Type': photo.mime_type || 'image/jpeg',
'Content-Length': imageBuffer.length,
'Cache-Control': 'private, max-age=3600',
'X-Content-Type-Options': 'nosniff'
});
// Send the watermarked image
res.send(imageBuffer);
} catch (error) {
console.error('Error serving watermarked image:', error);
res.status(500).json({ error: 'Failed to serve image' });
}
});
/**
* Generate signed URL for image access
*/
router.post('/:slug/photo/:photoId/generate-url', verifyGalleryAccess, async (req, res) => {
try {
const { photoId } = req.params;
// Verify photo belongs to this event
const photo = await db('photos')
.where({
id: photoId,
event_id: req.event.id
})
.first();
if (!photo) {
return res.status(404).json({ error: 'Photo not found' });
}
// Generate signed token
const token = generateImageToken(photoId);
const signedUrl = `/api/images/${req.params.slug}/photo/${photoId}/signed/${token}`;
res.json({
url: signedUrl,
expiresIn: 3600 // 1 hour
});
} catch (error) {
console.error('Error generating signed URL:', error);
res.status(500).json({ error: 'Failed to generate URL' });
}
});
/**
* Serve image with signed URL (no gallery auth required, token is the auth)
*/
router.get('/:slug/photo/:photoId/signed/:token', async (req, res) => {
try {
const { slug, photoId, token } = req.params;
// Verify token
const tokenData = verifyImageToken(token);
if (!tokenData || tokenData.photoId !== parseInt(photoId)) {
return res.status(403).json({ error: 'Invalid or expired token' });
}
// Get event
const event = await db('events')
.where({ slug })
.where('is_active', true)
.first();
if (!event) {
return res.status(404).json({ error: 'Event not found' });
}
// Get photo
const photo = await db('photos')
.where({
id: photoId,
event_id: event.id
})
.first();
if (!photo) {
return res.status(404).json({ error: 'Photo not found' });
}
// Get watermark settings
const watermarkSettings = await watermarkService.getWatermarkSettings();
// Build full path to photo
const photoPath = path.join(getStoragePath(), 'events/active', event.slug, photo.path);
// Apply watermark if enabled
const imageBuffer = await watermarkService.applyWatermark(photoPath, watermarkSettings);
// Set appropriate headers
res.set({
'Content-Type': photo.mime_type || 'image/jpeg',
'Content-Length': imageBuffer.length,
'Cache-Control': 'private, max-age=3600',
'X-Content-Type-Options': 'nosniff'
});
// Send the watermarked image
res.send(imageBuffer);
} catch (error) {
console.error('Error serving signed image:', error);
res.status(500).json({ error: 'Failed to serve image' });
}
});
module.exports = router;
+33
View File
@@ -0,0 +1,33 @@
const express = require('express');
const { db } = require('../database/db');
const router = express.Router();
// Get public CMS page
router.get('/pages/:slug', async (req, res) => {
try {
const { slug } = req.params;
const { lang = 'en' } = req.query;
const page = await db('cms_pages').where('slug', slug).first();
if (!page) {
return res.status(404).json({ error: 'Page not found' });
}
// Return the appropriate language version
const title = lang === 'de' ? page.title_de : page.title_en;
const content = lang === 'de' ? page.content_de : page.content_en;
res.json({
title,
content,
slug: page.slug,
updated_at: page.updated_at
});
} catch (error) {
console.error('Error fetching public CMS page:', error);
res.status(500).json({ error: 'Failed to fetch page' });
}
});
module.exports = router;
+10 -3
View File
@@ -5,9 +5,9 @@ const router = express.Router();
// Get public settings (branding and theme)
router.get('/', async (req, res) => {
try {
// Fetch branding and theme settings
// Fetch branding, theme, and general settings
const settings = await db('app_settings')
.whereIn('setting_type', ['branding', 'theme'])
.whereIn('setting_type', ['branding', 'theme', 'general'])
.select('setting_key', 'setting_value');
// Convert to object format
@@ -30,7 +30,14 @@ router.get('/', async (req, res) => {
branding_support_email: settingsObject.branding_support_email || '',
branding_footer_text: settingsObject.branding_footer_text || '',
branding_watermark_enabled: settingsObject.branding_watermark_enabled || false,
theme_config: settingsObject.theme_config || null
branding_watermark_logo_url: settingsObject.branding_watermark_logo_url || '',
branding_watermark_position: settingsObject.branding_watermark_position || 'bottom-right',
branding_watermark_opacity: settingsObject.branding_watermark_opacity || 50,
branding_watermark_size: settingsObject.branding_watermark_size || 15,
branding_favicon_url: settingsObject.branding_favicon_url || '',
branding_logo_url: settingsObject.branding_logo_url || '',
theme_config: settingsObject.theme_config || null,
default_language: settingsObject.general_default_language || 'en'
};
res.json(publicSettings);
+4 -1
View File
@@ -60,12 +60,15 @@ async function processNewPhoto(filePath) {
// Generate thumbnail
const thumbnailPath = await generateThumbnail(filePath);
// Calculate relative thumbnail path
const relativeThumbPath = thumbnailPath; // thumbnailPath is already relative to storage root
// Add to database
await db('photos').insert({
event_id: event.id,
filename: path.basename(filePath),
path: relativePath,
thumbnail_path: thumbnailPath,
thumbnail_path: relativeThumbPath,
type: photoType,
size_bytes: stats.size
});
+6 -4
View File
@@ -3,15 +3,17 @@ const path = require('path');
const fs = require('fs').promises;
const THUMBNAIL_WIDTH = 300;
const THUMBNAIL_PATH = path.join(__dirname, '../../../storage/thumbnails');
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
const getThumbnailPath = () => path.join(getStoragePath(), 'thumbnails');
async function generateThumbnail(imagePath) {
const filename = path.basename(imagePath);
const thumbnailFilename = `thumb_${filename}`;
const thumbnailPath = path.join(THUMBNAIL_PATH, thumbnailFilename);
const thumbnailDir = getThumbnailPath();
const thumbnailPath = path.join(thumbnailDir, thumbnailFilename);
// Ensure thumbnail directory exists
await fs.mkdir(THUMBNAIL_PATH, { recursive: true });
await fs.mkdir(thumbnailDir, { recursive: true });
// Generate thumbnail
await sharp(imagePath)
@@ -22,7 +24,7 @@ async function generateThumbnail(imagePath) {
.jpeg({ quality: 80 })
.toFile(thumbnailPath);
return path.relative(path.join(__dirname, '../../../storage'), thumbnailPath);
return path.relative(getStoragePath(), thumbnailPath);
}
module.exports = { generateThumbnail };
+226
View File
@@ -0,0 +1,226 @@
const sharp = require('sharp');
const path = require('path');
const fs = require('fs').promises;
const { db } = require('../database/db');
class WatermarkService {
constructor() {
this.cache = new Map();
this.cacheMaxAge = 3600000; // 1 hour in milliseconds
}
/**
* Get watermark settings from database
*/
async getWatermarkSettings() {
try {
const settings = await db('app_settings')
.whereIn('setting_key', [
'branding_watermark_enabled',
'branding_watermark_logo_path',
'branding_watermark_position',
'branding_watermark_opacity',
'branding_watermark_size',
'branding_company_name'
])
.select('setting_key', 'setting_value');
const settingsObj = {};
settings.forEach(setting => {
try {
settingsObj[setting.setting_key] = JSON.parse(setting.setting_value);
} catch (e) {
settingsObj[setting.setting_key] = setting.setting_value;
}
});
return {
enabled: settingsObj.branding_watermark_enabled || false,
logoPath: settingsObj.branding_watermark_logo_path || null,
position: settingsObj.branding_watermark_position || 'bottom-right',
opacity: parseInt(settingsObj.branding_watermark_opacity || 50),
size: parseInt(settingsObj.branding_watermark_size || 15),
companyName: settingsObj.branding_company_name || 'Photo Gallery'
};
} catch (error) {
console.error('Error fetching watermark settings:', error);
return null;
}
}
/**
* Calculate position coordinates based on position string
*/
getPositionCoordinates(imageWidth, imageHeight, watermarkWidth, watermarkHeight, position) {
const padding = 20;
let left, top;
switch (position) {
case 'top-left':
left = padding;
top = padding;
break;
case 'top-right':
left = imageWidth - watermarkWidth - padding;
top = padding;
break;
case 'bottom-left':
left = padding;
top = imageHeight - watermarkHeight - padding;
break;
case 'bottom-right':
left = imageWidth - watermarkWidth - padding;
top = imageHeight - watermarkHeight - padding;
break;
case 'center':
left = Math.floor((imageWidth - watermarkWidth) / 2);
top = Math.floor((imageHeight - watermarkHeight) / 2);
break;
default:
// Default to bottom-right
left = imageWidth - watermarkWidth - padding;
top = imageHeight - watermarkHeight - padding;
}
return { left: Math.max(0, left), top: Math.max(0, top) };
}
/**
* Apply watermark to an image
*/
async applyWatermark(imagePath, settings) {
try {
if (!settings || !settings.enabled) {
// Return original image if watermarking is disabled
return await fs.readFile(imagePath);
}
// Check cache first
const cacheKey = `${imagePath}_${JSON.stringify(settings)}`;
const cached = this.cache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < this.cacheMaxAge) {
return cached.buffer;
}
// Load the main image
const image = sharp(imagePath);
const metadata = await image.metadata();
let watermarkBuffer;
let watermarkMetadata;
// Try to use logo watermark first
if (settings.logoPath) {
try {
const watermarkImage = sharp(settings.logoPath);
watermarkMetadata = await watermarkImage.metadata();
// Calculate watermark size based on percentage of main image
const scaleFactor = settings.size / 100;
const targetWidth = Math.floor(metadata.width * scaleFactor);
const targetHeight = Math.floor(watermarkMetadata.height * (targetWidth / watermarkMetadata.width));
// Resize watermark and apply opacity
watermarkBuffer = await watermarkImage
.resize(targetWidth, targetHeight, { fit: 'inside' })
.composite([{
input: Buffer.from([255, 255, 255, Math.floor(255 * (settings.opacity / 100))]),
raw: {
width: 1,
height: 1,
channels: 4
},
tile: true,
blend: 'dest-in'
}])
.toBuffer();
watermarkMetadata = { width: targetWidth, height: targetHeight };
} catch (error) {
console.error('Error processing watermark logo:', error);
watermarkBuffer = null;
}
}
// If no logo or logo failed, create text watermark
if (!watermarkBuffer) {
const fontSize = Math.max(16, Math.floor(metadata.width * 0.03));
const padding = 10;
// Create SVG text watermark
const svg = `
<svg width="${settings.companyName.length * fontSize * 0.6 + padding * 2}" height="${fontSize + padding * 2}">
<rect x="0" y="0" width="100%" height="100%" fill="black" opacity="0.5" rx="5"/>
<text x="${padding}" y="${fontSize + padding/2}"
font-family="Arial, sans-serif"
font-size="${fontSize}"
fill="white"
opacity="${settings.opacity / 100}">
${settings.companyName}
</text>
</svg>
`;
watermarkBuffer = Buffer.from(svg);
watermarkMetadata = {
width: settings.companyName.length * fontSize * 0.6 + padding * 2,
height: fontSize + padding * 2
};
}
// Calculate position
const position = this.getPositionCoordinates(
metadata.width,
metadata.height,
watermarkMetadata.width,
watermarkMetadata.height,
settings.position
);
// Apply watermark
const watermarkedBuffer = await image
.composite([{
input: watermarkBuffer,
top: position.top,
left: position.left
}])
.toBuffer();
// Cache the result
this.cache.set(cacheKey, {
buffer: watermarkedBuffer,
timestamp: Date.now()
});
// Clean old cache entries
this.cleanCache();
return watermarkedBuffer;
} catch (error) {
console.error('Error applying watermark:', error);
// Return original image on error
return await fs.readFile(imagePath);
}
}
/**
* Clean old cache entries
*/
cleanCache() {
const now = Date.now();
for (const [key, value] of this.cache.entries()) {
if (now - value.timestamp > this.cacheMaxAge) {
this.cache.delete(key);
}
}
}
/**
* Clear entire cache
*/
clearCache() {
this.cache.clear();
}
}
module.exports = new WatermarkService();
+57
View File
@@ -0,0 +1,57 @@
/**
* Sanitize a string to be used as a filename component
* @param {string} str - The string to sanitize
* @param {number} maxLength - Maximum length of the sanitized string
* @returns {string} - Sanitized string
*/
function sanitizeFilename(str, maxLength = 50) {
if (!str) return 'unnamed';
// Convert to string and trim
let sanitized = String(str).trim();
// Replace spaces with underscores
sanitized = sanitized.replace(/\s+/g, '_');
// Remove special characters except hyphens, underscores, and dots
sanitized = sanitized.replace(/[^a-zA-Z0-9_\-\.]/g, '');
// Remove multiple consecutive underscores or hyphens
sanitized = sanitized.replace(/[_\-]{2,}/g, '_');
// Remove leading/trailing underscores or hyphens
sanitized = sanitized.replace(/^[_\-]+|[_\-]+$/g, '');
// Limit length
if (sanitized.length > maxLength) {
sanitized = sanitized.substring(0, maxLength);
}
// If empty after sanitization, use default
if (!sanitized) {
sanitized = 'unnamed';
}
return sanitized;
}
/**
* Generate a photo filename based on event name, category, and counter
* @param {string} eventName - The event name
* @param {string} categoryName - The category name
* @param {number} counter - The photo counter
* @param {string} extension - The file extension (including dot)
* @returns {string} - Generated filename
*/
function generatePhotoFilename(eventName, categoryName, counter, extension) {
const sanitizedEvent = sanitizeFilename(eventName, 30);
const sanitizedCategory = sanitizeFilename(categoryName || 'uncategorized', 20);
const paddedCounter = String(counter).padStart(4, '0');
return `${sanitizedEvent}_${sanitizedCategory}_${paddedCounter}${extension}`;
}
module.exports = {
sanitizeFilename,
generatePhotoFilename
};