Compare commits

...

11 Commits

Author SHA1 Message Date
Gitea Actions Bot f20472ca26 chore: bump version to 1.1.11 (backend + frontend) 2025-10-12 19:23:19 +00:00
Gitea Actions Bot 87f4526220 chore: bump version to 1.1.10 (backend + frontend) 2025-10-12 19:18:37 +00:00
Gitea Actions Bot d42a11680f chore: bump version to 1.1.9 (backend + frontend) 2025-10-06 13:18:43 +00:00
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
35 changed files with 1599 additions and 430 deletions
@@ -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.4", "version": "1.1.11",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "picpeak-backend", "name": "picpeak-backend",
"version": "1.1.4", "version": "1.1.11",
"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.4", "version": "1.1.11",
"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;
});
+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' });
} }
} }
+17 -11
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,21 +79,17 @@ 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' });
} }
@@ -103,6 +98,17 @@ async function photoAuth(req, res, next) {
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' });
} }
} }
+123 -23
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 (!passwordValidation.valid) { if (requirePassword) {
return res.status(400).json({ passwordValidation = await validatePasswordInContext(password, 'gallery', {
error: 'Password does not meet security requirements', eventName: event_name
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;
+52 -35
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) {
+56 -39
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')
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
.first();
// 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();
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) {
+102 -15
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,11 +65,29 @@ 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}`;
let slug = baseSlug; let slug = baseSlug;
@@ -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,8 +145,8 @@ 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 || ''
}); });
@@ -97,8 +154,9 @@ router.post('/', adminAuth, [
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 const hasRequirePasswordUpdate = Object.prototype.hasOwnProperty.call(updates, 'require_password');
if (updates.password) { let requirePasswordUpdate;
updates.password_hash = await bcrypt.hash(updates.password, 10); if (hasRequirePasswordUpdate) {
delete updates.password; 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' });
} }
}); });
+29 -10
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');
+6
View File
@@ -133,6 +133,12 @@ async function processTemplate(template, variables, language = 'en') {
: '(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) {
processedVariables.event_date = await formatDate(processedVariables.event_date, language); processedVariables.event_date = await formatDate(processedVariables.event_date, language);
+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,
}; };
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "picpeak-frontend", "name": "picpeak-frontend",
"version": "1.1.4", "version": "1.1.11",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "picpeak-frontend", "name": "picpeak-frontend",
"version": "1.1.4", "version": "1.1.11",
"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.4", "version": "1.1.11",
"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",
+91 -43
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 ? (
<>
<h2 className="text-base sm:text-lg lg:text-xl font-semibold mb-4">{t('auth.enterPassword')}</h2>
<form onSubmit={handleLogin} className="space-y-4"> <form onSubmit={handleLogin} className="space-y-4">
<Input <Input
type="password" type="password"
label={t('auth.password')} label={t('auth.password')}
placeholder={t('auth.passwordPlaceholder')} placeholder={t('auth.passwordPlaceholder')}
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
error={loginError || undefined} error={loginError || undefined}
autoFocus autoFocus
className="text-sm sm:text-base" className="text-sm sm:text-base"
/> />
<ReCaptcha <ReCaptcha
onChange={setRecaptchaToken} onChange={setRecaptchaToken}
onExpired={() => setRecaptchaToken(null)} onExpired={() => setRecaptchaToken(null)}
/> />
<Button <Button
type="submit" type="submit"
variant="primary" variant="primary"
size="lg" size="lg"
className="w-full text-sm sm:text-base" className="w-full text-sm sm:text-base"
isLoading={isLoggingIn} isLoading={isLoggingIn}
disabled={isLoggingIn} disabled={isLoggingIn}
> >
{t('gallery.viewGallery')} {t('gallery.viewGallery')}
</Button> </Button>
</form> </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>
+122 -83
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 */} {!formData.require_password && (
<div className="mt-2"> <div className="rounded-md border border-orange-200 bg-orange-50 p-3 text-xs text-orange-800">
<PasswordGenerator {t('events.publicGalleryWarning', 'Public galleries are accessible to anyone with the link. Consider enabling download watermarks and monitoring activity.')}
eventName={formData.event_name}
eventDate={formData.event_date}
eventType={formData.event_type}
onPasswordGenerated={handlePasswordGenerated}
passwordComplexity={passwordComplexity?.complexityLevel || 'moderate'}
className="w-full"
/>
</div> </div>
</div> )}
{/* Confirm Password */} {formData.require_password && (
<div> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<label htmlFor="confirm_password" className="block text-sm font-medium text-neutral-700 mb-1"> <div>
{t('events.confirmPassword')} <label htmlFor="password" className="block text-sm font-medium text-neutral-700 mb-1">
</label> {t('events.galleryPassword')}
<div className="relative"> </label>
<Input <div className="relative">
id="confirm_password" <Input
type={showPassword ? 'text' : 'password'} id="password"
value={formData.confirm_password} type={showPassword ? 'text' : 'password'}
onChange={handleInputChange('confirm_password')} value={formData.password}
error={errors.confirm_password} onChange={handleInputChange('password')}
placeholder={t('events.confirmPasswordPlaceholder')} error={errors.password}
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />} placeholder={t('events.enterPassword')}
className="pr-10" 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" />}
<button className="pr-10"
type="button" />
onClick={() => setShowPassword(!showPassword)} <button
className="absolute inset-y-0 right-0 pr-3 flex items-center" type="button"
style={{ top: errors.confirm_password ? '0' : '0' }} onClick={() => setShowPassword(!showPassword)}
> className="absolute inset-y-0 right-0 pr-3 flex items-center"
{showPassword ? ( style={{ top: errors.password ? '0' : '0' }}
<EyeOff className="w-5 h-5 text-neutral-400 hover:text-neutral-600" /> >
) : ( {showPassword ? (
<Eye className="w-5 h-5 text-neutral-400 hover:text-neutral-600" /> <EyeOff className="w-5 h-5 text-neutral-400 hover:text-neutral-600" />
)} ) : (
</button> <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> )}
</div> </div>
</Card> </Card>
@@ -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,8 +285,13 @@ 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) {
setFeedbackSettings(eventFeedbackSettings); setFeedbackSettings(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
@@ -356,6 +393,10 @@ export const EventDetailsPage: React.FC = () => {
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 => {
if (updateData[key] === undefined) { if (updateData[key] === undefined) {
@@ -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
}
}; };