Compare commits

...

10 Commits

Author SHA1 Message Date
Gitea Actions Bot 38dd74b893 chore: bump version to 1.1.8 (backend + frontend) 2025-10-03 05:19:52 +00:00
paul fc1bf53412 fix: harden gallery downloads and per-gallery auth
Test and Lint / backend-test (push) Successful in 1m57s
Test and Lint / frontend-test (push) Successful in 1m57s
2025-10-01 16:00:37 +02:00
paul 5d6c061f1c feat: support per-gallery password toggle 2025-10-01 16:00:37 +02:00
Gitea Actions Bot 45e835a51a chore: bump frontend version to 1.1.7 2025-09-27 06:14:25 +00:00
Gitea Actions Bot afc00090cf chore: bump frontend version to 1.1.6 2025-09-27 06:11:45 +00:00
paul 59750dea15 Enforce mandatory gallery passwords in UI
Test and Lint / backend-test (push) Successful in 1m23s
Test and Lint / frontend-test (push) Successful in 1m51s
2025-09-27 08:05:59 +02:00
Gitea Actions Bot 2fe32e9a69 chore: bump backend version to 1.1.5 2025-09-27 05:59:32 +00:00
paul 5f8c8c5508 Fix branding asset storage path
Test and Lint / backend-test (push) Successful in 1m47s
Test and Lint / frontend-test (push) Successful in 1m56s
2025-09-26 17:26:39 +02:00
Gitea Actions Bot fb739f221d chore: bump version to 1.1.4 (backend + frontend) 2025-09-24 15:39:34 +00:00
paul b5399aaa9b Add installer flag to regenerate admin credentials
Test and Lint / backend-test (push) Successful in 1m42s
Test and Lint / frontend-test (push) Successful in 1m54s
2025-09-24 17:33:54 +02:00
39 changed files with 1690 additions and 455 deletions
+11 -1
View File
@@ -420,7 +420,17 @@ Upon first login, the system will **automatically redirect** you to change your
If you lose your admin credentials after the first login, you'll need to manually reset the password in the database or create a new admin user through the database. If you lose your admin credentials after the first login, you'll need to manually reset the password in the database or create a new admin user through the database.
**Note**: The credentials file (`ADMIN_CREDENTIALS.txt`) is only created during initial deployment and contains the first admin password. After changing the password, this file becomes outdated but is kept for reference. **Note**: The credentials file (`ADMIN_CREDENTIALS.txt`) is only created during initial deployment and contains the first admin password. After changing the password, this file becomes outdated but is kept for reference. If you need to regenerate the password and file during a reinstall, re-run the installer with the `--force-admin-password-reset` flag:
```bash
# Native reinstall example
sudo ./setup.sh --native --force-admin-password-reset
# Docker reinstall example
sudo ./setup.sh --docker --force-admin-password-reset
```
The flag calls `scripts/reset-admin-password.js` in non-interactive mode, writes a fresh random password into `data/ADMIN_CREDENTIALS.txt`, and prints the new credentials at the end of the installer run.
#### Configuring Admin Email #### Configuring Admin Email
@@ -0,0 +1,184 @@
const fs = require('fs');
const fsPromises = fs.promises;
const os = require('os');
const path = require('path');
const express = require('express');
const request = require('supertest');
describe('Admin settings logo upload flow', () => {
let tmpDir;
let router;
let app;
let settingsStore;
const resetModules = () => {
jest.resetModules();
jest.clearAllMocks();
};
beforeEach(async () => {
resetModules();
tmpDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'picpeak-logo-'));
process.env.STORAGE_PATH = tmpDir;
settingsStore = new Map();
const buildQuery = (table) => {
const filters = [];
const applyFilters = (rows) => {
if (filters.length === 0) {
return rows;
}
return rows.filter((row) =>
filters.every(({ column, value }) => row[column] === value)
);
};
const makeRow = (row) => ({ ...row });
return {
where(column, value) {
filters.push({ column, value });
return this;
},
first() {
if (table === 'app_settings') {
const rows = applyFilters(Array.from(settingsStore.values()).map(makeRow));
return Promise.resolve(rows[0]);
}
return Promise.resolve(undefined);
},
select() {
return Promise.resolve([]);
},
sum() {
return Promise.resolve({ total: 0 });
},
join() {
return this;
},
groupBy() {
return this;
},
orderBy() {
return this;
},
limit() {
return this;
},
insert(payload) {
const rows = Array.isArray(payload) ? payload : [payload];
const upsert = (row, overrides = {}) => {
if (table === 'app_settings') {
const key = row.setting_key;
const existing = settingsStore.get(key) || {};
settingsStore.set(key, { ...existing, ...row, ...overrides });
}
return Promise.resolve();
};
return {
onConflict() {
return {
merge(overrides) {
return Promise.all(rows.map((row) => upsert(row, overrides))).then(() => undefined);
}
};
}
};
}
};
};
const dbMock = jest.fn((table) => buildQuery(table));
dbMock.raw = jest.fn();
dbMock.transaction = async (handler) => handler({
commit: async () => {},
rollback: async () => {}
});
jest.doMock('../src/database/db', () => ({
db: dbMock,
logActivity: jest.fn()
}));
jest.doMock('../src/middleware/auth', () => ({
adminAuth: (req, res, next) => {
req.admin = { id: 1, username: 'tester' };
next();
}
}));
jest.doMock('../src/services/publicSiteService', () => ({
clearPublicSiteCache: jest.fn(),
getDefaultPublicSitePayload: jest.fn(),
getRawPublicSiteSettings: jest.fn().mockResolvedValue({})
}));
jest.doMock('../src/services/rateLimitService', () => ({
clearSettingsCache: jest.fn()
}));
jest.doMock('../src/middleware/maintenance', () => ({
maintenanceMiddleware: (req, res, next) => next(),
clearMaintenanceCache: jest.fn()
}));
router = require('../src/routes/adminSettings');
app = express();
app.use(express.json());
app.use('/api/admin/settings', router);
});
afterEach(async () => {
resetModules();
if (tmpDir) {
await fsPromises.rm(tmpDir, { recursive: true, force: true });
tmpDir = null;
}
delete process.env.STORAGE_PATH;
});
it('stores logo uploads under STORAGE_PATH and deletes on branding reset', async () => {
const fileBuffer = Buffer.from('fake image data');
const uploadResponse = await request(app)
.post('/api/admin/settings/logo')
.attach('logo', fileBuffer, 'logo.png');
expect(uploadResponse.status).toBe(200);
expect(uploadResponse.body).toHaveProperty('logoUrl');
const logoUrl = uploadResponse.body.logoUrl;
expect(logoUrl.startsWith('/uploads/logos/')).toBe(true);
const storedPath = path.join(tmpDir, logoUrl.replace('/uploads/', 'uploads/'));
await expect(fsPromises.access(storedPath)).resolves.toBeUndefined();
await request(app)
.put('/api/admin/settings/branding')
.send({
company_name: 'Test Co',
company_tagline: 'Tagline',
support_email: 'test@example.com',
footer_text: 'Footer',
watermark_enabled: false,
watermark_position: 'bottom-right',
watermark_opacity: 0.5,
watermark_size: 'medium',
favicon_url: null,
logo_url: '',
watermark_logo_url: null,
logo_size: 'medium',
logo_max_height: 120,
logo_position: 'left',
logo_display_header: true,
logo_display_hero: false,
logo_display_mode: 'default'
})
.expect(200);
await expect(fsPromises.access(storedPath)).rejects.toThrow();
});
});
@@ -0,0 +1,75 @@
const path = require('path');
const mockPath = path;
jest.mock('../../src/services/externalMediaService', () => ({
resolveExternalPath: jest.fn((event, relPath) => mockPath.join('/mock/external', event.external_path || '', relPath || '')),
}));
const { resolveExternalPath } = require('../../src/services/externalMediaService');
const { resolvePhotoFilePath } = require('../../src/services/photoResolver');
describe('resolvePhotoFilePath', () => {
const backendRoot = path.resolve(__dirname, '../../');
const originalStoragePath = process.env.STORAGE_PATH;
beforeEach(() => {
process.env.STORAGE_PATH = path.join(backendRoot, 'storage');
});
afterEach(() => {
jest.clearAllMocks();
});
afterAll(() => {
if (typeof originalStoragePath === 'string') {
process.env.STORAGE_PATH = originalStoragePath;
} else {
delete process.env.STORAGE_PATH;
}
});
it('returns absolute path for managed photos with legacy slug paths', () => {
const event = { slug: 'wedding-party', source_mode: 'managed' };
const photo = { path: 'wedding-party/hero.jpg' };
const result = resolvePhotoFilePath(event, photo);
expect(result).toBe(path.join(backendRoot, 'storage', 'events/active', 'wedding-party', 'hero.jpg'));
});
it('normalizes prefixed managed paths without duplicating segments', () => {
const event = { slug: 'wedding-party', source_mode: 'managed' };
const photo = { path: 'events/active/wedding-party/hero.jpg' };
const result = resolvePhotoFilePath(event, photo);
expect(result).toBe(path.join(backendRoot, 'storage', 'events/active', 'wedding-party', 'hero.jpg'));
});
it('delegates external photos to external media resolver', () => {
const event = { slug: 'fashion-show', source_mode: 'reference', external_path: 'picsum-demo' };
const photo = { source_origin: 'external', external_relpath: 'individual/look-01.jpg' };
const result = resolvePhotoFilePath(event, photo);
expect(resolveExternalPath).toHaveBeenCalledWith(event, 'individual/look-01.jpg');
expect(result).toBe(path.join('/mock/external', 'picsum-demo', 'individual', 'look-01.jpg'));
});
it('deduplicates folder names when event external path already ends with segment', () => {
const event = { slug: 'fashion-show', source_mode: 'reference', external_path: 'picsum-demo/individual' };
const photo = { source_origin: 'external', external_relpath: 'individual/look-02.jpg' };
const result = resolvePhotoFilePath(event, photo);
expect(resolveExternalPath).toHaveBeenCalledWith(event, 'look-02.jpg');
expect(result).toBe(path.join('/mock/external', 'picsum-demo/individual', 'look-02.jpg'));
});
it('throws when external photo is missing relative path data', () => {
const event = { slug: 'fashion-show', source_mode: 'reference', external_path: 'picsum-demo' };
const photo = { source_origin: 'external' };
expect(() => resolvePhotoFilePath(event, photo)).toThrow('Missing external_relpath for external photo');
});
});
@@ -0,0 +1,18 @@
exports.up = async function (knex) {
const hasColumn = await knex.schema.hasColumn('events', 'require_password');
if (!hasColumn) {
await knex.schema.table('events', (table) => {
table.boolean('require_password').notNullable().defaultTo(true);
});
await knex('events').update({ require_password: true });
}
};
exports.down = async function (knex) {
const hasColumn = await knex.schema.hasColumn('events', 'require_password');
if (hasColumn) {
await knex.schema.table('events', (table) => {
table.dropColumn('require_password');
});
}
};
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "picpeak-backend", "name": "picpeak-backend",
"version": "1.1.3", "version": "1.1.8",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "picpeak-backend", "name": "picpeak-backend",
"version": "1.1.3", "version": "1.1.8",
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.850.0", "@aws-sdk/client-s3": "^3.850.0",
"@aws-sdk/lib-storage": "^3.850.0", "@aws-sdk/lib-storage": "^3.850.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "picpeak-backend", "name": "picpeak-backend",
"version": "1.1.3", "version": "1.1.8",
"description": "Backend for PicPeak event photo sharing platform", "description": "Backend for PicPeak event photo sharing platform",
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const fsp = fs.promises;
async function pathExists(location) {
try {
await fsp.access(location);
return true;
} catch (error) {
if (error && error.code === 'ENOENT') {
return false;
}
throw error;
}
}
async function moveFile(source, destination) {
await fsp.mkdir(path.dirname(destination), { recursive: true });
try {
await fsp.rename(source, destination);
} catch (error) {
if (error.code === 'EXDEV') {
await fsp.copyFile(source, destination);
await fsp.unlink(source);
} else if (error.code === 'EEXIST') {
console.warn(`Destination already exists, leaving original in place: ${destination}`);
return;
} else {
throw error;
}
}
}
async function migrate() {
const backendRoot = path.resolve(__dirname, '..');
const defaultStorage = path.resolve(backendRoot, '../storage');
const targetStorage = path.resolve(process.env.STORAGE_PATH || defaultStorage);
const legacyUploadsRoot = path.resolve(backendRoot, 'storage/uploads');
const targetUploadsRoot = path.join(targetStorage, 'uploads');
if (legacyUploadsRoot === targetUploadsRoot) {
console.log('Legacy uploads directory already matches target STORAGE_PATH. Nothing to migrate.');
return;
}
if (!fs.existsSync(legacyUploadsRoot)) {
console.log(`Legacy uploads directory not found at ${legacyUploadsRoot}. Nothing to migrate.`);
return;
}
const categories = ['logos', 'favicons'];
let migratedCounter = 0;
for (const category of categories) {
const legacyDir = path.join(legacyUploadsRoot, category);
if (!fs.existsSync(legacyDir)) {
continue;
}
const targetDir = path.join(targetUploadsRoot, category);
await fsp.mkdir(targetDir, { recursive: true });
const entries = await fsp.readdir(legacyDir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isFile()) {
continue;
}
const sourcePath = path.join(legacyDir, entry.name);
const destinationPath = path.join(targetDir, entry.name);
if (await pathExists(destinationPath)) {
console.warn(`Skipping ${sourcePath} because ${destinationPath} already exists.`);
continue;
}
await moveFile(sourcePath, destinationPath);
migratedCounter += 1;
}
const remaining = await fsp.readdir(legacyDir);
if (remaining.length === 0) {
await fsp.rm(legacyDir, { recursive: true, force: true });
}
}
if (migratedCounter === 0) {
console.log('No legacy logo or favicon files needed migration.');
return;
}
console.log(`Migrated ${migratedCounter} files into ${targetUploadsRoot}.`);
console.log('If the database still references legacy absolute paths, they will be cleaned up automatically on the next upload.');
}
migrate().catch((error) => {
console.error('Migration failed:', error);
process.exitCode = 1;
});
+54 -22
View File
@@ -7,12 +7,32 @@ const fs = require('fs').promises;
const path = require('path'); const path = require('path');
const readline = require('readline'); const readline = require('readline');
const rl = readline.createInterface({ const args = process.argv.slice(2);
const hasFlag = (flag) => args.includes(flag);
const getOption = (name) => {
const index = args.indexOf(`--${name}`);
if (index !== -1 && index + 1 < args.length) {
return args[index + 1];
}
return null;
};
const force = hasFlag('--force') || hasFlag('--yes') || hasFlag('--non-interactive');
const credentialsFileArg = getOption('credentials-file');
const resolvedCredentialsFile = credentialsFileArg
? path.resolve(process.cwd(), credentialsFileArg)
: path.join(__dirname, '..', '..', 'ADMIN_PASSWORD_RESET.txt');
const rl = force ? null : readline.createInterface({
input: process.stdin, input: process.stdin,
output: process.stdout output: process.stdout
}); });
async function question(prompt) { async function ask(prompt) {
if (force) {
return 'yes';
}
return new Promise((resolve) => { return new Promise((resolve) => {
rl.question(prompt, resolve); rl.question(prompt, resolve);
}); });
@@ -37,13 +57,18 @@ async function resetAdminPassword() {
console.log('Found admin user:', admin.username); console.log('Found admin user:', admin.username);
console.log('Email:', admin.email); console.log('Email:', admin.email);
console.log('\nThis will reset the password for this admin account.'); if (!force) {
console.log('\nThis will reset the password for this admin account.');
const confirm = await question('\nDo you want to continue? (yes/no): '); }
if (confirm.toLowerCase() !== 'yes' && confirm.toLowerCase() !== 'y') { const confirm = await ask('\nDo you want to continue? (yes/no): ');
console.log('\n❌ Password reset cancelled.');
process.exit(0); if (!force) {
const normalized = confirm.trim().toLowerCase();
if (normalized !== 'yes' && normalized !== 'y') {
console.log('\n❌ Password reset cancelled.');
process.exit(0);
}
} }
// Generate new password // Generate new password
@@ -60,39 +85,44 @@ async function resetAdminPassword() {
}); });
// Save to file // Save to file
const resetInfoPath = path.join(__dirname, '..', '..', 'ADMIN_PASSWORD_RESET.txt'); const credentialsDir = path.dirname(resolvedCredentialsFile);
await fs.mkdir(credentialsDir, { recursive: true });
const adminUrl = `${process.env.ADMIN_URL || 'http://localhost:3001'}/admin`;
const resetInfo = ` const resetInfo = `
======================================== ========================================
PicPeak Admin Password Reset PicPeak Admin Credentials
======================================== ========================================
Password has been reset for admin account: Your admin account has been reset with these credentials:
Username: admin Username: ${admin.username}
New Password: ${newPassword} Email: ${admin.email}
Password: ${newPassword}
IMPORTANT: IMPORTANT SECURITY NOTES:
1. You MUST change this password on next login 1. You MUST change this password after first login
2. This file contains sensitive information 2. This file contains sensitive information
3. Delete this file after noting the password 3. Delete this file after noting the password
Login URL: ${process.env.ADMIN_URL || 'http://localhost:3001'}/admin Login URL: ${adminUrl}
Reset performed on: ${new Date().toISOString()} Reset performed on: ${new Date().toISOString()}
======================================== ========================================
`; `;
await fs.writeFile(resetInfoPath, resetInfo, 'utf8'); await fs.writeFile(resolvedCredentialsFile, resetInfo, 'utf8');
console.log('\n✅ Password reset successful!\n'); console.log('\n✅ Password reset successful!\n');
console.log('========================================'); console.log('========================================');
console.log('New Credentials:'); console.log('New Credentials:');
console.log('========================================'); console.log('========================================');
console.log('Username: admin'); console.log(`Username: ${admin.username}`);
console.log(`Email: ${admin.email}`);
console.log(`Password: ${newPassword}`); console.log(`Password: ${newPassword}`);
console.log('\n⚠️ IMPORTANT:'); console.log('\n⚠️ IMPORTANT:');
console.log('1. You will be required to change this password on next login'); console.log('1. You will be required to change this password on next login');
console.log('2. Credentials are also saved in: ADMIN_PASSWORD_RESET.txt'); console.log(`2. Credentials are also saved in: ${resolvedCredentialsFile}`);
console.log('3. Delete the file after noting the password'); console.log('3. Delete the file after noting the password');
console.log('========================================\n'); console.log('========================================\n');
@@ -100,10 +130,12 @@ Reset performed on: ${new Date().toISOString()}
console.error('❌ Error resetting password:', error.message); console.error('❌ Error resetting password:', error.message);
process.exit(1); process.exit(1);
} finally { } finally {
rl.close(); if (rl) {
rl.close();
}
process.exit(0); process.exit(0);
} }
} }
// Run the reset // Run the reset
resetAdminPassword(); resetAdminPassword();
+5 -1
View File
@@ -82,6 +82,7 @@ async function initializeDatabase() {
table.boolean('watermark_downloads').defaultTo(false); table.boolean('watermark_downloads').defaultTo(false);
table.text('watermark_text'); table.text('watermark_text');
table.integer('hero_photo_id').references('id').inTable('photos').onDelete('SET NULL'); table.integer('hero_photo_id').references('id').inTable('photos').onDelete('SET NULL');
table.boolean('require_password').defaultTo(true);
}); });
} else { } else {
// Check if color_theme needs to be updated to TEXT type // Check if color_theme needs to be updated to TEXT type
@@ -116,7 +117,8 @@ async function initializeDatabase() {
disable_right_click BOOLEAN DEFAULT 0, disable_right_click BOOLEAN DEFAULT 0,
watermark_downloads BOOLEAN DEFAULT 0, watermark_downloads BOOLEAN DEFAULT 0,
watermark_text TEXT, watermark_text TEXT,
hero_photo_id INTEGER hero_photo_id INTEGER,
require_password BOOLEAN DEFAULT 1
) )
`); `);
@@ -138,6 +140,8 @@ async function initializeDatabase() {
return 'watermark_text'; return 'watermark_text';
case 'hero_photo_id': case 'hero_photo_id':
return 'hero_photo_id'; return 'hero_photo_id';
case 'require_password':
return 'COALESCE(require_password, 1) as require_password';
default: default:
return col; return col;
} }
+40 -6
View File
@@ -2,13 +2,48 @@ const jwt = require('jsonwebtoken');
const { db, withRetry } = require('../database/db'); const { db, withRetry } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat'); const { formatBoolean } = require('../utils/dbCompat');
const { getGalleryTokenFromRequest } = require('../utils/tokenUtils'); const { getGalleryTokenFromRequest } = require('../utils/tokenUtils');
const logger = require('../utils/logger');
// Middleware to verify gallery access // Middleware to verify gallery access
async function verifyGalleryAccess(req, res, next) { async function verifyGalleryAccess(req, res, next) {
try { try {
const requestedSlug = req.params.slug || req.requestedSlug; const requestedSlug = req.params.slug || req.requestedSlug;
const token = getGalleryTokenFromRequest(req, requestedSlug); const token = getGalleryTokenFromRequest(req, requestedSlug);
let event;
if (!token) { if (!token) {
if (!requestedSlug) {
return res.status(401).json({ error: 'No token provided' });
}
event = await withRetry(async () => {
return await db('events')
.where({
slug: requestedSlug,
is_active: formatBoolean(true),
is_archived: formatBoolean(false)
})
.select('*')
.first();
});
if (!event) {
return res.status(404).json({ error: 'Gallery not found or expired' });
}
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
if (!requiresPassword) {
req.event = event;
req.sessionID = `gallery_public_${event.id}_${Date.now()}`;
req.clientInfo = {
ip: req.ip || req.connection.remoteAddress || 'unknown',
userAgent: req.get('User-Agent') || 'unknown',
fingerprint: `${req.ip}-${req.get('User-Agent')}`.substring(0, 32),
timestamp: Date.now()
};
return next();
}
return res.status(401).json({ error: 'No token provided' }); return res.status(401).json({ error: 'No token provided' });
} }
@@ -26,10 +61,9 @@ async function verifyGalleryAccess(req, res, next) {
throw error; throw error;
} }
} }
console.log('[verifyGalleryAccess] Token decoded successfully, eventId:', decoded.eventId); logger.debug('[verifyGalleryAccess] Token decoded successfully', { eventId: decoded.eventId, slug: requestedSlug });
// If we have a slug in the URL params or from pre-middleware, verify it matches // If we have a slug in the URL params or from pre-middleware, verify it matches
let event;
if (requestedSlug) { if (requestedSlug) {
// Verify by slug and ensure it matches the token's event // Verify by slug and ensure it matches the token's event
event = await withRetry(async () => { event = await withRetry(async () => {
@@ -62,11 +96,11 @@ async function verifyGalleryAccess(req, res, next) {
} }
if (!event) { if (!event) {
console.log('[verifyGalleryAccess] Event not found for slug:', requestedSlug || 'no-slug', 'eventId:', decoded.eventId); logger.warn('[verifyGalleryAccess] Event not found for slug', { slug: requestedSlug || 'no-slug', tokenEventId: decoded.eventId });
return res.status(404).json({ error: 'Gallery not found or expired' }); return res.status(404).json({ error: 'Gallery not found or expired' });
} }
console.log('[verifyGalleryAccess] Event found:', event.id, event.slug); logger.debug('[verifyGalleryAccess] Event located', { eventId: event.id, slug: event.slug });
req.event = event; req.event = event;
req.sessionID = decoded.sessionId || `gallery_${event.id}_${Date.now()}`; req.sessionID = decoded.sessionId || `gallery_${event.id}_${Date.now()}`;
@@ -78,10 +112,10 @@ async function verifyGalleryAccess(req, res, next) {
timestamp: Date.now() timestamp: Date.now()
}; };
console.log('[verifyGalleryAccess] Access granted for event:', event.id); logger.debug('[verifyGalleryAccess] Access granted', { eventId: event.id, slug: event.slug });
next(); next();
} catch (error) { } catch (error) {
console.error('Error verifying gallery access:', error); logger.error('Error verifying gallery access', { error: error.message, stack: error.stack });
res.status(401).json({ error: 'Invalid token' }); res.status(401).json({ error: 'Invalid token' });
} }
} }
+18 -12
View File
@@ -3,14 +3,13 @@ const jwt = require('jsonwebtoken');
const { db } = require('../database/db'); const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat'); const { formatBoolean } = require('../utils/dbCompat');
const { getGalleryTokenFromRequest } = require('../utils/tokenUtils'); const { getGalleryTokenFromRequest } = require('../utils/tokenUtils');
const logger = require('../utils/logger');
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
@@ -80,29 +79,36 @@ async function photoAuth(req, res, next) {
// For both thumbnails and photos with admin token, allow access // For both thumbnails and photos with admin token, allow access
return next(); 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); logger.warn('JWT verification failed in photoAuth', { error: err.message });
} }
} }
// Check for password header (legacy support) // Check for password header (legacy support)
const password = req.headers['x-gallery-password']; const password = req.headers['x-gallery-password'];
if (!password && !tokenFromRequest) {
return res.status(401).json({ error: 'Authentication required' });
}
// If no eventSlug (thumbnails), and we don't have valid auth yet, deny access // If no eventSlug (thumbnails), and we don't have valid auth yet, deny access
if (!eventSlug && !password) { if (!eventSlug && !password && !tokenFromRequest) {
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: formatBoolean(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' });
} }
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
if (!requiresPassword) {
req.event = event;
return next();
}
if (!password && !tokenFromRequest) {
return res.status(401).json({ error: 'Authentication required' });
}
if (password) { if (password) {
const validPassword = await bcrypt.compare(password, event.password_hash); const validPassword = await bcrypt.compare(password, event.password_hash);
if (!validPassword) { if (!validPassword) {
@@ -122,7 +128,7 @@ async function photoAuth(req, res, next) {
req.event = event; req.event = event;
next(); next();
} catch (error) { } catch (error) {
console.error('Photo auth error:', error); logger.error('Photo auth error', { error: error.message, stack: error.stack });
res.status(500).json({ error: 'Authentication error' }); res.status(500).json({ error: 'Authentication error' });
} }
} }
+124 -24
View File
@@ -13,6 +13,29 @@ const { queueEmail } = require('../services/emailProcessor');
const { escapeLikePattern } = require('../utils/sqlSecurity'); const { escapeLikePattern } = require('../utils/sqlSecurity');
// formatDate import removed - dates are formatted by email processor // formatDate import removed - dates are formatted by email processor
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation'); const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
const logger = require('../utils/logger');
const parseBooleanInput = (value, defaultValue = true) => {
if (value === undefined || value === null) {
return defaultValue;
}
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'number') {
return value !== 0;
}
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase();
if (['false', '0', 'no', 'off'].includes(normalized)) {
return false;
}
if (['true', '1', 'yes', 'on'].includes(normalized)) {
return true;
}
}
return defaultValue;
};
// Create new event // Create new event
router.post('/', adminAuth, [ router.post('/', adminAuth, [
@@ -21,7 +44,30 @@ router.post('/', adminAuth, [
body('event_date').isDate(), body('event_date').isDate(),
body('host_email').isEmail().normalizeEmail(), body('host_email').isEmail().normalizeEmail(),
body('admin_email').isEmail().normalizeEmail(), body('admin_email').isEmail().normalizeEmail(),
body('password').isLength({ min: 6 }), body('require_password').optional().isBoolean(),
body('password').optional().isString().custom((value, { req }) => {
const input = req.body.require_password;
const normalizeBoolean = (val, defaultValue = true) => {
if (val === undefined || val === null) return defaultValue;
if (typeof val === 'boolean') return val;
if (typeof val === 'number') return val !== 0;
if (typeof val === 'string') {
const normalized = val.trim().toLowerCase();
if (['false', '0', 'no', 'off'].includes(normalized)) return false;
if (['true', '1', 'yes', 'on'].includes(normalized)) return true;
}
return defaultValue;
};
const requirePassword = normalizeBoolean(input, true);
if (!requirePassword) {
return true;
}
if (typeof value !== 'string' || value.trim().length < 6) {
throw new Error('Password must be at least 6 characters long');
}
return true;
}),
body('expiration_days').isInt({ min: 1, max: 365 }).optional(), body('expiration_days').isInt({ min: 1, max: 365 }).optional(),
body('welcome_message').optional().trim(), body('welcome_message').optional().trim(),
body('color_theme').optional().trim(), body('color_theme').optional().trim(),
@@ -34,7 +80,7 @@ router.post('/', adminAuth, [
body('watermark_text').optional().trim() body('watermark_text').optional().trim()
], async (req, res) => { ], async (req, res) => {
try { try {
console.log('Create event request body:', req.body); logger.debug('Create event request body', { body: req.body });
const errors = validationResult(req); const errors = validationResult(req);
if (!errors.isEmpty()) { if (!errors.isEmpty()) {
console.error('Validation errors:', errors.array()); console.error('Validation errors:', errors.array());
@@ -58,6 +104,7 @@ router.post('/', adminAuth, [
disable_right_click = false, disable_right_click = false,
watermark_downloads = false, watermark_downloads = false,
watermark_text = null, watermark_text = null,
require_password: requirePasswordInput = true,
// Feedback settings // Feedback settings
feedback_enabled = false, feedback_enabled = false,
allow_ratings = true, allow_ratings = true,
@@ -69,12 +116,15 @@ router.post('/', adminAuth, [
show_feedback_to_guests = true show_feedback_to_guests = true
} = req.body; } = req.body;
const requirePassword = parseBooleanInput(requirePasswordInput, true);
// Debug logging // Debug logging
console.log('Download control values:', { logger.debug('Download control values', {
allow_downloads, allow_downloads,
disable_right_click, disable_right_click,
watermark_downloads, watermark_downloads,
watermark_text, watermark_text,
require_password: requirePassword,
types: { types: {
allow_downloads: typeof allow_downloads, allow_downloads: typeof allow_downloads,
disable_right_click: typeof disable_right_click, disable_right_click: typeof disable_right_click,
@@ -82,18 +132,24 @@ router.post('/', adminAuth, [
} }
}); });
// Validate password strength let passwordValidation = null;
const passwordValidation = await validatePasswordInContext(password, 'gallery', { let galleryPassword = password;
eventName: event_name
}); if (requirePassword) {
passwordValidation = await validatePasswordInContext(password, 'gallery', {
if (!passwordValidation.valid) { eventName: event_name
return res.status(400).json({
error: 'Password does not meet security requirements',
details: passwordValidation.errors,
score: passwordValidation.score,
feedback: passwordValidation.feedback
}); });
if (!passwordValidation.valid) {
return res.status(400).json({
error: 'Password does not meet security requirements',
details: passwordValidation.errors,
score: passwordValidation.score,
feedback: passwordValidation.feedback
});
}
} else {
galleryPassword = '';
} }
// Generate unique slug // Generate unique slug
@@ -113,10 +169,14 @@ router.post('/', adminAuth, [
// Generate share link // Generate share link
const shareToken = crypto.randomBytes(16).toString('hex'); const shareToken = crypto.randomBytes(16).toString('hex');
const shareLink = `${process.env.FRONTEND_URL}/gallery/${slug}/${shareToken}`; const sharePath = `/gallery/${slug}/${shareToken}`;
const frontendBase = (process.env.FRONTEND_URL || '').replace(/\/$/, '');
const shareLink = frontendBase ? `${frontendBase}${sharePath}` : sharePath;
// Hash password with configurable rounds // Hash password with configurable rounds (random placeholder when not required)
const password_hash = await bcrypt.hash(password, getBcryptRounds()); const password_hash = requirePassword
? await bcrypt.hash(password, getBcryptRounds())
: await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds());
// Calculate expiration date (days after event date) // Calculate expiration date (days after event date)
// Parse YYYY-MM-DD format as local date to avoid timezone issues // Parse YYYY-MM-DD format as local date to avoid timezone issues
@@ -155,7 +215,8 @@ router.post('/', adminAuth, [
allow_downloads: formatBoolean(allow_downloads !== undefined ? allow_downloads : true), allow_downloads: formatBoolean(allow_downloads !== undefined ? allow_downloads : true),
disable_right_click: formatBoolean(disable_right_click !== undefined ? disable_right_click : false), disable_right_click: formatBoolean(disable_right_click !== undefined ? disable_right_click : false),
watermark_downloads: formatBoolean(watermark_downloads !== undefined ? watermark_downloads : false), watermark_downloads: formatBoolean(watermark_downloads !== undefined ? watermark_downloads : false),
watermark_text watermark_text,
require_password: formatBoolean(requirePassword)
}).returning('id'); }).returning('id');
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs) // Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
@@ -180,7 +241,7 @@ router.post('/', adminAuth, [
// Log activity // Log activity
await logActivity('event_created', await logActivity('event_created',
{ event_type, expires_at }, { event_type, expires_at, require_password: requirePassword, password_strength: passwordValidation?.score },
eventId, eventId,
{ type: 'admin', id: req.admin.id, name: req.admin.username } { type: 'admin', id: req.admin.id, name: req.admin.username }
); );
@@ -197,7 +258,7 @@ router.post('/', adminAuth, [
event_name, event_name,
event_date: event_date, // Pass raw date - will be formatted by email processor event_date: event_date, // Pass raw date - will be formatted by email processor
gallery_link: shareLink, gallery_link: shareLink,
gallery_password: password, gallery_password: requirePassword ? password : 'No password required',
expiry_date: expires_at.toISOString(), // Pass ISO string - will be formatted by email processor expiry_date: expires_at.toISOString(), // Pass ISO string - will be formatted by email processor
welcome_message: welcome_message || '' welcome_message: welcome_message || ''
}), }),
@@ -211,6 +272,7 @@ router.post('/', adminAuth, [
slug, slug,
event_name, event_name,
event_type, event_type,
require_password: requirePassword,
share_link: shareLink, share_link: shareLink,
expires_at: expires_at.toISOString(), expires_at: expires_at.toISOString(),
created_at: new Date().toISOString() created_at: new Date().toISOString()
@@ -398,19 +460,45 @@ router.put('/:id', adminAuth, [
body('watermark_downloads').optional().isBoolean(), body('watermark_downloads').optional().isBoolean(),
body('watermark_text').optional().trim(), body('watermark_text').optional().trim(),
body('source_mode').optional().isIn(['managed', 'reference']), body('source_mode').optional().isIn(['managed', 'reference']),
body('external_path').optional({ nullable: true }).isString().trim() body('external_path').optional({ nullable: true }).isString().trim(),
body('require_password').optional().isBoolean(),
body('password').optional().isString().custom((value, { req }) => {
if (value === undefined || value === null || value === '') {
return true;
}
if (typeof value !== 'string' || value.trim().length < 6) {
throw new Error('Password must be at least 6 characters long');
}
return true;
})
], async (req, res) => { ], async (req, res) => {
try { try {
const errors = validationResult(req); const errors = validationResult(req);
if (!errors.isEmpty()) { if (!errors.isEmpty()) {
console.log('Update event validation errors:', JSON.stringify(errors.array(), null, 2)); logger.debug('Update event validation errors', { errors: errors.array(), body: req.body });
console.log('Request body:', req.body);
return res.status(400).json({ errors: errors.array() }); return res.status(400).json({ errors: errors.array() });
} }
const { id } = req.params; const { id } = req.params;
const updates = { ...req.body }; const updates = { ...req.body };
const hasRequirePasswordUpdate = Object.prototype.hasOwnProperty.call(updates, 'require_password');
let requirePasswordUpdate;
if (hasRequirePasswordUpdate) {
requirePasswordUpdate = parseBooleanInput(updates.require_password, true);
updates.require_password = formatBoolean(requirePasswordUpdate);
}
let newPasswordPlain;
if (Object.prototype.hasOwnProperty.call(updates, 'password')) {
if (updates.password === undefined || updates.password === null || updates.password === '') {
delete updates.password;
} else {
newPasswordPlain = updates.password;
delete updates.password;
}
}
if (Object.prototype.hasOwnProperty.call(updates, 'source_mode')) { if (Object.prototype.hasOwnProperty.call(updates, 'source_mode')) {
updates.source_mode = updates.source_mode === 'reference' ? 'reference' : 'managed'; updates.source_mode = updates.source_mode === 'reference' ? 'reference' : 'managed';
} }
@@ -429,7 +517,7 @@ router.put('/:id', adminAuth, [
} }
// Log the update request for debugging // Log the update request for debugging
console.log('Update event request:', { logger.debug('Update event request', {
id, id,
updates, updates,
color_theme_length: updates.color_theme ? updates.color_theme.length : 0, color_theme_length: updates.color_theme ? updates.color_theme.length : 0,
@@ -444,6 +532,18 @@ router.put('/:id', adminAuth, [
return res.status(404).json({ error: 'Event not found' }); return res.status(404).json({ error: 'Event not found' });
} }
const currentRequirePassword = parseBooleanInput(event.require_password, true);
if (hasRequirePasswordUpdate && requirePasswordUpdate === true && !currentRequirePassword && !newPasswordPlain) {
return res.status(400).json({ error: 'Password must be provided when enabling password requirement.' });
}
if (newPasswordPlain) {
updates.password_hash = await bcrypt.hash(newPasswordPlain, getBcryptRounds());
} else if (hasRequirePasswordUpdate && requirePasswordUpdate === false && currentRequirePassword) {
updates.password_hash = await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds());
}
// Update event // Update event
await db('events') await db('events')
.where('id', id) .where('id', id)
+10 -6
View File
@@ -20,10 +20,12 @@ const {
const { sanitizeCss } = require('../utils/cssSanitizer'); const { sanitizeCss } = require('../utils/cssSanitizer');
const router = express.Router(); const router = express.Router();
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
// Configure multer for logo uploads // Configure multer for logo uploads
const storage = multer.diskStorage({ const storage = multer.diskStorage({
destination: async (req, file, cb) => { destination: async (req, file, cb) => {
const uploadDir = path.join(__dirname, '../../storage/uploads/logos'); const uploadDir = path.join(getStoragePath(), 'uploads/logos');
await fs.mkdir(uploadDir, { recursive: true }); await fs.mkdir(uploadDir, { recursive: true });
cb(null, uploadDir); cb(null, uploadDir);
}, },
@@ -53,7 +55,7 @@ const upload = multer({
// Configure multer for favicon uploads // Configure multer for favicon uploads
const faviconStorage = multer.diskStorage({ const faviconStorage = multer.diskStorage({
destination: async (req, file, cb) => { destination: async (req, file, cb) => {
const uploadDir = path.join(__dirname, '../../storage/uploads/favicons'); const uploadDir = path.join(getStoragePath(), 'uploads/favicons');
await fs.mkdir(uploadDir, { recursive: true }); await fs.mkdir(uploadDir, { recursive: true });
cb(null, uploadDir); cb(null, uploadDir);
}, },
@@ -228,7 +230,8 @@ router.put('/branding', adminAuth, async (req, res) => {
if (currentFaviconUrl && typeof currentFaviconUrl === 'string' && currentFaviconUrl.startsWith('/uploads/favicons/')) { if (currentFaviconUrl && typeof currentFaviconUrl === 'string' && currentFaviconUrl.startsWith('/uploads/favicons/')) {
// Delete the file from filesystem // Delete the file from filesystem
const faviconPath = path.join(__dirname, '..', '..', 'storage', currentFaviconUrl.replace('/uploads/', '')); const relativePath = currentFaviconUrl.replace(/^\//, '');
const faviconPath = path.join(getStoragePath(), relativePath);
try { try {
await fs.unlink(faviconPath); await fs.unlink(faviconPath);
console.log('Deleted favicon file:', faviconPath); console.log('Deleted favicon file:', faviconPath);
@@ -258,7 +261,8 @@ router.put('/branding', adminAuth, async (req, res) => {
if (currentLogoUrl && typeof currentLogoUrl === 'string' && currentLogoUrl.startsWith('/uploads/logos/')) { if (currentLogoUrl && typeof currentLogoUrl === 'string' && currentLogoUrl.startsWith('/uploads/logos/')) {
// Delete the file from filesystem // Delete the file from filesystem
const logoPath = path.join(__dirname, '..', '..', 'storage', currentLogoUrl.replace('/uploads/', '')); const relativePath = currentLogoUrl.replace(/^\//, '');
const logoPath = path.join(getStoragePath(), relativePath);
try { try {
await fs.unlink(logoPath); await fs.unlink(logoPath);
console.log('Deleted logo file:', logoPath); console.log('Deleted logo file:', logoPath);
@@ -643,7 +647,7 @@ router.get('/storage/info', adminAuth, async (req, res) => {
for (const archive of archives) { for (const archive of archives) {
if (archive.archive_path) { if (archive.archive_path) {
try { try {
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); const storagePath = getStoragePath();
const fullArchivePath = path.join(storagePath, archive.archive_path); const fullArchivePath = path.join(storagePath, archive.archive_path);
const stats = await fs.stat(fullArchivePath); const stats = await fs.stat(fullArchivePath);
archiveStorage += stats.size; archiveStorage += stats.size;
@@ -654,7 +658,7 @@ router.get('/storage/info', adminAuth, async (req, res) => {
} }
const DEFAULT_SOFT_LIMIT_BYTES = 10 * 1024 * 1024 * 1024; // 10GB fallback const DEFAULT_SOFT_LIMIT_BYTES = 10 * 1024 * 1024 * 1024; // 10GB fallback
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); const storagePath = getStoragePath();
let diskStats = null; let diskStats = null;
let rawDiskTotal = null; let rawDiskTotal = null;
+54 -37
View File
@@ -220,7 +220,7 @@ router.post('/logout', async (req, res) => {
// Gallery password verification with enhanced security // Gallery password verification with enhanced security
router.post('/gallery/verify', [ router.post('/gallery/verify', [
body('slug').notEmpty().trim(), body('slug').notEmpty().trim(),
body('password').notEmpty() body('password').optional().isString()
], async (req, res) => { ], async (req, res) => {
try { try {
const errors = validationResult(req); const errors = validationResult(req);
@@ -232,53 +232,69 @@ router.post('/gallery/verify', [
const ipAddress = req.ip || req.connection.remoteAddress; const ipAddress = req.ip || req.connection.remoteAddress;
const userAgent = req.headers['user-agent'] || ''; const userAgent = req.headers['user-agent'] || '';
// Check gallery-specific lockout
const lockoutStatus = await checkAccountLockout(`gallery:${slug}`);
if (lockoutStatus.isLocked) {
logger.warn('Gallery access attempt on locked gallery', { slug, ipAddress });
return res.status(423).json({
error: 'Too many failed attempts. Please try again later.',
retryAfter: lockoutStatus.remainingTime
});
}
// Verify reCAPTCHA
const recaptchaValid = await verifyRecaptcha(recaptchaToken);
if (!recaptchaValid) {
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
return res.status(400).json({ error: 'reCAPTCHA verification failed' });
}
const event = await db('events').where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) }).first(); const event = await db('events').where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) }).first();
const requiresPassword = !(event && (event.require_password === false || event.require_password === 0 || event.require_password === '0'));
if (requiresPassword) {
const lockoutStatus = await checkAccountLockout(`gallery:${slug}`);
if (lockoutStatus.isLocked) {
logger.warn('Gallery access attempt on locked gallery', { slug, ipAddress });
return res.status(423).json({
error: 'Too many failed attempts. Please try again later.',
retryAfter: lockoutStatus.remainingTime
});
}
const recaptchaValid = await verifyRecaptcha(recaptchaToken);
if (!recaptchaValid) {
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
return res.status(400).json({ error: 'reCAPTCHA verification failed' });
}
}
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);
return res.status(401).json({ error: 'Invalid gallery or password' }); return res.status(401).json({ error: 'Invalid gallery or password' });
} }
const validPassword = await bcrypt.compare(password, event.password_hash); if (requiresPassword) {
if (!validPassword) { if (!password) {
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent); await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
return res.status(401).json({ error: 'Invalid gallery or password' });
}
const validPassword = await bcrypt.compare(password, event.password_hash);
if (!validPassword) {
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
await db('access_logs').insert({
event_id: event.id,
ip_address: ipAddress,
user_agent: userAgent,
action: 'login_fail'
});
return res.status(401).json({ error: 'Invalid gallery or password' });
}
await trackSuccessfulLogin(`gallery:${slug}`, ipAddress, userAgent);
await db('access_logs').insert({ await db('access_logs').insert({
event_id: event.id, event_id: event.id,
ip_address: ipAddress, ip_address: ipAddress,
user_agent: userAgent, user_agent: userAgent,
action: 'login_fail' action: 'login_success'
});
} else {
logger.info('Public gallery access granted without password', { slug, ipAddress });
await trackSuccessfulLogin(`gallery:${slug}`, ipAddress, userAgent);
await db('access_logs').insert({
event_id: event.id,
ip_address: ipAddress,
user_agent: userAgent,
action: 'login_success'
}); });
return res.status(401).json({ error: 'Invalid gallery or password' });
} }
// Successful access
await trackSuccessfulLogin(`gallery:${slug}`, ipAddress, userAgent);
// Log successful access
await db('access_logs').insert({
event_id: event.id,
ip_address: ipAddress,
user_agent: userAgent,
action: 'login_success'
});
// Generate session token with additional security info // Generate session token with additional security info
const token = jwt.sign({ const token = jwt.sign({
eventId: event.id, eventId: event.id,
@@ -302,7 +318,8 @@ router.post('/gallery/verify', [
color_theme: event.color_theme, color_theme: event.color_theme,
expires_at: event.expires_at, expires_at: event.expires_at,
allow_user_uploads: event.allow_user_uploads, allow_user_uploads: event.allow_user_uploads,
upload_category_id: event.upload_category_id upload_category_id: event.upload_category_id,
require_password: requiresPassword
} }
}); });
} catch (error) { } catch (error) {
@@ -372,4 +389,4 @@ router.post('/password-strength', [
} }
}); });
module.exports = router; module.exports = router;
+59 -42
View File
@@ -162,7 +162,7 @@ router.post('/logout', async (req, res) => {
// Gallery password verification with enhanced security // Gallery password verification with enhanced security
router.post('/gallery/verify', [ router.post('/gallery/verify', [
body('slug').notEmpty().trim(), body('slug').notEmpty().trim(),
body('password').notEmpty() body('password').optional().isString()
], async (req, res) => { ], async (req, res) => {
try { try {
const errors = validationResult(req); const errors = validationResult(req);
@@ -173,55 +173,68 @@ router.post('/gallery/verify', [
const { slug, password, recaptchaToken } = req.body; const { slug, password, recaptchaToken } = req.body;
const ipAddress = req.ip || req.connection.remoteAddress; const ipAddress = req.ip || req.connection.remoteAddress;
const userAgent = req.headers['user-agent'] || ''; const userAgent = req.headers['user-agent'] || '';
const event = await db('events')
// Check gallery-specific lockout .where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
const lockoutStatus = await checkAccountLockout(`gallery:${slug}`); .first();
if (lockoutStatus.isLocked) {
logger.warn('Gallery access attempt on locked gallery', { slug, ipAddress });
return res.status(423).json({
error: 'Too many failed attempts. Please try again later.',
retryAfter: lockoutStatus.remainingTime
});
}
// Verify reCAPTCHA
const recaptchaValid = await verifyRecaptcha(recaptchaToken);
if (!recaptchaValid) {
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
return res.status(400).json({ error: 'reCAPTCHA verification failed' });
}
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
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent); await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
return res.status(401).json({ error: 'Invalid gallery or password' }); return res.status(401).json({ error: 'Invalid gallery or password' });
} }
const validPassword = await bcrypt.compare(password, event.password_hash); const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
if (!validPassword) {
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent); if (requiresPassword) {
const lockoutStatus = await checkAccountLockout(`gallery:${slug}`);
if (lockoutStatus.isLocked) {
logger.warn('Gallery access attempt on locked gallery', { slug, ipAddress });
return res.status(423).json({
error: 'Too many failed attempts. Please try again later.',
retryAfter: lockoutStatus.remainingTime
});
}
const recaptchaValid = await verifyRecaptcha(recaptchaToken);
if (!recaptchaValid) {
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
return res.status(400).json({ error: 'reCAPTCHA verification failed' });
}
if (!password) {
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
return res.status(401).json({ error: 'Invalid gallery or password' });
}
const validPassword = await bcrypt.compare(password, event.password_hash);
if (!validPassword) {
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
await db('access_logs').insert({
event_id: event.id,
ip_address: ipAddress,
user_agent: userAgent,
action: 'login_fail'
});
return res.status(401).json({ error: 'Invalid gallery or password' });
}
await trackSuccessfulLogin(`gallery:${slug}`, ipAddress, userAgent);
await db('access_logs').insert({ await db('access_logs').insert({
event_id: event.id, event_id: event.id,
ip_address: ipAddress, ip_address: ipAddress,
user_agent: userAgent, user_agent: userAgent,
action: 'login_fail' action: 'login_success'
});
} else {
logger.info('Public gallery access granted without password', { slug, ipAddress });
await trackSuccessfulLogin(`gallery:${slug}`, ipAddress, userAgent);
await db('access_logs').insert({
event_id: event.id,
ip_address: ipAddress,
user_agent: userAgent,
action: 'login_success'
}); });
return res.status(401).json({ error: 'Invalid gallery or password' });
} }
// Successful access
await trackSuccessfulLogin(`gallery:${slug}`, ipAddress, userAgent);
// Log successful access
await db('access_logs').insert({
event_id: event.id,
ip_address: ipAddress,
user_agent: userAgent,
action: 'login_success'
});
// Generate session token with additional security info
const token = jwt.sign({ const token = jwt.sign({
eventId: event.id, eventId: event.id,
eventSlug: event.slug, eventSlug: event.slug,
@@ -246,7 +259,8 @@ router.post('/gallery/verify', [
color_theme: event.color_theme, color_theme: event.color_theme,
expires_at: event.expires_at, expires_at: event.expires_at,
allow_user_uploads: event.allow_user_uploads, allow_user_uploads: event.allow_user_uploads,
upload_category_id: event.upload_category_id upload_category_id: event.upload_category_id,
require_password: requiresPassword
} }
}); });
} catch (error) { } catch (error) {
@@ -301,6 +315,8 @@ router.post('/gallery/share-login', [
await trackSuccessfulLogin(`gallery:${slug}:share`, ipAddress, userAgent); await trackSuccessfulLogin(`gallery:${slug}:share`, ipAddress, userAgent);
setGalleryAuthCookies(res, jwtToken, event.slug); setGalleryAuthCookies(res, jwtToken, event.slug);
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
res.json({ res.json({
token: jwtToken, token: jwtToken,
event: { event: {
@@ -312,7 +328,8 @@ router.post('/gallery/share-login', [
color_theme: event.color_theme, color_theme: event.color_theme,
expires_at: event.expires_at, expires_at: event.expires_at,
allow_user_uploads: event.allow_user_uploads, allow_user_uploads: event.allow_user_uploads,
upload_category_id: event.upload_category_id upload_category_id: event.upload_category_id,
require_password: requiresPassword
} }
}); });
} catch (error) { } catch (error) {
+104 -17
View File
@@ -4,11 +4,34 @@ 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 { formatBoolean } = require('../utils/dbCompat');
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
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');
const router = express.Router(); const router = express.Router();
const parseBooleanInput = (value, defaultValue = true) => {
if (value === undefined || value === null) {
return defaultValue;
}
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'number') {
return value !== 0;
}
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase();
if (['false', '0', 'no', 'off'].includes(normalized)) {
return false;
}
if (['true', '1', 'yes', 'on'].includes(normalized)) {
return true;
}
}
return defaultValue;
};
// Create new event // Create new event
router.post('/', adminAuth, [ router.post('/', adminAuth, [
body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']), body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']),
@@ -16,7 +39,17 @@ router.post('/', adminAuth, [
body('event_date').isDate(), body('event_date').isDate(),
body('host_email').isEmail(), body('host_email').isEmail(),
body('admin_email').isEmail(), body('admin_email').isEmail(),
body('password').isLength({ min: 6 }), body('require_password').optional().isBoolean(),
body('password').optional().isString().custom((value, { req }) => {
const requirePassword = parseBooleanInput(req.body.require_password, true);
if (!requirePassword) {
return true;
}
if (typeof value !== 'string' || value.trim().length < 6) {
throw new Error('Password must be at least 6 characters long');
}
return true;
}),
body('expiration_days').isInt({ min: 1, max: 365 }).optional() body('expiration_days').isInt({ min: 1, max: 365 }).optional()
], async (req, res) => { ], async (req, res) => {
try { try {
@@ -32,10 +65,28 @@ router.post('/', adminAuth, [
host_email, host_email,
admin_email, admin_email,
password, password,
require_password: requirePasswordInput = true,
welcome_message, welcome_message,
color_theme, color_theme,
expiration_days = 30 expiration_days = 30
} = req.body; } = req.body;
const requirePassword = parseBooleanInput(requirePasswordInput, true);
if (requirePassword) {
const passwordValidation = await validatePasswordInContext(password, 'gallery', {
eventName: event_name
});
if (!passwordValidation.valid) {
return res.status(400).json({
error: 'Password does not meet security requirements',
details: passwordValidation.errors,
score: passwordValidation.score,
feedback: passwordValidation.feedback
});
}
}
// Generate unique slug // Generate unique slug
const baseSlug = `${event_type}-${event_name.toLowerCase().replace(/[^a-z0-9]/g, '-')}-${event_date}`; const baseSlug = `${event_type}-${event_name.toLowerCase().replace(/[^a-z0-9]/g, '-')}-${event_date}`;
@@ -49,10 +100,15 @@ router.post('/', adminAuth, [
// Generate share link (just slug/token, not full URL) // Generate share link (just slug/token, not full URL)
const shareToken = crypto.randomBytes(16).toString('hex'); const shareToken = crypto.randomBytes(16).toString('hex');
const shareLink = `${slug}/${shareToken}`; const sharePath = `/gallery/${slug}/${shareToken}`;
const frontendBase = (process.env.FRONTEND_URL || '').replace(/\/$/, '');
const fullShareLink = frontendBase ? `${frontendBase}${sharePath}` : sharePath;
const shareLinkSlug = `${slug}/${shareToken}`;
// Hash password // Hash password (or placeholder when not required)
const password_hash = await bcrypt.hash(password, 10); const password_hash = requirePassword
? await bcrypt.hash(password, getBcryptRounds())
: await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds());
// Calculate expiration date (days after event date) // Calculate expiration date (days after event date)
const expires_at = new Date(event_date); const expires_at = new Date(event_date);
@@ -75,8 +131,9 @@ router.post('/', adminAuth, [
password_hash, password_hash,
welcome_message, welcome_message,
color_theme, color_theme,
share_link: shareLink, share_link: shareLinkSlug,
expires_at expires_at,
require_password: formatBoolean(requirePassword)
}).returning('id'); }).returning('id');
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs) // Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
@@ -88,17 +145,18 @@ router.post('/', adminAuth, [
host_name: host_email.split('@')[0], // Extract name from email host_name: host_email.split('@')[0], // Extract name from email
event_name, event_name,
event_date: event_date, // Pass raw date - will be formatted by email processor event_date: event_date, // Pass raw date - will be formatted by email processor
gallery_link: shareLink, gallery_link: fullShareLink,
gallery_password: password, gallery_password: requirePassword ? password : 'No password required',
expiry_date: expires_at.toISOString(), // Pass ISO string - will be formatted by email processor expiry_date: expires_at.toISOString(), // Pass ISO string - will be formatted by email processor
welcome_message: welcome_message || '' welcome_message: welcome_message || ''
}); });
res.json({ res.json({
id: eventId, id: eventId,
slug, slug,
share_link: shareLink, share_link: fullShareLink,
expires_at expires_at,
require_password: requirePassword
}); });
} catch (error) { } catch (error) {
console.error(error); console.error(error);
@@ -137,17 +195,46 @@ router.get('/', adminAuth, async (req, res) => {
router.put('/:id', adminAuth, async (req, res) => { router.put('/:id', adminAuth, async (req, res) => {
try { try {
const { id } = req.params; const { id } = req.params;
const updates = req.body; const updates = { ...req.body };
// Don't allow updating certain fields // Don't allow updating certain fields
delete updates.id; delete updates.id;
delete updates.slug; delete updates.slug;
delete updates.created_at; delete updates.created_at;
delete updates.password_confirmation;
// If updating password, hash it
if (updates.password) { const hasRequirePasswordUpdate = Object.prototype.hasOwnProperty.call(updates, 'require_password');
updates.password_hash = await bcrypt.hash(updates.password, 10); let requirePasswordUpdate;
delete updates.password; if (hasRequirePasswordUpdate) {
requirePasswordUpdate = parseBooleanInput(updates.require_password, true);
updates.require_password = formatBoolean(requirePasswordUpdate);
}
let newPasswordPlain;
if (Object.prototype.hasOwnProperty.call(updates, 'password')) {
if (updates.password === undefined || updates.password === null || updates.password === '') {
delete updates.password;
} else {
newPasswordPlain = updates.password;
delete updates.password;
}
}
const event = await db('events').where('id', id).first();
if (!event) {
return res.status(404).json({ error: 'Event not found' });
}
const currentRequirePassword = parseBooleanInput(event.require_password, true);
if (hasRequirePasswordUpdate && requirePasswordUpdate === true && !currentRequirePassword && !newPasswordPlain) {
return res.status(400).json({ error: 'Password must be provided when enabling password requirement.' });
}
if (newPasswordPlain) {
updates.password_hash = await bcrypt.hash(newPasswordPlain, getBcryptRounds());
} else if (hasRequirePasswordUpdate && requirePasswordUpdate === false && currentRequirePassword) {
updates.password_hash = await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds());
} }
await db('events').where('id', id).update(updates); await db('events').where('id', id).update(updates);
+110 -47
View File
@@ -1,5 +1,4 @@
const express = require('express'); const express = require('express');
const jwt = require('jsonwebtoken');
const { db } = require('../database/db'); const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat'); const { formatBoolean } = require('../utils/dbCompat');
const archiver = require('archiver'); const archiver = require('archiver');
@@ -8,8 +7,8 @@ const router = express.Router();
const watermarkService = require('../services/watermarkService'); const watermarkService = require('../services/watermarkService');
const { verifyGalleryAccess } = require('../middleware/gallery'); const { verifyGalleryAccess } = require('../middleware/gallery');
const secureImageService = require('../services/secureImageService'); const secureImageService = require('../services/secureImageService');
const secureImageMiddleware = require('../middleware/secureImageMiddleware');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
const { resolvePhotoFilePath } = require('../services/photoResolver');
// 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');
@@ -20,7 +19,7 @@ router.get('/:slug/verify-token/:token', async (req, res) => {
const { slug, token } = req.params; const { slug, token } = req.params;
const event = await db('events') const event = await db('events')
.where({ share_link: slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) }) .where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
.select('id', 'share_link') .select('id', 'share_link')
.first(); .first();
@@ -48,9 +47,22 @@ router.get('/:slug/info', async (req, res) => {
const { token } = req.query; const { token } = req.query;
const event = await db('events') const event = await db('events')
.where({ slug: slug }) .where({ slug })
.select('event_name', 'event_type', 'event_date', 'expires_at', 'is_active', 'is_archived', 'share_link', .select(
'allow_downloads', 'disable_right_click', 'watermark_downloads', 'watermark_text') 'event_name',
'event_type',
'event_date',
'expires_at',
'is_active',
'is_archived',
'share_link',
'allow_downloads',
'disable_right_click',
'watermark_downloads',
'watermark_text',
'require_password',
'color_theme'
)
.first(); .first();
if (!event) { if (!event) {
@@ -74,6 +86,8 @@ router.get('/:slug/info', async (req, res) => {
} }
} }
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
res.json({ res.json({
event_name: event.event_name, event_name: event.event_name,
event_type: event.event_type, event_type: event.event_type,
@@ -81,11 +95,11 @@ router.get('/:slug/info', async (req, res) => {
expires_at: event.expires_at, expires_at: event.expires_at,
is_active: event.is_active, is_active: event.is_active,
is_expired: !event.is_active || new Date(event.expires_at) < new Date(), is_expired: !event.is_active || new Date(event.expires_at) < new Date(),
requires_password: true, requires_password: requiresPassword,
color_theme: event.color_theme, color_theme: event.color_theme,
allow_downloads: event.allow_downloads !== false, allow_downloads: !(event.allow_downloads === false || event.allow_downloads === 0 || event.allow_downloads === '0'),
disable_right_click: event.disable_right_click === true, disable_right_click: event.disable_right_click === true || event.disable_right_click === 1 || event.disable_right_click === '1',
watermark_downloads: event.watermark_downloads === true, watermark_downloads: event.watermark_downloads === true || event.watermark_downloads === 1 || event.watermark_downloads === '1',
watermark_text: event.watermark_text watermark_text: event.watermark_text
}); });
} catch (error) { } catch (error) {
@@ -99,7 +113,6 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
try { try {
// Get filter parameters from query // Get filter parameters from query
const { filter, guest_id } = req.query; const { filter, guest_id } = req.query;
const feedbackService = require('../services/feedbackService');
// First get all photos // First get all photos
let photos = await db('photos') let photos = await db('photos')
@@ -317,16 +330,17 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, async (req, res) =>
photo_id: photoId photo_id: photoId
}); });
// Photo path should be in storage/events/active directory
// Handle both legacy paths (just slug/filename) and new paths (events/active/slug/filename)
const storagePath = getStoragePath();
let filePath; let filePath;
if (photo.path.startsWith('events/active/')) { try {
// New format: path already includes events/active/ prefix filePath = resolvePhotoFilePath(req.event, photo);
filePath = path.join(storagePath, photo.path); } catch (resolveError) {
} else { logger.error('Failed to resolve photo path for download', {
// Legacy format: path is just slug/filename slug: req.params.slug,
filePath = path.join(storagePath, 'events/active', photo.path); photoId,
eventId: req.event.id,
error: resolveError.message,
});
return res.status(404).json({ error: 'Photo file not found' });
} }
// Get watermark settings // Get watermark settings
@@ -345,9 +359,24 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, async (req, res) =>
res.send(watermarkedBuffer); res.send(watermarkedBuffer);
} else { } else {
// Send original file // Send original file
res.download(filePath, photo.filename); res.download(filePath, photo.filename, (downloadError) => {
if (downloadError) {
logger.error('Error streaming gallery download', {
slug: req.params.slug,
photoId,
eventId: req.event.id,
error: downloadError.message,
});
}
});
} }
} catch (error) { } catch (error) {
logger.error('Unexpected error processing gallery download', {
slug: req.params.slug,
photoId: req.params.photoId,
eventId: req.event?.id,
error: error.message,
});
res.status(500).json({ error: 'Failed to download photo' }); res.status(500).json({ error: 'Failed to download photo' });
} }
}); });
@@ -390,16 +419,17 @@ router.get('/:slug/download-all', verifyGalleryAccess, async (req, res) => {
// Add photos to archive // Add photos to archive
for (const photo of photos) { for (const photo of photos) {
// Photo path should be in storage/events/active directory
// Handle both legacy paths (just slug/filename) and new paths (events/active/slug/filename)
const storagePath = getStoragePath();
let filePath; let filePath;
if (photo.path.startsWith('events/active/')) { try {
// New format: path already includes events/active/ prefix filePath = resolvePhotoFilePath(req.event, photo);
filePath = path.join(storagePath, photo.path); } catch (resolveError) {
} else { logger.warn('Skipping photo in bulk download due to unresolved path', {
// Legacy format: path is just slug/filename slug: req.params.slug,
filePath = path.join(storagePath, 'events/active', photo.path); photoId: photo.id,
eventId: req.event.id,
error: resolveError.message,
});
continue;
} }
// Determine the file name in the archive // Determine the file name in the archive
@@ -414,11 +444,18 @@ router.get('/:slug/download-all', verifyGalleryAccess, async (req, res) => {
} }
if (watermarkSettings && watermarkSettings.enabled) { if (watermarkSettings && watermarkSettings.enabled) {
// Apply watermark try {
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings); const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings);
archive.append(watermarkedBuffer, { name: archiveName }); archive.append(watermarkedBuffer, { name: archiveName });
} catch (watermarkError) {
logger.warn('Failed to watermark photo for bulk download, skipping original to avoid leak', {
slug: req.params.slug,
photoId: photo.id,
eventId: req.event.id,
error: watermarkError.message,
});
}
} else { } else {
// Add original file
archive.file(filePath, { name: archiveName }); archive.file(filePath, { name: archiveName });
} }
} }
@@ -433,6 +470,11 @@ router.get('/:slug/download-all', verifyGalleryAccess, async (req, res) => {
action: 'download_all' action: 'download_all'
}); });
} catch (error) { } catch (error) {
logger.error('Error creating bulk gallery download', {
slug: req.params.slug,
eventId: req.event?.id,
error: error.message,
});
res.status(500).json({ error: 'Failed to create download archive' }); res.status(500).json({ error: 'Failed to create download archive' });
} }
}); });
@@ -477,30 +519,47 @@ router.post('/:slug/download-selected', verifyGalleryAccess, async (req, res) =>
const archive = archiver('zip', { zlib: { level: 5 } }); const archive = archiver('zip', { zlib: { level: 5 } });
archive.on('error', (err) => { archive.on('error', (err) => {
console.error('Zip error:', err); logger.error('Zip error generating selected download', {
try { res.status(500).end(); } catch (e) {} slug: req.params.slug,
eventId: req.event?.id,
error: err.message,
});
try {
res.status(500).end();
} catch (_) {
// ignore double-send errors
}
}); });
archive.pipe(res); archive.pipe(res);
const { resolvePhotoFilePath } = require('../services/photoResolver');
const fs = require('fs');
// Check watermark settings similar to download-all // Check watermark settings similar to download-all
const watermarkSettings = await watermarkService.getWatermarkSettings(); const watermarkSettings = await watermarkService.getWatermarkSettings();
for (const photo of photos) { for (const photo of photos) {
try { try {
const filePath = resolvePhotoFilePath(req.event, photo); const filePath = resolvePhotoFilePath(req.event, photo);
if (filePath && fs.existsSync(filePath)) { const name = photo.filename || `photo-${photo.id}.jpg`;
const name = photo.filename || `photo-${photo.id}.jpg`; if (watermarkSettings && watermarkSettings.enabled) {
if (watermarkSettings && watermarkSettings.enabled) { try {
// Apply watermark like download-all
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings); const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings);
archive.append(watermarkedBuffer, { name }); archive.append(watermarkedBuffer, { name });
} else { } catch (watermarkError) {
archive.file(filePath, { name }); logger.warn('Failed to watermark selected photo, skipping original to avoid leak', {
slug: req.params.slug,
photoId: photo.id,
eventId: req.event.id,
error: watermarkError.message,
});
} }
} else {
archive.file(filePath, { name });
} }
} catch (e) { } catch (resolveError) {
// skip missing/inaccessible files logger.warn('Skipping selected photo due to unresolved path', {
slug: req.params.slug,
photoId: photo.id,
eventId: req.event.id,
error: resolveError.message,
});
} }
} }
@@ -513,7 +572,11 @@ router.post('/:slug/download-selected', verifyGalleryAccess, async (req, res) =>
action: 'download_selected' action: 'download_selected'
}); });
} catch (error) { } catch (error) {
console.error('Error in download-selected:', error); logger.error('Error in download-selected:', {
slug: req.params.slug,
eventId: req.event?.id,
error: error.message,
});
res.status(500).json({ error: 'Failed to download selected photos' }); res.status(500).json({ error: 'Failed to download selected photos' });
} }
}); });
+30 -11
View File
@@ -1,17 +1,14 @@
const express = require('express'); const express = require('express');
const path = require('path');
const { db } = require('../database/db'); const { db } = require('../database/db');
const { verifyGalleryAccess } = require('../middleware/gallery'); const { verifyGalleryAccess } = require('../middleware/gallery');
const secureImageService = require('../services/secureImageService'); const secureImageService = require('../services/secureImageService');
const secureImageMiddleware = require('../middleware/secureImageMiddleware'); const secureImageMiddleware = require('../middleware/secureImageMiddleware');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
const { formatBoolean } = require('../utils/dbCompat'); const { formatBoolean } = require('../utils/dbCompat');
const { resolvePhotoFilePath } = require('../services/photoResolver');
const router = express.Router(); const router = express.Router();
// Get storage path from environment or default
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
/** /**
* Generate secure token for image access * Generate secure token for image access
*/ */
@@ -94,11 +91,11 @@ router.get('/:slug/secure/:photoId/:token',
const { slug, photoId, token } = req.params; // Move outside try block for error handler access const { slug, photoId, token } = req.params; // Move outside try block for error handler access
try { try {
console.log('Secure image route hit:', { logger.debug('Secure image route hit', {
slug: slug, slug,
photoId: photoId, photoId,
tokenLength: token?.length, tokenLength: token?.length,
headers: req.headers.authorization ? 'present' : 'absent' hasAuthHeader: Boolean(req.headers.authorization),
}); });
const { fragment } = req.query; const { fragment } = req.query;
@@ -142,7 +139,18 @@ router.get('/:slug/secure/:photoId/:token',
return res.status(404).json({ error: 'Photo not found' }); return res.status(404).json({ error: 'Photo not found' });
} }
const filePath = path.join(getStoragePath(), 'events/active', photo.path); let filePath;
try {
filePath = resolvePhotoFilePath(req.event, photo);
} catch (resolveError) {
logger.error('Failed to resolve photo path for secure token generation', {
slug: req.params.slug,
photoId,
eventId: req.event.id,
error: resolveError.message,
});
return res.status(404).json({ error: 'Photo file not found' });
}
// Get protection settings for this event // Get protection settings for this event
const protectionSettings = { const protectionSettings = {
@@ -284,7 +292,18 @@ router.get('/:slug/secure-download/:photoId/:token',
return res.status(404).json({ error: 'Photo not found' }); return res.status(404).json({ error: 'Photo not found' });
} }
const filePath = path.join(getStoragePath(), 'events/active', photo.path); let filePath;
try {
filePath = resolvePhotoFilePath(req.event, photo);
} catch (resolveError) {
logger.error('Failed to resolve photo path for secure download', {
slug: req.params.slug,
photoId,
eventId: req.event.id,
error: resolveError.message,
});
return res.status(404).json({ error: 'Photo file not found' });
}
// Apply watermark if enabled // Apply watermark if enabled
const watermarkService = require('../services/watermarkService'); const watermarkService = require('../services/watermarkService');
@@ -426,4 +445,4 @@ async function getSuspiciousActivityStats() {
} }
} }
module.exports = router; module.exports = router;
+7 -1
View File
@@ -132,6 +132,12 @@ async function processTemplate(template, variables, language = 'en') {
? '(Aus Sicherheitsgründen nicht angezeigt)' ? '(Aus Sicherheitsgründen nicht angezeigt)'
: '(Not shown for security reasons)'; : '(Not shown for security reasons)';
} }
if (processedVariables.gallery_password === 'No password required') {
processedVariables.gallery_password = language === 'de'
? 'Kein Passwort erforderlich'
: 'No password required';
}
// Format dates if they exist // Format dates if they exist
if (processedVariables.event_date) { if (processedVariables.event_date) {
@@ -546,4 +552,4 @@ module.exports = {
queueEmail, queueEmail,
stopEmailQueueProcessor, stopEmailQueueProcessor,
testEmailConnection testEmailConnection
}; };
+44 -2
View File
@@ -1,9 +1,52 @@
const fs = require('fs').promises; const fs = require('fs').promises;
const fsSync = require('fs');
const path = require('path'); const path = require('path');
const { safePathJoin } = require('../utils/fileSecurityUtils'); const { safePathJoin } = require('../utils/fileSecurityUtils');
let cachedRoot = null;
function resolveDefaultRoot() {
const containerDefault = '/external-media';
try {
if (fsSync.existsSync(containerDefault)) {
return containerDefault;
}
} catch (error) {
// ignore lookup errors, fallback below
}
const localFallback = path.resolve(__dirname, '../../..', 'storage/external-media');
try {
if (fsSync.existsSync(localFallback)) {
return localFallback;
}
} catch (error) {
// ignore and return container default
}
return containerDefault;
}
function getExternalMediaRoot() { function getExternalMediaRoot() {
return process.env.EXTERNAL_MEDIA_ROOT || '/external-media'; if (cachedRoot) {
return cachedRoot;
}
const configured = process.env.EXTERNAL_MEDIA_ROOT;
if (configured && configured.trim()) {
const resolvedConfigured = path.resolve(configured.trim());
try {
if (fsSync.existsSync(resolvedConfigured)) {
cachedRoot = resolvedConfigured;
return cachedRoot;
}
} catch (error) {
// ignore lookup errors and fall back to defaults
}
}
cachedRoot = resolveDefaultRoot();
return cachedRoot;
} }
function isUnderRoot(p) { function isUnderRoot(p) {
@@ -64,4 +107,3 @@ module.exports = {
list, list,
resolveExternalPath, resolveExternalPath,
}; };
+3 -1
View File
@@ -108,6 +108,8 @@ If ADMIN_CREDENTIALS.txt is missing:
- Check the console output from when you ran migrations - Check the console output from when you ran migrations
- File is created in the backend directory root - File is created in the backend directory root
- File might have been deleted for security (as recommended) - File might have been deleted for security (as recommended)
- Regenerate it by running `node scripts/reset-admin-password.js --force --credentials-file data/ADMIN_CREDENTIALS.txt`
- When using the unified `setup.sh` installer for a reinstall, append `--force-admin-password-reset` to have the script perform the reset automatically
## Best Practices ## Best Practices
@@ -161,4 +163,4 @@ If upgrading from the old system with hardcoded `admin123`:
- [ ] Stored new password in password manager - [ ] Stored new password in password manager
- [ ] Tested login with new password - [ ] Tested login with new password
- [ ] Set up additional admin accounts if needed - [ ] Set up additional admin accounts if needed
- [ ] Configured password policies for organization - [ ] Configured password policies for organization
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "picpeak-frontend", "name": "picpeak-frontend",
"version": "1.1.1", "version": "1.1.8",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "picpeak-frontend", "name": "picpeak-frontend",
"version": "1.1.1", "version": "1.1.8",
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.0.0", "@tanstack/react-query": "^5.0.0",
"@tiptap/extension-character-count": "^2.26.1", "@tiptap/extension-character-count": "^2.26.1",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "picpeak-frontend", "name": "picpeak-frontend",
"private": true, "private": true,
"version": "1.1.1", "version": "1.1.8",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+35 -9
View File
@@ -3,6 +3,7 @@ import type { ReactNode } from 'react';
import { api } from '../config/api'; import { api } from '../config/api';
import { authService, galleryService } from '../services'; import { authService, galleryService } from '../services';
import { cleanupOldGalleryAuth } from '../utils/cleanupGalleryAuth'; import { cleanupOldGalleryAuth } from '../utils/cleanupGalleryAuth';
import { normalizeRequirePassword } from '../utils/accessControl';
import { import {
clearActiveGallerySlug, clearActiveGallerySlug,
clearGalleryToken, clearGalleryToken,
@@ -18,12 +19,24 @@ interface GalleryEvent {
welcome_message?: string; welcome_message?: string;
color_theme?: string; color_theme?: string;
expires_at: string; expires_at: string;
require_password?: boolean;
} }
const normalizeEvent = (incoming: GalleryEvent | null | undefined): GalleryEvent | null => {
if (!incoming) {
return null;
}
return {
...incoming,
require_password: normalizeRequirePassword(incoming.require_password, true),
};
};
interface GalleryAuthContextType { interface GalleryAuthContextType {
isAuthenticated: boolean; isAuthenticated: boolean;
event: GalleryEvent | null; event: GalleryEvent | null;
login: (slug: string, password: string, recaptchaToken?: string | null) => Promise<void>; login: (slug: string, password?: string, recaptchaToken?: string | null) => Promise<void>;
logout: () => void; logout: () => void;
isLoading: boolean; isLoading: boolean;
error: string | null; error: string | null;
@@ -83,7 +96,11 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
try { try {
const parsed = JSON.parse(storedEvent); const parsed = JSON.parse(storedEvent);
if (parsed && parsed.id) { if (parsed && parsed.id) {
setEvent(parsed); const normalizedStored = normalizeEvent(parsed);
setEvent(normalizedStored);
if (normalizedStored) {
sessionStorage.setItem(`gallery_event_${currentSlug}`, JSON.stringify(normalizedStored));
}
} }
} catch (err) { } catch (err) {
sessionStorage.removeItem(`gallery_event_${currentSlug}`); sessionStorage.removeItem(`gallery_event_${currentSlug}`);
@@ -104,8 +121,11 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
// Fetch gallery details to hydrate context // Fetch gallery details to hydrate context
const galleryData = await galleryService.getGalleryPhotos(currentSlug); const galleryData = await galleryService.getGalleryPhotos(currentSlug);
if (galleryData?.event) { if (galleryData?.event) {
setEvent(galleryData.event); const normalizedEvent = normalizeEvent(galleryData.event);
sessionStorage.setItem(`gallery_event_${currentSlug}`, JSON.stringify(galleryData.event)); setEvent(normalizedEvent);
if (normalizedEvent) {
sessionStorage.setItem(`gallery_event_${currentSlug}`, JSON.stringify(normalizedEvent));
}
} }
} }
@@ -121,9 +141,12 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
if (verify?.valid) { if (verify?.valid) {
const response = await authService.shareLinkLogin(currentSlug, urlToken); const response = await authService.shareLinkLogin(currentSlug, urlToken);
if (response?.event) { if (response?.event) {
setEvent(response.event); const normalizedEvent = normalizeEvent(response.event);
setEvent(normalizedEvent);
setIsAuthenticated(true); setIsAuthenticated(true);
sessionStorage.setItem(`gallery_event_${currentSlug}`, JSON.stringify(response.event)); if (normalizedEvent) {
sessionStorage.setItem(`gallery_event_${currentSlug}`, JSON.stringify(normalizedEvent));
}
if (response.token) { if (response.token) {
storeGalleryToken(currentSlug, response.token); storeGalleryToken(currentSlug, response.token);
} }
@@ -154,12 +177,13 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
}; };
}, []); }, []);
const login = async (slug: string, password: string, recaptchaToken?: string | null) => { const login = async (slug: string, password?: string, recaptchaToken?: string | null) => {
try { try {
setError(null); setError(null);
setIsLoading(true); setIsLoading(true);
const response = await authService.verifyGalleryPassword(slug, password, recaptchaToken); const response = await authService.verifyGalleryPassword(slug, password, recaptchaToken);
setEvent(response.event); const normalizedEvent = normalizeEvent(response.event);
setEvent(normalizedEvent);
setIsAuthenticated(true); setIsAuthenticated(true);
if (response.token) { if (response.token) {
storeGalleryToken(slug, response.token); storeGalleryToken(slug, response.token);
@@ -167,7 +191,9 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
setActiveGallerySlug(slug); setActiveGallerySlug(slug);
// Store event data for quick reloads (non-sensitive) // Store event data for quick reloads (non-sensitive)
sessionStorage.setItem(`gallery_event_${slug}`, JSON.stringify(response.event)); if (normalizedEvent) {
sessionStorage.setItem(`gallery_event_${slug}`, JSON.stringify(normalizedEvent));
}
} catch (err: any) { } catch (err: any) {
setError(err.response?.data?.error || 'Invalid password'); setError(err.response?.data?.error || 'Invalid password');
throw err; throw err;
+10 -1
View File
@@ -496,6 +496,8 @@
"expiresIn": "Galerie läuft in {{count}} Tag ab", "expiresIn": "Galerie läuft in {{count}} Tag ab",
"expiresIn_plural": "Galerie läuft in {{count}} Tagen ab", "expiresIn_plural": "Galerie läuft in {{count}} Tagen ab",
"downloadBefore": "Laden Sie Ihre Fotos herunter, bevor sie nicht mehr verfügbar sind.", "downloadBefore": "Laden Sie Ihre Fotos herunter, bevor sie nicht mehr verfügbar sind.",
"publicGalleryTitle": "Diese Galerie ist öffentlich zugänglich",
"publicGallerySubtitle": "Fotos werden geladen...",
"viewGallery": "Galerie anzeigen", "viewGallery": "Galerie anzeigen",
"downloadAll": "Alle herunterladen", "downloadAll": "Alle herunterladen",
"downloading": "Lade {{count}} Foto herunter...", "downloading": "Lade {{count}} Foto herunter...",
@@ -607,6 +609,7 @@
"created": "Erstellt", "created": "Erstellt",
"expires": "Läuft ab", "expires": "Läuft ab",
"shareWithGuests": "Teilen Sie diesen Link mit Gästen. Sie benötigen das Passwort, um auf die Galerie zuzugreifen.", "shareWithGuests": "Teilen Sie diesen Link mit Gästen. Sie benötigen das Passwort, um auf die Galerie zuzugreifen.",
"shareWithGuestsPublic": "Teilen Sie diesen Link mit Gästen. Für diese Galerie ist kein Passwort erforderlich.",
"resetGalleryPassword": "Galerie-Passwort zurücksetzen", "resetGalleryPassword": "Galerie-Passwort zurücksetzen",
"resendCreationEmail": "Erstellungs-E-Mail erneut senden", "resendCreationEmail": "Erstellungs-E-Mail erneut senden",
"creationEmailResent": "Die Erstellungs-E-Mail wurde zur Warteschlange hinzugefügt", "creationEmailResent": "Die Erstellungs-E-Mail wurde zur Warteschlange hinzugefügt",
@@ -632,10 +635,14 @@
"adminEmailHelp": "Erhält Systembenachrichtigungen und Archivbestätigungen", "adminEmailHelp": "Erhält Systembenachrichtigungen und Archivbestätigungen",
"securityAccess": "Sicherheit & Zugriff", "securityAccess": "Sicherheit & Zugriff",
"galleryPassword": "Galerie-Passwort", "galleryPassword": "Galerie-Passwort",
"requirePasswordToggle": "Galerie mit Passwort schützen",
"requirePasswordToggleHelp": "Deaktivieren Sie diese Option, wenn die Galerie ohne Passwort geteilt werden soll. Jeder mit dem Link kann die Fotos ansehen.",
"publicGalleryWarning": "Öffentliche Galerien sind für jeden mit dem Link zugänglich. Aktivieren Sie gegebenenfalls Wasserzeichen und behalten Sie die Aktivität im Blick.",
"passwordHelperText": "Sie können Datumsangaben wie \"04.07.2025\" oder beliebigen Text mit mindestens 6 Zeichen verwenden", "passwordHelperText": "Sie können Datumsangaben wie \"04.07.2025\" oder beliebigen Text mit mindestens 6 Zeichen verwenden",
"passwordPlaceholder": "Sicheres Passwort eingeben", "passwordPlaceholder": "Sicheres Passwort eingeben",
"confirmPassword": "Passwort bestätigen", "confirmPassword": "Passwort bestätigen",
"showPasswords": "Passwörter anzeigen", "showPasswords": "Passwörter anzeigen",
"newPasswordLabel": "Neues Galerie-Passwort",
"gallerySettings": "Galerie-Einstellungen", "gallerySettings": "Galerie-Einstellungen",
"colorTheme": "Farbthema", "colorTheme": "Farbthema",
"galleryExpiration": "Galerie-Ablauf", "galleryExpiration": "Galerie-Ablauf",
@@ -735,6 +742,9 @@
"noEventsDescription": "Erstellen Sie Ihre erste Veranstaltung, um zu beginnen.", "noEventsDescription": "Erstellen Sie Ihre erste Veranstaltung, um zu beginnen.",
"eventsSelected": "{{count}} Veranstaltung ausgewählt", "eventsSelected": "{{count}} Veranstaltung ausgewählt",
"eventsSelected_plural": "{{count}} Veranstaltungen ausgewählt", "eventsSelected_plural": "{{count}} Veranstaltungen ausgewählt",
"publicAccess": "Öffentlicher Zugriff",
"passwordProtected": "Passwortgeschützt",
"newPasswordRequired": "Bitte legen Sie vor dem Aktivieren des Passwortschutzes ein Passwort fest.",
"viewDetails": "Details anzeigen", "viewDetails": "Details anzeigen",
"archiveEventAction": "Veranstaltung archivieren", "archiveEventAction": "Veranstaltung archivieren",
"downloadArchiveAction": "Archiv herunterladen", "downloadArchiveAction": "Archiv herunterladen",
@@ -853,7 +863,6 @@
"security": { "security": {
"title": "Sicherheit", "title": "Sicherheit",
"passwordSettings": "Passworteinstellungen", "passwordSettings": "Passworteinstellungen",
"requirePassword": "Passwort für alle Galerien erforderlich",
"minPasswordLength": "Minimale Passwortlänge", "minPasswordLength": "Minimale Passwortlänge",
"minPasswordLengthHelp": "Mindestanzahl von Zeichen für Galerie-Passwörter", "minPasswordLengthHelp": "Mindestanzahl von Zeichen für Galerie-Passwörter",
"passwordComplexity": "Passwort-Komplexität", "passwordComplexity": "Passwort-Komplexität",
+10 -1
View File
@@ -161,6 +161,8 @@
"expiresIn": "Gallery expires in {{count}} day", "expiresIn": "Gallery expires in {{count}} day",
"expiresIn_plural": "Gallery expires in {{count}} days", "expiresIn_plural": "Gallery expires in {{count}} days",
"downloadBefore": "Download your photos before they're no longer available.", "downloadBefore": "Download your photos before they're no longer available.",
"publicGalleryTitle": "This gallery is publicly accessible",
"publicGallerySubtitle": "Loading the photos now...",
"viewGallery": "View Gallery", "viewGallery": "View Gallery",
"downloadAll": "Download All", "downloadAll": "Download All",
"downloading": "Downloading {{count}} photo...", "downloading": "Downloading {{count}} photo...",
@@ -290,6 +292,7 @@
"created": "Created", "created": "Created",
"expires": "Expires", "expires": "Expires",
"shareWithGuests": "Share this link with guests. They'll need the password to access the gallery.", "shareWithGuests": "Share this link with guests. They'll need the password to access the gallery.",
"shareWithGuestsPublic": "Share this link with guests. No password is required for this gallery.",
"resetGalleryPassword": "Reset Gallery Password", "resetGalleryPassword": "Reset Gallery Password",
"resendCreationEmail": "Resend Creation Email", "resendCreationEmail": "Resend Creation Email",
"creationEmailResent": "Creation email has been queued for sending", "creationEmailResent": "Creation email has been queued for sending",
@@ -316,9 +319,13 @@
"adminEmailHelp": "Will receive system notifications and archive confirmations", "adminEmailHelp": "Will receive system notifications and archive confirmations",
"securityAccess": "Security & Access", "securityAccess": "Security & Access",
"galleryPassword": "Gallery Password", "galleryPassword": "Gallery Password",
"requirePasswordToggle": "Require password for this gallery",
"requirePasswordToggleHelp": "Disable this if you want to share the gallery without a password. Anyone with the link will be able to view the photos.",
"publicGalleryWarning": "Public galleries are accessible to anyone with the link. Consider enabling download watermarks and monitoring activity.",
"passwordHelperText": "You can use dates like \"04.07.2025\" or any text with 6+ characters", "passwordHelperText": "You can use dates like \"04.07.2025\" or any text with 6+ characters",
"confirmPassword": "Confirm Password", "confirmPassword": "Confirm Password",
"showPasswords": "Show passwords", "showPasswords": "Show passwords",
"newPasswordLabel": "New Gallery Password",
"gallerySettings": "Gallery Settings", "gallerySettings": "Gallery Settings",
"themeAndStyle": "Theme & Style", "themeAndStyle": "Theme & Style",
"colorTheme": "Color Theme", "colorTheme": "Color Theme",
@@ -373,6 +380,9 @@
"eventsSelected_plural": "{{count}} events selected", "eventsSelected_plural": "{{count}} events selected",
"clear": "Clear", "clear": "Clear",
"archiveSelected": "Archive Selected", "archiveSelected": "Archive Selected",
"publicAccess": "Public access",
"passwordProtected": "Password protected",
"newPasswordRequired": "Please set a password before enabling protection.",
"event": "Event", "event": "Event",
"type": "Type", "type": "Type",
"date": "Date", "date": "Date",
@@ -533,7 +543,6 @@
"security": { "security": {
"title": "Security", "title": "Security",
"passwordSettings": "Password Settings", "passwordSettings": "Password Settings",
"requirePassword": "Require password for all galleries",
"minPasswordLength": "Minimum Password Length", "minPasswordLength": "Minimum Password Length",
"minPasswordLengthHelp": "Minimum number of characters for gallery passwords", "minPasswordLengthHelp": "Minimum number of characters for gallery passwords",
"passwordComplexity": "Password Complexity", "passwordComplexity": "Password Complexity",
+95 -47
View File
@@ -14,6 +14,7 @@ import { analyticsService } from '../services/analytics.service';
import { api } from '../config/api'; import { api } from '../config/api';
import { GALLERY_THEME_PRESETS } from '../types/theme.types'; import { GALLERY_THEME_PRESETS } from '../types/theme.types';
import { buildResourceUrl } from '../utils/url'; import { buildResourceUrl } from '../utils/url';
import { isGalleryPublic, normalizeRequirePassword } from '../utils/accessControl';
export const GalleryPage: React.FC = () => { export const GalleryPage: React.FC = () => {
const { slug, token } = useParams<{ slug: string; token?: string }>(); const { slug, token } = useParams<{ slug: string; token?: string }>();
@@ -25,9 +26,11 @@ export const GalleryPage: React.FC = () => {
const [isLoggingIn, setIsLoggingIn] = useState(false); const [isLoggingIn, setIsLoggingIn] = useState(false);
const [loginError, setLoginError] = useState<string | null>(null); const [loginError, setLoginError] = useState<string | null>(null);
const [recaptchaToken, setRecaptchaToken] = useState<string | null>(null); const [recaptchaToken, setRecaptchaToken] = useState<string | null>(null);
const [autoLoginAttempted, setAutoLoginAttempted] = useState(false);
// Fetch gallery info (public data) // Fetch gallery info (public data)
const { data: galleryInfo, isLoading: isLoadingInfo, error: infoError } = useGalleryInfo(slug!, token); const { data: galleryInfo, isLoading: isLoadingInfo, error: infoError } = useGalleryInfo(slug!, token);
const requiresPassword = normalizeRequirePassword(galleryInfo?.requires_password, true);
// Fetch branding settings // Fetch branding settings
const { data: settingsData } = useQuery({ const { data: settingsData } = useQuery({
@@ -87,6 +90,30 @@ export const GalleryPage: React.FC = () => {
} }
}, [galleryInfo, settingsData, isAuthenticated, setTheme]); }, [galleryInfo, settingsData, isAuthenticated, setTheme]);
React.useEffect(() => {
if (!slug) {
return;
}
if (galleryInfo && isGalleryPublic(galleryInfo.requires_password) && !isAuthenticated && !autoLoginAttempted) {
setAutoLoginAttempted(true);
setIsLoggingIn(true);
login(slug, '')
.then(() => {
setLoginError(null);
})
.catch((error: any) => {
const message = error?.response?.data?.error;
if (message) {
setLoginError(message);
}
})
.finally(() => {
setIsLoggingIn(false);
});
}
}, [galleryInfo, isAuthenticated, autoLoginAttempted, login, slug]);
// Calculate days until expiration // Calculate days until expiration
const daysUntilExpiration = galleryInfo const daysUntilExpiration = galleryInfo
? differenceInDays(parseISO(galleryInfo.expires_at), new Date()) ? differenceInDays(parseISO(galleryInfo.expires_at), new Date())
@@ -96,7 +123,7 @@ export const GalleryPage: React.FC = () => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); // Prevent any bubbling e.stopPropagation(); // Prevent any bubbling
if (!password.trim()) { if (requiresPassword && !password.trim()) {
setLoginError(t('auth.pleaseEnterPassword')); setLoginError(t('auth.pleaseEnterPassword'));
return; return;
} }
@@ -104,13 +131,14 @@ export const GalleryPage: React.FC = () => {
try { try {
setIsLoggingIn(true); setIsLoggingIn(true);
setLoginError(null); setLoginError(null);
await login(slug!, password, recaptchaToken); await login(slug!, requiresPassword ? password : '', recaptchaToken);
// Track successful password entry if (requiresPassword) {
analyticsService.trackGalleryEvent('password_entry', { analyticsService.trackGalleryEvent('password_entry', {
gallery: slug, gallery: slug,
success: true success: true
}); });
}
} catch (error: any) { } catch (error: any) {
console.error('Login error:', error); console.error('Login error:', error);
const errorMessage = error.response?.data?.error || 'Invalid password'; const errorMessage = error.response?.data?.error || 'Invalid password';
@@ -128,11 +156,13 @@ export const GalleryPage: React.FC = () => {
} }
// Track failed password entry // Track failed password entry
analyticsService.trackGalleryEvent('password_entry', { if (requiresPassword) {
gallery: slug, analyticsService.trackGalleryEvent('password_entry', {
success: false, gallery: slug,
statusCode success: false,
}); statusCode
});
}
// Keep the password field to allow retry // Keep the password field to allow retry
// Do not clear the password // Do not clear the password
@@ -311,43 +341,61 @@ export const GalleryPage: React.FC = () => {
</div> </div>
)} )}
{/* Login Card */}
<Card> <Card>
<CardContent className="p-4 sm:p-6"> <CardContent className="p-4 sm:p-6">
<h2 className="text-base sm:text-lg lg:text-xl font-semibold mb-4">{t('auth.enterPassword')}</h2> {requiresPassword ? (
<>
<form onSubmit={handleLogin} className="space-y-4"> <h2 className="text-base sm:text-lg lg:text-xl font-semibold mb-4">{t('auth.enterPassword')}</h2>
<Input
type="password" <form onSubmit={handleLogin} className="space-y-4">
label={t('auth.password')} <Input
placeholder={t('auth.passwordPlaceholder')} type="password"
value={password} label={t('auth.password')}
onChange={(e) => setPassword(e.target.value)} placeholder={t('auth.passwordPlaceholder')}
error={loginError || undefined} value={password}
autoFocus onChange={(e) => setPassword(e.target.value)}
className="text-sm sm:text-base" error={loginError || undefined}
/> autoFocus
className="text-sm sm:text-base"
<ReCaptcha />
onChange={setRecaptchaToken}
onExpired={() => setRecaptchaToken(null)} <ReCaptcha
/> onChange={setRecaptchaToken}
onExpired={() => setRecaptchaToken(null)}
<Button />
type="submit"
variant="primary" <Button
size="lg" type="submit"
className="w-full text-sm sm:text-base" variant="primary"
isLoading={isLoggingIn} size="lg"
disabled={isLoggingIn} className="w-full text-sm sm:text-base"
> isLoading={isLoggingIn}
{t('gallery.viewGallery')} disabled={isLoggingIn}
</Button> >
</form> {t('gallery.viewGallery')}
</Button>
</form>
<p className="text-xs text-neutral-500 text-center mt-4 sm:mt-6"> <p className="text-xs text-neutral-500 text-center mt-4 sm:mt-6">
{t('auth.passwordHint')} {t('auth.passwordHint')}
</p> </p>
</>
) : (
<div className="text-center space-y-3">
<h2 className="text-base sm:text-lg lg:text-xl font-semibold">
{t('gallery.publicGalleryTitle', 'This gallery is publicly accessible')}
</h2>
<p className="text-sm text-neutral-600">
{t('gallery.publicGallerySubtitle', 'Loading the photos now...')}
</p>
<div className="flex justify-center py-4">
<Loading size="sm" text={t('gallery.loading')} />
</div>
{loginError && (
<p className="text-xs text-red-600">{loginError}</p>
)}
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
@@ -376,4 +424,4 @@ export const GalleryPage: React.FC = () => {
</div> </div>
</div> </div>
); );
}; };
+125 -86
View File
@@ -27,6 +27,7 @@ interface FormData {
event_date: string; event_date: string;
host_email: string; host_email: string;
admin_email: string; admin_email: string;
require_password: boolean;
password: string; password: string;
confirm_password: string; confirm_password: string;
welcome_message: string; welcome_message: string;
@@ -123,6 +124,7 @@ export const CreateEventPage: React.FC = () => {
event_date: format(new Date(), 'yyyy-MM-dd'), event_date: format(new Date(), 'yyyy-MM-dd'),
host_email: '', host_email: '',
admin_email: '', admin_email: '',
require_password: true,
password: '', password: '',
confirm_password: '', confirm_password: '',
welcome_message: '', welcome_message: '',
@@ -208,17 +210,18 @@ export const CreateEventPage: React.FC = () => {
newErrors.admin_email = t('validation.invalidEmailFormat'); newErrors.admin_email = t('validation.invalidEmailFormat');
} }
if (!formData.password) { if (formData.require_password) {
newErrors.password = t('validation.passwordRequired'); if (!formData.password) {
} else if (formData.password.length < 6) { newErrors.password = t('validation.passwordRequired');
newErrors.password = t('validation.passwordMinLength'); } else if (formData.password.length < 6) {
} else if (/^\d{1,6}$/.test(formData.password)) { newErrors.password = t('validation.passwordMinLength');
// Prevent simple numeric passwords like "123456" } else if (/^\d{1,6}$/.test(formData.password)) {
newErrors.password = t('validation.passwordTooSimple', 'Password cannot be just numbers. Consider using a date format like "04.07.2025"'); newErrors.password = t('validation.passwordTooSimple', 'Password cannot be just numbers. Consider using a date format like "04.07.2025"');
} }
if (formData.password !== formData.confirm_password) { if (formData.password !== formData.confirm_password) {
newErrors.confirm_password = t('validation.passwordsDoNotMatch'); newErrors.confirm_password = t('validation.passwordsDoNotMatch');
}
} }
if (formData.expires_in_days < 1 || formData.expires_in_days > 365) { if (formData.expires_in_days < 1 || formData.expires_in_days > 365) {
@@ -238,19 +241,22 @@ export const CreateEventPage: React.FC = () => {
const selectedTheme = COLOR_THEMES.find(t => t.value === formData.color_theme); const selectedTheme = COLOR_THEMES.find(t => t.value === formData.color_theme);
createMutation.mutate({ const payload = {
event_type: formData.event_type, event_type: formData.event_type,
event_name: formData.event_name, event_name: formData.event_name,
event_date: formData.event_date, event_date: formData.event_date,
host_email: formData.host_email, host_email: formData.host_email,
admin_email: formData.admin_email, admin_email: formData.admin_email,
password: formData.password, require_password: formData.require_password,
password: formData.require_password ? formData.password : undefined,
welcome_message: formData.welcome_message || '', welcome_message: formData.welcome_message || '',
color_theme: selectedTheme ? JSON.stringify(selectedTheme.theme) : undefined, color_theme: selectedTheme ? JSON.stringify(selectedTheme.theme) : undefined,
expiration_days: formData.expires_in_days, expiration_days: formData.expires_in_days,
allow_user_uploads: formData.allow_user_uploads, allow_user_uploads: formData.allow_user_uploads,
upload_category_id: formData.upload_category_id, upload_category_id: formData.upload_category_id,
}); };
createMutation.mutate(payload);
}; };
const handleInputChange = (field: keyof FormData) => ( const handleInputChange = (field: keyof FormData) => (
@@ -426,81 +432,114 @@ export const CreateEventPage: React.FC = () => {
<Card padding="md" className="mb-6"> <Card padding="md" className="mb-6">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('events.securityAndAccess')}</h2> <h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('events.securityAndAccess')}</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="space-y-4">
{/* Password */} <label className="flex items-start gap-2">
<div> <input
<label htmlFor="password" className="block text-sm font-medium text-neutral-700 mb-1"> type="checkbox"
{t('events.galleryPassword')} className="mt-1 w-4 h-4 text-primary-600 border-neutral-300 rounded focus:ring-primary-500"
</label> checked={formData.require_password}
<div className="relative"> onChange={(e) => {
<Input const checked = e.target.checked;
id="password" setFormData(prev => ({
type={showPassword ? 'text' : 'password'} ...prev,
value={formData.password} require_password: checked,
onChange={handleInputChange('password')} password: checked ? prev.password : '',
error={errors.password} confirm_password: checked ? prev.confirm_password : ''
placeholder={t('events.enterPassword')} }));
helperText={t('events.passwordHelperText', 'You can use dates like "04.07.2025" or any text with 6+ characters')} if (!checked) {
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />} setErrors(prev => ({ ...prev, password: '', confirm_password: '' }));
className="pr-10" }
/> }}
<button />
type="button" <div>
onClick={() => setShowPassword(!showPassword)} <span className="text-sm font-medium text-neutral-700">{t('events.requirePasswordToggle')}</span>
className="absolute inset-y-0 right-0 pr-3 flex items-center" <p className="text-xs text-neutral-500 mt-1">
style={{ top: errors.password ? '0' : '0' }} {t('events.requirePasswordToggleHelp', 'Disable this if you want to share the gallery without a password. Anyone with the link will be able to view the photos.')}
> </p>
{showPassword ? (
<EyeOff className="w-5 h-5 text-neutral-400 hover:text-neutral-600" />
) : (
<Eye className="w-5 h-5 text-neutral-400 hover:text-neutral-600" />
)}
</button>
</div> </div>
</label>
{/* Password Generator */}
<div className="mt-2">
<PasswordGenerator
eventName={formData.event_name}
eventDate={formData.event_date}
eventType={formData.event_type}
onPasswordGenerated={handlePasswordGenerated}
passwordComplexity={passwordComplexity?.complexityLevel || 'moderate'}
className="w-full"
/>
</div>
</div>
{/* Confirm Password */} {!formData.require_password && (
<div> <div className="rounded-md border border-orange-200 bg-orange-50 p-3 text-xs text-orange-800">
<label htmlFor="confirm_password" className="block text-sm font-medium text-neutral-700 mb-1"> {t('events.publicGalleryWarning', 'Public galleries are accessible to anyone with the link. Consider enabling download watermarks and monitoring activity.')}
{t('events.confirmPassword')}
</label>
<div className="relative">
<Input
id="confirm_password"
type={showPassword ? 'text' : 'password'}
value={formData.confirm_password}
onChange={handleInputChange('confirm_password')}
error={errors.confirm_password}
placeholder={t('events.confirmPasswordPlaceholder')}
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
className="pr-10"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute inset-y-0 right-0 pr-3 flex items-center"
style={{ top: errors.confirm_password ? '0' : '0' }}
>
{showPassword ? (
<EyeOff className="w-5 h-5 text-neutral-400 hover:text-neutral-600" />
) : (
<Eye className="w-5 h-5 text-neutral-400 hover:text-neutral-600" />
)}
</button>
</div> </div>
</div> )}
{formData.require_password && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label htmlFor="password" className="block text-sm font-medium text-neutral-700 mb-1">
{t('events.galleryPassword')}
</label>
<div className="relative">
<Input
id="password"
type={showPassword ? 'text' : 'password'}
value={formData.password}
onChange={handleInputChange('password')}
error={errors.password}
placeholder={t('events.enterPassword')}
helperText={t('events.passwordHelperText', 'You can use dates like "04.07.2025" or any text with 6+ characters')}
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
className="pr-10"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute inset-y-0 right-0 pr-3 flex items-center"
style={{ top: errors.password ? '0' : '0' }}
>
{showPassword ? (
<EyeOff className="w-5 h-5 text-neutral-400 hover:text-neutral-600" />
) : (
<Eye className="w-5 h-5 text-neutral-400 hover:text-neutral-600" />
)}
</button>
</div>
<div className="mt-2">
<PasswordGenerator
eventName={formData.event_name}
eventDate={formData.event_date}
eventType={formData.event_type}
onPasswordGenerated={handlePasswordGenerated}
passwordComplexity={passwordComplexity?.complexityLevel || 'moderate'}
className="w-full"
/>
</div>
</div>
<div>
<label htmlFor="confirm_password" className="block text-sm font-medium text-neutral-700 mb-1">
{t('events.confirmPassword')}
</label>
<div className="relative">
<Input
id="confirm_password"
type={showPassword ? 'text' : 'password'}
value={formData.confirm_password}
onChange={handleInputChange('confirm_password')}
error={errors.confirm_password}
placeholder={t('events.confirmPasswordPlaceholder')}
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
className="pr-10"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute inset-y-0 right-0 pr-3 flex items-center"
style={{ top: errors.confirm_password ? '0' : '0' }}
>
{showPassword ? (
<EyeOff className="w-5 h-5 text-neutral-400 hover:text-neutral-600" />
) : (
<Eye className="w-5 h-5 text-neutral-400 hover:text-neutral-600" />
)}
</button>
</div>
</div>
</div>
)}
</div> </div>
</Card> </Card>
@@ -645,4 +684,4 @@ export const CreateEventPage: React.FC = () => {
); );
}; };
CreateEventPage.displayName = 'CreateEventPage'; CreateEventPage.displayName = 'CreateEventPage';
@@ -30,6 +30,7 @@ interface FormData {
host_name: string; host_name: string;
host_email: string; host_email: string;
admin_email: string; admin_email: string;
require_password: boolean;
password: string; password: string;
confirm_password: string; confirm_password: string;
welcome_message: string; welcome_message: string;
@@ -88,6 +89,7 @@ export const CreateEventPageEnhanced: React.FC = () => {
host_name: '', host_name: '',
host_email: '', host_email: '',
admin_email: '', admin_email: '',
require_password: true,
password: '', password: '',
confirm_password: '', confirm_password: '',
welcome_message: '', welcome_message: '',
@@ -198,17 +200,19 @@ export const CreateEventPageEnhanced: React.FC = () => {
newErrors.admin_email = t('validation.invalidEmailFormat'); newErrors.admin_email = t('validation.invalidEmailFormat');
} }
if (!formData.password) { if (formData.require_password) {
newErrors.password = t('validation.passwordRequired'); if (!formData.password) {
} else if (formData.password.length < 6) { newErrors.password = t('validation.passwordRequired');
newErrors.password = t('validation.passwordMinLength'); } else if (formData.password.length < 6) {
} else if (/^\d{1,6}$/.test(formData.password)) { newErrors.password = t('validation.passwordMinLength');
// Prevent simple numeric passwords like "123456" } else if (/^\d{1,6}$/.test(formData.password)) {
newErrors.password = t('validation.passwordTooSimple', 'Password cannot be just numbers. Consider using a date format like "04.07.2025"'); // Prevent simple numeric passwords like "123456"
} newErrors.password = t('validation.passwordTooSimple', 'Password cannot be just numbers. Consider using a date format like "04.07.2025"');
}
if (formData.password !== formData.confirm_password) { if (formData.password !== formData.confirm_password) {
newErrors.confirm_password = t('validation.passwordsDoNotMatch'); newErrors.confirm_password = t('validation.passwordsDoNotMatch');
}
} }
if (formData.expires_in_days < 1 || formData.expires_in_days > 365) { if (formData.expires_in_days < 1 || formData.expires_in_days > 365) {
@@ -235,7 +239,8 @@ export const CreateEventPageEnhanced: React.FC = () => {
host_name: formData.host_name, host_name: formData.host_name,
host_email: formData.host_email, host_email: formData.host_email,
admin_email: formData.admin_email, admin_email: formData.admin_email,
password: formData.password, require_password: formData.require_password,
password: formData.require_password ? formData.password : undefined,
welcome_message: formData.welcome_message || '', welcome_message: formData.welcome_message || '',
color_theme: JSON.stringify(formData.theme_config), color_theme: JSON.stringify(formData.theme_config),
expiration_days: formData.expires_in_days, expiration_days: formData.expires_in_days,
@@ -495,51 +500,89 @@ export const CreateEventPageEnhanced: React.FC = () => {
/> />
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="space-y-3">
<div> <label className="flex items-start gap-2">
<input
type="checkbox"
className="mt-1 w-4 h-4 text-primary-600 border-neutral-300 rounded focus:ring-primary-500"
checked={formData.require_password}
onChange={(e) => {
const checked = e.target.checked;
setFormData(prev => ({
...prev,
require_password: checked,
password: checked ? prev.password : '',
confirm_password: checked ? prev.confirm_password : '',
}));
if (!checked) {
setErrors(prev => ({ ...prev, password: undefined, confirm_password: undefined }));
}
}}
/>
<div>
<span className="text-sm font-medium text-neutral-700">
{t('events.requirePasswordToggle')}
</span>
<p className="text-xs text-neutral-500 mt-1">
{t('events.requirePasswordToggleHelp', 'Disable this if you want to share the gallery without a password. Anyone with the link will be able to view the photos.')}
</p>
</div>
</label>
{!formData.require_password && (
<div className="rounded-md border border-orange-200 bg-orange-50 p-3 text-xs text-orange-800">
{t('events.publicGalleryWarning', 'Public galleries are accessible to anyone with the link. Consider enabling download watermarks and monitoring activity.')}
</div>
)}
</div>
{formData.require_password && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Input
type={showPassword ? 'text' : 'password'}
label={t('events.galleryPassword')}
placeholder={t('events.passwordPlaceholder')}
value={formData.password}
onChange={handleInputChange('password')}
error={errors.password}
helperText={t('events.passwordHelperText', 'You can use dates like "04.07.2025" or any text with 6+ characters')}
leftIcon={<Lock className="w-5 h-5" />}
rightIcon={
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="p-1"
>
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
}
/>
{/* Password Generator */}
<div className="mt-2">
<PasswordGenerator
eventName={formData.event_name}
eventDate={formData.event_date}
eventType={formData.event_type}
onPasswordGenerated={handlePasswordGenerated}
passwordComplexity="moderate"
className="w-full"
/>
</div>
</div>
<Input <Input
type={showPassword ? 'text' : 'password'} type={showPassword ? 'text' : 'password'}
label={t('events.galleryPassword')} label={t('events.confirmPassword')}
placeholder={t('events.passwordPlaceholder')} placeholder={t('events.confirmPasswordPlaceholder')}
value={formData.password} value={formData.confirm_password}
onChange={handleInputChange('password')} onChange={handleInputChange('confirm_password')}
error={errors.password} error={errors.confirm_password}
helperText={t('events.passwordHelperText', 'You can use dates like "04.07.2025" or any text with 6+ characters')}
leftIcon={<Lock className="w-5 h-5" />} leftIcon={<Lock className="w-5 h-5" />}
rightIcon={
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="p-1"
>
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
}
/> />
{/* Password Generator */}
<div className="mt-2">
<PasswordGenerator
eventName={formData.event_name}
eventDate={formData.event_date}
eventType={formData.event_type}
onPasswordGenerated={handlePasswordGenerated}
passwordComplexity="moderate"
className="w-full"
/>
</div>
</div> </div>
)}
<Input
type={showPassword ? 'text' : 'password'}
label={t('events.confirmPassword')}
placeholder={t('events.confirmPasswordPlaceholder')}
value={formData.confirm_password}
onChange={handleInputChange('confirm_password')}
error={errors.confirm_password}
leftIcon={<Lock className="w-5 h-5" />}
/>
</div>
<div> <div>
<label className="block text-sm font-medium text-neutral-700 mb-2"> <label className="block text-sm font-medium text-neutral-700 mb-2">
+132 -2
View File
@@ -17,7 +17,10 @@ import {
Image, Image,
Key, Key,
Mail, Mail,
MessageSquare MessageSquare,
Lock,
Eye,
EyeOff
} from 'lucide-react'; } from 'lucide-react';
import { parseISO, differenceInDays } from 'date-fns'; import { parseISO, differenceInDays } from 'date-fns';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
@@ -27,6 +30,7 @@ import { Button, Input, Card, Loading } from '../../components/common';
import { EventCategoryManager, AdminPhotoGrid, AdminPhotoViewer, PhotoFilters, PasswordResetModal, ThemeCustomizerEnhanced, ThemeDisplay, HeroPhotoSelector, PhotoUploadModal, FeedbackSettings, FeedbackModerationPanel } from '../../components/admin'; import { EventCategoryManager, AdminPhotoGrid, AdminPhotoViewer, PhotoFilters, PasswordResetModal, ThemeCustomizerEnhanced, ThemeDisplay, HeroPhotoSelector, PhotoUploadModal, FeedbackSettings, FeedbackModerationPanel } from '../../components/admin';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { eventsService } from '../../services/events.service'; import { eventsService } from '../../services/events.service';
import { isGalleryPublic, normalizeRequirePassword } from '../../utils/accessControl';
import { archiveService } from '../../services/archive.service'; import { archiveService } from '../../services/archive.service';
import { externalMediaService } from '../../services/externalMedia.service'; import { externalMediaService } from '../../services/externalMedia.service';
import { photosService, AdminPhoto, type PhotoFilters as PhotoFilterParams } from '../../services/photos.service'; import { photosService, AdminPhoto, type PhotoFilters as PhotoFilterParams } from '../../services/photos.service';
@@ -121,6 +125,9 @@ export const EventDetailsPage: React.FC = () => {
host_name: string; host_name: string;
source_mode: 'managed' | 'reference'; source_mode: 'managed' | 'reference';
external_path: string; external_path: string;
require_password: boolean;
new_password: string;
confirm_new_password: string;
}; };
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
@@ -134,6 +141,9 @@ export const EventDetailsPage: React.FC = () => {
host_name: '', host_name: '',
source_mode: 'managed', source_mode: 'managed',
external_path: '', external_path: '',
require_password: true,
new_password: '',
confirm_new_password: '',
}); });
const [feedbackSettings, setFeedbackSettings] = useState<FeedbackSettingsType>({ const [feedbackSettings, setFeedbackSettings] = useState<FeedbackSettingsType>({
feedback_enabled: false, feedback_enabled: false,
@@ -156,6 +166,7 @@ export const EventDetailsPage: React.FC = () => {
const [importing, setImporting] = useState<boolean>(false); const [importing, setImporting] = useState<boolean>(false);
const [selectedPhoto, setSelectedPhoto] = useState<{ photo: AdminPhoto; index: number } | null>(null); const [selectedPhoto, setSelectedPhoto] = useState<{ photo: AdminPhoto; index: number } | null>(null);
const [showPasswordReset, setShowPasswordReset] = useState(false); const [showPasswordReset, setShowPasswordReset] = useState(false);
const [showNewPassword, setShowNewPassword] = useState(false);
const [currentTheme, setCurrentTheme] = useState<ThemeConfig | null>(null); const [currentTheme, setCurrentTheme] = useState<ThemeConfig | null>(null);
const [currentPresetName, setCurrentPresetName] = useState<string>('default'); const [currentPresetName, setCurrentPresetName] = useState<string>('default');
@@ -274,7 +285,12 @@ export const EventDetailsPage: React.FC = () => {
host_name: event.host_name || '', host_name: event.host_name || '',
source_mode: event.source_mode === 'reference' ? 'reference' : 'managed', source_mode: event.source_mode === 'reference' ? 'reference' : 'managed',
external_path: event.external_path || '', external_path: event.external_path || '',
require_password: normalizeRequirePassword(event.require_password),
new_password: '',
confirm_new_password: '',
}); });
setShowNewPassword(false);
// Set feedback settings if available // Set feedback settings if available
if (eventFeedbackSettings) { if (eventFeedbackSettings) {
@@ -324,6 +340,26 @@ export const EventDetailsPage: React.FC = () => {
const externalPathToSave = editForm.external_path?.trim() || ''; const externalPathToSave = editForm.external_path?.trim() || '';
const currentRequirePassword = normalizeRequirePassword(event.require_password);
const requirePasswordChanged = editForm.require_password !== currentRequirePassword;
if (editForm.require_password) {
if (requirePasswordChanged && !editForm.new_password) {
toast.error(t('events.newPasswordRequired', 'Please set a password before enabling protection.'));
return;
}
if (editForm.new_password) {
if (editForm.new_password.length < 6) {
toast.error(t('validation.passwordMinLength'));
return;
}
if (editForm.new_password !== editForm.confirm_new_password) {
toast.error(t('validation.passwordsDoNotMatch'));
return;
}
}
}
if (editForm.source_mode === 'reference' && !externalPathToSave) { if (editForm.source_mode === 'reference' && !externalPathToSave) {
toast.error(t('events.externalFolderRequired', 'Please select an external folder before saving.')); toast.error(t('events.externalFolderRequired', 'Please select an external folder before saving.'));
return; return;
@@ -333,6 +369,7 @@ export const EventDetailsPage: React.FC = () => {
const updateData: any = { const updateData: any = {
expires_at: editForm.expires_at, expires_at: editForm.expires_at,
allow_user_uploads: editForm.allow_user_uploads, allow_user_uploads: editForm.allow_user_uploads,
require_password: editForm.require_password,
}; };
// Only include fields that have defined values // Only include fields that have defined values
@@ -355,6 +392,10 @@ export const EventDetailsPage: React.FC = () => {
if (editForm.host_name !== undefined && editForm.host_name !== null) { if (editForm.host_name !== undefined && editForm.host_name !== null) {
updateData.host_name = editForm.host_name; updateData.host_name = editForm.host_name;
} }
if (editForm.new_password) {
updateData.password = editForm.new_password;
}
// Remove any keys with undefined values // Remove any keys with undefined values
Object.keys(updateData).forEach(key => { Object.keys(updateData).forEach(key => {
@@ -437,6 +478,15 @@ export const EventDetailsPage: React.FC = () => {
{format(parseISO(event.event_date), 'PPP')} {format(parseISO(event.event_date), 'PPP')}
</span> </span>
<span className="capitalize">{event.event_type}</span> <span className="capitalize">{event.event_type}</span>
<span
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${
isGalleryPublic(event.require_password)
? 'bg-green-100 text-green-700'
: 'bg-neutral-100 text-neutral-700'
}`}
>
{isGalleryPublic(event.require_password) ? t('events.publicAccess', 'Public access') : t('events.passwordProtected', 'Password protected')}
</span>
{event.is_archived ? ( {event.is_archived ? (
<span className="text-neutral-500 flex items-center"> <span className="text-neutral-500 flex items-center">
<Archive className="w-4 h-4 mr-1" /> <Archive className="w-4 h-4 mr-1" />
@@ -641,6 +691,84 @@ export const EventDetailsPage: React.FC = () => {
isEditing={isEditing} isEditing={isEditing}
/> />
<div>
<label className="flex items-start gap-2">
<input
type="checkbox"
className="mt-1 w-4 h-4 text-primary-600 border-neutral-300 rounded focus:ring-primary-500"
checked={editForm.require_password}
onChange={(e) => {
const checked = e.target.checked;
setEditForm(prev => ({
...prev,
require_password: checked,
new_password: checked ? prev.new_password : '',
confirm_new_password: checked ? prev.confirm_new_password : '',
}));
if (!checked) {
setShowNewPassword(false);
}
}}
/>
<div>
<span className="text-sm font-medium text-neutral-700">{t('events.requirePasswordToggle')}</span>
<p className="text-xs text-neutral-500 mt-1">
{t('events.requirePasswordToggleHelp', 'Disable this if you want to share the gallery without a password. Anyone with the link will be able to view the photos.')}
</p>
</div>
</label>
{!editForm.require_password && (
<div className="mt-2 rounded-md border border-orange-200 bg-orange-50 p-3 text-xs text-orange-800">
{t('events.publicGalleryWarning', 'Public galleries are accessible to anyone with the link. Consider enabling download watermarks and monitoring activity.')}
</div>
)}
</div>
{editForm.require_password && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('events.newPasswordLabel', 'New gallery password')}
</label>
<div className="relative">
<Input
type={showNewPassword ? 'text' : 'password'}
value={editForm.new_password}
onChange={(e) => setEditForm(prev => ({ ...prev, new_password: e.target.value }))}
placeholder={t('events.enterPassword')}
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
className="pr-10"
/>
<button
type="button"
onClick={() => setShowNewPassword(!showNewPassword)}
className="absolute inset-y-0 right-0 pr-3 flex items-center"
>
{showNewPassword ? (
<EyeOff className="w-5 h-5 text-neutral-400 hover:text-neutral-600" />
) : (
<Eye className="w-5 h-5 text-neutral-400 hover:text-neutral-600" />
)}
</button>
</div>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('events.confirmPassword')}
</label>
<Input
type={showNewPassword ? 'text' : 'password'}
value={editForm.confirm_new_password}
onChange={(e) => setEditForm(prev => ({ ...prev, confirm_new_password: e.target.value }))}
placeholder={t('events.confirmPasswordPlaceholder')}
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
/>
</div>
</div>
)}
<div> <div>
<label className="block text-sm font-medium text-neutral-700 mb-1"> <label className="block text-sm font-medium text-neutral-700 mb-1">
{t('events.sourceMode', 'Source Mode')} {t('events.sourceMode', 'Source Mode')}
@@ -848,7 +976,9 @@ export const EventDetailsPage: React.FC = () => {
</div> </div>
<p className="text-sm text-neutral-600 mt-2"> <p className="text-sm text-neutral-600 mt-2">
{t('events.shareWithGuests')} {isGalleryPublic(event.require_password)
? t('events.shareWithGuestsPublic', 'Anyone with this link can view the gallery. No password is required.')
: t('events.shareWithGuests')}
</p> </p>
{!event.is_archived && ( {!event.is_archived && (
@@ -22,6 +22,7 @@ import { Button, Input, Card, SkeletonTable, ErrorBoundary } from '../../compone
import { BulkArchiveModal } from '../../components/admin'; import { BulkArchiveModal } from '../../components/admin';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { eventsService } from '../../services/events.service'; import { eventsService } from '../../services/events.service';
import { isGalleryPublic } from '../../utils/accessControl';
import type { Event } from '../../types'; import type { Event } from '../../types';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -428,6 +429,17 @@ export const EventsListPage: React.FC = () => {
<div> <div>
<p className="text-sm font-medium text-neutral-900">{event.event_name}</p> <p className="text-sm font-medium text-neutral-900">{event.event_name}</p>
<p className="text-xs text-neutral-500">{event.host_email}</p> <p className="text-xs text-neutral-500">{event.host_email}</p>
<div className="mt-1">
<span
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium ${
isGalleryPublic(event.require_password)
? 'bg-green-100 text-green-700'
: 'bg-neutral-100 text-neutral-700'
}`}
>
{isGalleryPublic(event.require_password) ? t('events.publicAccess', 'Public access') : t('events.passwordProtected', 'Password protected')}
</span>
</div>
</div> </div>
</td> </td>
<td className="px-6 py-4 text-sm text-neutral-700"> <td className="px-6 py-4 text-sm text-neutral-700">
-12
View File
@@ -94,7 +94,6 @@ export const SettingsPage: React.FC = () => {
// Security settings state // Security settings state
const [securitySettings, setSecuritySettings] = useState({ const [securitySettings, setSecuritySettings] = useState({
require_password: true,
password_min_length: 8, password_min_length: 8,
password_complexity: 'moderate', password_complexity: 'moderate',
enable_2fa: false, enable_2fa: false,
@@ -146,7 +145,6 @@ export const SettingsPage: React.FC = () => {
// Extract security settings // Extract security settings
setSecuritySettings({ setSecuritySettings({
require_password: toBoolean(settings.security_require_password, true),
password_min_length: toNumber(settings.security_password_min_length, 8), password_min_length: toNumber(settings.security_password_min_length, 8),
password_complexity: settings.security_password_complexity ?? 'moderate', password_complexity: settings.security_password_complexity ?? 'moderate',
enable_2fa: toBoolean(settings.security_enable_2fa, false), enable_2fa: toBoolean(settings.security_enable_2fa, false),
@@ -1105,16 +1103,6 @@ export const SettingsPage: React.FC = () => {
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.security.passwordSettings')}</h2> <h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.security.passwordSettings')}</h2>
<div className="space-y-4"> <div className="space-y-4">
<label className="flex items-center">
<input
type="checkbox"
checked={securitySettings.require_password}
onChange={(e) => setSecuritySettings(prev => ({ ...prev, require_password: e.target.checked }))}
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
/>
<span className="ml-2 text-sm text-neutral-700">{t('settings.security.requirePassword')}</span>
</label>
<div> <div>
<label className="block text-sm font-medium text-neutral-700 mb-1"> <label className="block text-sm font-medium text-neutral-700 mb-1">
{t('settings.security.minPasswordLength')} {t('settings.security.minPasswordLength')}
+14 -3
View File
@@ -1,5 +1,16 @@
import { api } from '../config/api'; import { api } from '../config/api';
import type { LoginResponse, GalleryAuthResponse } from '../types'; import type { LoginResponse, GalleryAuthResponse } from '../types';
import { normalizeRequirePassword } from '../utils/accessControl';
const normalizeGalleryResponse = (response: GalleryAuthResponse): GalleryAuthResponse => ({
...response,
event: response.event
? {
...response.event,
require_password: normalizeRequirePassword((response.event as any)?.require_password, true),
}
: response.event,
});
export const authService = { export const authService = {
// Admin authentication // Admin authentication
@@ -24,7 +35,7 @@ export const authService = {
}, },
// Gallery authentication // Gallery authentication
async verifyGalleryPassword(slug: string, password: string, recaptchaToken?: string | null): Promise<GalleryAuthResponse> { async verifyGalleryPassword(slug: string, password?: string, recaptchaToken?: string | null): Promise<GalleryAuthResponse> {
const response = await api.post<GalleryAuthResponse>('/auth/gallery/verify', { const response = await api.post<GalleryAuthResponse>('/auth/gallery/verify', {
slug, slug,
password, password,
@@ -32,7 +43,7 @@ export const authService = {
}); });
// Token is now handled by GalleryAuthContext with slug-specific storage // Token is now handled by GalleryAuthContext with slug-specific storage
return response.data; return normalizeGalleryResponse(response.data);
}, },
async shareLinkLogin(slug: string, token: string): Promise<GalleryAuthResponse> { async shareLinkLogin(slug: string, token: string): Promise<GalleryAuthResponse> {
@@ -40,7 +51,7 @@ export const authService = {
slug, slug,
token, token,
}); });
return response.data; return normalizeGalleryResponse(response.data);
}, },
async galleryLogout(slug?: string | null) { async galleryLogout(slug?: string | null) {
+18 -4
View File
@@ -1,5 +1,11 @@
import { api } from '../config/api'; import { api } from '../config/api';
import type { Event } from '../types'; import type { Event } from '../types';
import { normalizeRequirePassword } from '../utils/accessControl';
const normalizeEvent = (event: Event): Event => ({
...event,
require_password: normalizeRequirePassword((event as any)?.require_password, true),
});
interface CreateEventData { interface CreateEventData {
event_type: string; event_type: string;
@@ -7,7 +13,8 @@ interface CreateEventData {
event_date: string; event_date: string;
host_email: string; host_email: string;
admin_email: string; admin_email: string;
password: string; require_password?: boolean;
password?: string;
welcome_message?: string; welcome_message?: string;
color_theme?: string; color_theme?: string;
expiration_days: number; expiration_days: number;
@@ -28,6 +35,7 @@ interface UpdateEventData {
event_date?: string; event_date?: string;
host_email?: string; host_email?: string;
admin_email?: string; admin_email?: string;
require_password?: boolean;
password?: string; password?: string;
welcome_message?: string; welcome_message?: string;
color_theme?: string; color_theme?: string;
@@ -64,19 +72,25 @@ export const eventsService = {
} }
const response = await api.get<EventsListResponse>(`/admin/events?${params}`); const response = await api.get<EventsListResponse>(`/admin/events?${params}`);
return response.data; const data: any = response.data;
if (Array.isArray(data?.events)) {
data.events = data.events.map((event: Event) => normalizeEvent(event));
} else if (Array.isArray(data)) {
return data.map((event: Event) => normalizeEvent(event)) as any;
}
return data;
}, },
// Get single event details (admin) // Get single event details (admin)
async getEvent(id: number): Promise<Event> { async getEvent(id: number): Promise<Event> {
const response = await api.get<Event>(`/admin/events/${id}`); const response = await api.get<Event>(`/admin/events/${id}`);
return response.data; return normalizeEvent(response.data as Event);
}, },
// Create new event (admin) // Create new event (admin)
async createEvent(data: CreateEventData): Promise<Event> { async createEvent(data: CreateEventData): Promise<Event> {
const response = await api.post<Event>('/admin/events', data); const response = await api.post<Event>('/admin/events', data);
return response.data; return normalizeEvent(response.data as Event);
}, },
// Update event (admin) // Update event (admin)
+17 -2
View File
@@ -1,5 +1,6 @@
import { api } from '../config/api'; import { api } from '../config/api';
import type { GalleryInfo, GalleryData, GalleryStats } from '../types'; import type { GalleryInfo, GalleryData, GalleryStats } from '../types';
import { normalizeRequirePassword } from '../utils/accessControl';
export const galleryService = { export const galleryService = {
// Verify share token // Verify share token
@@ -12,7 +13,11 @@ export const galleryService = {
async getGalleryInfo(slug: string, token?: string): Promise<GalleryInfo> { async getGalleryInfo(slug: string, token?: string): Promise<GalleryInfo> {
const params = token ? { token } : {}; const params = token ? { token } : {};
const response = await api.get<GalleryInfo>(`/gallery/${slug}/info`, { params }); const response = await api.get<GalleryInfo>(`/gallery/${slug}/info`, { params });
return response.data; const data = response.data;
return {
...data,
requires_password: normalizeRequirePassword((data as any)?.requires_password, true),
};
}, },
// Get gallery photos (requires auth) // Get gallery photos (requires auth)
@@ -29,7 +34,17 @@ export const galleryService = {
} }
} }
const response = await api.get<GalleryData>(`/gallery/${slug}/photos`, { params }); const response = await api.get<GalleryData>(`/gallery/${slug}/photos`, { params });
return response.data; const data = response.data;
const normalizedEvent = data?.event
? {
...data.event,
require_password: normalizeRequirePassword((data.event as any)?.require_password, true),
}
: data.event;
return {
...data,
event: normalizedEvent,
};
}, },
// Download single photo // Download single photo
+3
View File
@@ -17,6 +17,7 @@ export interface Event {
is_archived: boolean; is_archived: boolean;
archive_path?: string; archive_path?: string;
archived_at?: string; archived_at?: string;
require_password?: boolean;
photo_count?: number; photo_count?: number;
total_size?: number; total_size?: number;
recent_photos?: Array<{ recent_photos?: Array<{
@@ -92,6 +93,7 @@ export interface GalleryData {
disable_right_click?: boolean; disable_right_click?: boolean;
watermark_downloads?: boolean; watermark_downloads?: boolean;
watermark_text?: string; watermark_text?: string;
require_password?: boolean;
protection_level?: 'basic' | 'standard' | 'enhanced' | 'maximum'; protection_level?: 'basic' | 'standard' | 'enhanced' | 'maximum';
image_quality?: number; image_quality?: number;
use_canvas_rendering?: boolean; use_canvas_rendering?: boolean;
@@ -134,6 +136,7 @@ export interface GalleryAuthResponse {
expires_at: string; expires_at: string;
allow_user_uploads?: boolean; allow_user_uploads?: boolean;
upload_category_id?: number | null; upload_category_id?: number | null;
require_password?: boolean;
}; };
} }
+29
View File
@@ -0,0 +1,29 @@
export const normalizeRequirePassword = (value: unknown, defaultValue = true): boolean => {
if (value === undefined || value === null) {
return defaultValue;
}
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'number') {
return value !== 0;
}
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase();
if (normalized === 'false' || normalized === '0' || normalized === 'no' || normalized === 'off') {
return false;
}
if (normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on') {
return true;
}
}
return defaultValue;
};
export const isGalleryPublic = (value: unknown, defaultValue = true): boolean => {
return !normalizeRequirePassword(value, defaultValue);
};
+15
View File
@@ -24,4 +24,19 @@ export const cleanupOldGalleryAuth = () => {
sessionStorage.removeItem('gallery_event'); sessionStorage.removeItem('gallery_event');
sessionStorage.removeItem('gallery_token'); sessionStorage.removeItem('gallery_token');
sessionStorage.removeItem('gallery_active_slug'); sessionStorage.removeItem('gallery_active_slug');
// Remove slug-specific session storage entries as well
try {
const sessionKeysToRemove: string[] = [];
for (let i = 0; i < sessionStorage.length; i += 1) {
const key = sessionStorage.key(i);
if (key && (key.startsWith('gallery_event_') || key.startsWith('gallery_token_'))) {
sessionKeysToRemove.push(key);
}
}
sessionKeysToRemove.forEach((key) => sessionStorage.removeItem(key));
} catch {
// Session storage may be unavailable; ignore cleanup failures
}
}; };
+23 -1
View File
@@ -55,6 +55,7 @@ CUSTOM_PORT=""
UNATTENDED=false UNATTENDED=false
UPDATE_MODE=false UPDATE_MODE=false
UNINSTALL_MODE=false UNINSTALL_MODE=false
FORCE_ADMIN_PASSWORD_RESET=false
################################################################################ ################################################################################
# Helper Functions # Helper Functions
@@ -487,6 +488,15 @@ EOF
# Run database migrations # Run database migrations
log_step "Running database migrations..." log_step "Running database migrations..."
docker compose exec -T backend npm run migrate docker compose exec -T backend npm run migrate
if [[ "$FORCE_ADMIN_PASSWORD_RESET" == "true" ]]; then
log_step "Resetting admin credentials..."
if docker compose exec -T backend node scripts/reset-admin-password.js --force --credentials-file data/ADMIN_CREDENTIALS.txt; then
docker compose cp backend:/app/data/ADMIN_CREDENTIALS.txt "$app_dir/data/ADMIN_CREDENTIALS.txt" 2>/dev/null || true
else
log_warn "Automatic admin password reset failed; run reset-admin-password.js inside the backend container."
fi
fi
log_success "Docker installation completed!" log_success "Docker installation completed!"
} }
@@ -737,6 +747,13 @@ EOF
log_step "Initializing database..." log_step "Initializing database..."
cd "$NATIVE_APP_DIR/app/backend" cd "$NATIVE_APP_DIR/app/backend"
run_as_user "npm run migrate" run_as_user "npm run migrate"
if [[ "$FORCE_ADMIN_PASSWORD_RESET" == "true" ]]; then
log_step "Resetting admin credentials..."
if ! run_as_user "node scripts/reset-admin-password.js --force --credentials-file data/ADMIN_CREDENTIALS.txt"; then
log_warn "Automatic admin password reset failed; please run reset-admin-password.js manually."
fi
fi
# Create systemd services # Create systemd services
create_systemd_services create_systemd_services
@@ -989,7 +1006,7 @@ print_success_message() {
fi fi
else else
echo -e "Email: ${CYAN}$ADMIN_EMAIL${NC}" echo -e "Email: ${CYAN}$ADMIN_EMAIL${NC}"
echo -e "Password: ${YELLOW}(credentials file not found)${NC}" echo -e "Password: ${YELLOW}(credentials file not found - rerun setup with --force-admin-password-reset or run node scripts/reset-admin-password.js manually)${NC}"
fi fi
echo echo
echo -e "${YELLOW}⚠️ IMPORTANT: Change the admin password on first login!${NC}" echo -e "${YELLOW}⚠️ IMPORTANT: Change the admin password on first login!${NC}"
@@ -1256,6 +1273,10 @@ parse_arguments() {
SMTP_PASS="$2" SMTP_PASS="$2"
shift 2 shift 2
;; ;;
--force-admin-password-reset)
FORCE_ADMIN_PASSWORD_RESET=true
shift
;;
--enable-ssl) --enable-ssl)
ENABLE_SSL=true ENABLE_SSL=true
shift shift
@@ -1301,6 +1322,7 @@ Options:
--smtp-port PORT SMTP server port --smtp-port PORT SMTP server port
--smtp-user USER SMTP username --smtp-user USER SMTP username
--smtp-pass PASS SMTP password --smtp-pass PASS SMTP password
--force-admin-password-reset Regenerate admin credentials after setup
--enable-ssl Enable HTTPS with Let's Encrypt --enable-ssl Enable HTTPS with Let's Encrypt
--port PORT Custom port (native only) --port PORT Custom port (native only)
--update Update existing installation --update Update existing installation