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:
@@ -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
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user