feat: implement 4 new features with bug fixes and refactoring plan

## Features Implemented

### 1. Event Rename Functionality
- Add EventRenameDialog component with live slug preview
- Create eventRenameService for safe event renaming
- Add slug_redirects table for old URL redirects
- Support optional email notification on rename
- Fix date formatting in slug (YYYY-MM-DD format)

### 2. Optional Event Contact Fields
- Add settings to make customer name/email/admin email optional
- Create migration for field requirement settings
- Update CreateEventPage forms to show "(optional)" labels
- Fix boolean parsing in publicSettings.js

### 3. Photo Filtering & Export
- Add PhotoFilterPanel with rating/likes/favorites/comments filters
- Create PhotoExportMenu with ZIP/metadata/XMP export options
- Add photoExportService with Lightroom XMP sidecar generation
- Create photoFilterBuilder utility for query construction
- Wire up photo selection to export button via onSelectionChange

### 4. Custom CSS Gallery Templates
- Add CssTemplateEditor component with 3 template slots
- Create cssSanitizer utility blocking XSS vectors
- Add gallery CSS endpoint for template delivery
- Integrate Custom CSS tab into Settings page
- Include default "Elegant Dark" template

## Bug Fixes
- Fix event rename date formatting (was showing full Date string)
- Fix common.optional translation key missing in locales
- Fix photo export button staying disabled when photos selected
- Fix authService import missing in SettingsPage

## Documentation
- Add comprehensive REFACTORING_PLAN.md for codebase improvement
- Add test specification documents for all features
- Add feature documentation for CSS templates

## Database Migrations
- 049_add_slug_redirects.js
- 050_add_optional_event_fields_settings.js
- 051_add_photo_filter_indexes.js
- 052_add_css_templates.js
This commit is contained in:
Paul Nothaft
2026-01-02 09:56:19 +01:00
parent 64ceb20431
commit 77a4bfd499
38 changed files with 7989 additions and 58 deletions
+139
View File
@@ -1,3 +1,43 @@
/**
* CSS Sanitizer
* Sanitizes user-provided CSS to prevent security vulnerabilities
*/
// Patterns that should be blocked for security
const FORBIDDEN_PATTERNS = [
// JavaScript execution
/expression\s*\(/gi,
/javascript:/gi,
/behavior\s*:/gi,
/-moz-binding/gi,
/vbscript:/gi,
// External resources (potential data exfiltration)
/@import/gi,
// Dangerous at-rules
/@charset/gi,
/@namespace/gi,
// IE-specific exploits
/\\0/g, // Null byte
/\\9/g, // IE CSS hack
// Script injection attempts
/<script/gi,
/<\/script/gi,
/on\w+\s*=/gi, // onclick=, onload=, etc.
];
// Pattern for external URLs (block external, allow data: for images)
const EXTERNAL_URL_PATTERN = /url\s*\(\s*["']?(?!data:image)/gi;
// Maximum CSS size in bytes (100KB)
const MAX_CSS_SIZE = 100 * 1024;
/**
* Basic CSS sanitization (original function, kept for compatibility)
*/
function sanitizeCss(css) {
if (!css || typeof css !== 'string') {
return '';
@@ -27,6 +67,105 @@ function sanitizeCss(css) {
return sanitized.trim();
}
/**
* Enhanced CSS sanitization with warnings
* @param {string} cssContent - Raw CSS content
* @returns {Object} - { sanitized: string, warnings: string[] }
*/
function sanitizeCSS(cssContent) {
if (!cssContent || typeof cssContent !== 'string') {
return { sanitized: '', warnings: [] };
}
const warnings = [];
let sanitized = cssContent;
// Check size
if (sanitized.length > MAX_CSS_SIZE) {
warnings.push(`CSS exceeds maximum size of ${MAX_CSS_SIZE / 1024}KB`);
sanitized = sanitized.substring(0, MAX_CSS_SIZE);
}
// Remove forbidden patterns
for (const pattern of FORBIDDEN_PATTERNS) {
const patternStr = pattern.toString();
// Reset lastIndex for global patterns
pattern.lastIndex = 0;
if (pattern.test(sanitized)) {
const patternName = patternStr.replace(/\/[gi]*/g, '').substring(0, 30);
warnings.push(`Blocked potentially unsafe pattern: ${patternName}`);
pattern.lastIndex = 0;
sanitized = sanitized.replace(pattern, '/* BLOCKED */');
}
}
// Block external URLs (only allow data: URIs for images)
EXTERNAL_URL_PATTERN.lastIndex = 0;
if (EXTERNAL_URL_PATTERN.test(sanitized)) {
warnings.push('Blocked external URL references. Only data: URIs are allowed for images.');
EXTERNAL_URL_PATTERN.lastIndex = 0;
sanitized = sanitized.replace(EXTERNAL_URL_PATTERN, '/* BLOCKED URL */ url(');
}
// Remove HTML comments that might be used for injection
sanitized = sanitized.replace(/<!--[\s\S]*?-->/g, '');
// Remove control characters
sanitized = sanitized.replace(/[\u0000-\u001F\u007F]/g, '');
// Remove any remaining script-like content
sanitized = sanitized.replace(/<[^>]*>/g, '/* BLOCKED TAG */');
return { sanitized: sanitized.trim(), warnings };
}
/**
* Validate CSS syntax (basic check)
* @param {string} cssContent - CSS content to validate
* @returns {Object} - { valid: boolean, error?: string }
*/
function validateCSS(cssContent) {
if (!cssContent || cssContent.trim() === '') {
return { valid: true };
}
// Basic bracket matching
const openBraces = (cssContent.match(/{/g) || []).length;
const closeBraces = (cssContent.match(/}/g) || []).length;
if (openBraces !== closeBraces) {
return {
valid: false,
error: `Mismatched braces: ${openBraces} opening, ${closeBraces} closing`
};
}
return { valid: true };
}
/**
* Scope CSS to gallery page
* @param {string} cssContent - CSS content
* @returns {string} - Scoped CSS
*/
function scopeToGalleryPage(cssContent) {
if (!cssContent || cssContent.trim() === '') {
return '';
}
// If the CSS already uses .gallery-page, return as-is
if (cssContent.includes('.gallery-page')) {
return cssContent;
}
// Simple scoping: wrap entire content in .gallery-page
return `.gallery-page {\n${cssContent}\n}`;
}
module.exports = {
sanitizeCss,
sanitizeCSS,
validateCSS,
scopeToGalleryPage,
MAX_CSS_SIZE
};
+160
View File
@@ -0,0 +1,160 @@
/**
* Photo Filter Query Builder
* Builds Knex queries for filtering photos by feedback metrics
*/
class PhotoFilterBuilder {
constructor(queryBuilder, eventId) {
this.query = queryBuilder;
this.eventId = eventId;
}
/**
* Apply all filters from a filter object
*/
applyFilters(filters = {}) {
const {
min_rating,
max_rating,
has_likes,
min_likes,
has_favorites,
min_favorites,
has_comments,
category_id,
logic = 'AND'
} = filters;
// Always filter by event
this.query.where('photos.event_id', this.eventId);
// Build conditions array
const conditions = [];
if (min_rating !== undefined && min_rating !== null) {
conditions.push(builder => builder.where('photos.average_rating', '>=', min_rating));
}
if (max_rating !== undefined && max_rating !== null) {
conditions.push(builder => builder.where('photos.average_rating', '<=', max_rating));
}
if (has_likes === true || has_likes === 'true') {
conditions.push(builder => builder.where('photos.like_count', '>', 0));
}
if (min_likes !== undefined && min_likes !== null) {
conditions.push(builder => builder.where('photos.like_count', '>=', min_likes));
}
if (has_favorites === true || has_favorites === 'true') {
conditions.push(builder => builder.where('photos.favorite_count', '>', 0));
}
if (min_favorites !== undefined && min_favorites !== null) {
conditions.push(builder => builder.where('photos.favorite_count', '>=', min_favorites));
}
if (has_comments === true || has_comments === 'true') {
conditions.push(builder => builder.where('photos.comment_count', '>', 0));
}
if (category_id) {
conditions.push(builder => builder.where('photos.category_id', category_id));
}
// Apply conditions with AND/OR logic
if (conditions.length > 0) {
if (logic === 'OR') {
this.query.where(builder => {
conditions.forEach((condition, index) => {
if (index === 0) {
condition(builder);
} else {
builder.orWhere(subBuilder => condition(subBuilder));
}
});
});
} else {
// AND logic (default)
conditions.forEach(condition => {
this.query.where(builder => condition(builder));
});
}
}
return this;
}
/**
* Apply sorting
*/
applySorting(sort = 'date', order = 'desc') {
const sortMap = {
rating: 'photos.average_rating',
likes: 'photos.like_count',
favorites: 'photos.favorite_count',
date: 'photos.created_at',
filename: 'photos.filename'
};
const sortColumn = sortMap[sort] || sortMap.date;
this.query.orderBy(sortColumn, order === 'asc' ? 'asc' : 'desc');
return this;
}
/**
* Apply pagination
*/
applyPagination(page = 1, limit = 50) {
const offset = (page - 1) * limit;
this.query.limit(limit).offset(offset);
return this;
}
/**
* Get the built query
*/
getQuery() {
return this.query;
}
/**
* Build a count query for the same filters
*/
static buildCountQuery(db, eventId, filters = {}) {
const builder = new PhotoFilterBuilder(
db('photos').count('* as count'),
eventId
);
builder.applyFilters(filters);
return builder.getQuery();
}
/**
* Build a summary query for feedback counts
*/
static async getSummary(db, eventId) {
const result = await db('photos')
.where('event_id', eventId)
.select(
db.raw('COUNT(*) as total'),
db.raw('COUNT(CASE WHEN average_rating > 0 THEN 1 END) as with_ratings'),
db.raw('COUNT(CASE WHEN like_count > 0 THEN 1 END) as with_likes'),
db.raw('COUNT(CASE WHEN favorite_count > 0 THEN 1 END) as with_favorites'),
db.raw('COUNT(CASE WHEN comment_count > 0 THEN 1 END) as with_comments')
)
.first();
return {
total: parseInt(result.total) || 0,
withRatings: parseInt(result.with_ratings) || 0,
withLikes: parseInt(result.with_likes) || 0,
withFavorites: parseInt(result.with_favorites) || 0,
withComments: parseInt(result.with_comments) || 0
};
}
}
module.exports = { PhotoFilterBuilder };