feat: implement gallery feedback system with version tracking for backups
Gallery Feedback Features: - Add feedback system allowing ratings, likes, comments, and favorites on photos - Implement admin controls for enabling/disabling feedback per event - Add content moderation with word filters and spam detection - Implement rate limiting to prevent abuse (10 requests/15min per type) - Create comprehensive admin interface for feedback management - Add analytics dashboard for feedback insights - Export feedback data when archiving events Frontend Components: - PhotoRating: 5-star rating system with optimistic updates - PhotoLikes: Like/unlike with animation - PhotoComments: Threaded comments with moderation - PhotoFavorites: Bookmark functionality - FeedbackSettings: Admin configuration panel - EventFeedbackPage: Complete management interface Backend Implementation: - Database migration 033: 4 new tables for feedback system - RESTful API with proper authorization - Guest identification via SHA256(IP+UserAgent) - Automatic backup integration - Email notification support Backup Version Tracking: - Migration 034: Add version columns to backup tables - Track app version, Node.js version, and DB schema version - Create restore_history table for tracking restore attempts - Add version compatibility checking for safe restores - Configurable version matching requirements Security & Performance: - Input validation and sanitization - Rate limiting per feedback type - Content moderation system - Optimistic UI updates - Efficient database queries with proper indexes 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
const { body, param, validationResult } = require('express-validator');
|
||||
const validator = require('validator');
|
||||
|
||||
/**
|
||||
* Validation rules for feedback submission
|
||||
*/
|
||||
const feedbackValidationRules = {
|
||||
rating: [
|
||||
body('feedback_type').equals('rating'),
|
||||
body('rating')
|
||||
.isInt({ min: 1, max: 5 })
|
||||
.withMessage('Rating must be between 1 and 5'),
|
||||
body('guest_name')
|
||||
.optional()
|
||||
.trim()
|
||||
.isLength({ max: 100 })
|
||||
.withMessage('Name must be less than 100 characters'),
|
||||
body('guest_email')
|
||||
.optional()
|
||||
.trim()
|
||||
.isEmail()
|
||||
.normalizeEmail()
|
||||
.withMessage('Invalid email address')
|
||||
],
|
||||
|
||||
like: [
|
||||
body('feedback_type').equals('like'),
|
||||
body('guest_name')
|
||||
.optional()
|
||||
.trim()
|
||||
.isLength({ max: 100 }),
|
||||
body('guest_email')
|
||||
.optional()
|
||||
.trim()
|
||||
.isEmail()
|
||||
.normalizeEmail()
|
||||
],
|
||||
|
||||
favorite: [
|
||||
body('feedback_type').equals('favorite'),
|
||||
body('guest_name')
|
||||
.optional()
|
||||
.trim()
|
||||
.isLength({ max: 100 }),
|
||||
body('guest_email')
|
||||
.optional()
|
||||
.trim()
|
||||
.isEmail()
|
||||
.normalizeEmail()
|
||||
],
|
||||
|
||||
comment: [
|
||||
body('feedback_type').equals('comment'),
|
||||
body('comment_text')
|
||||
.trim()
|
||||
.notEmpty()
|
||||
.withMessage('Comment cannot be empty')
|
||||
.isLength({ min: 1, max: 1000 })
|
||||
.withMessage('Comment must be between 1 and 1000 characters')
|
||||
.customSanitizer(value => sanitizeComment(value)),
|
||||
body('guest_name')
|
||||
.optional()
|
||||
.trim()
|
||||
.isLength({ max: 100 })
|
||||
.withMessage('Name must be less than 100 characters'),
|
||||
body('guest_email')
|
||||
.optional()
|
||||
.trim()
|
||||
.isEmail()
|
||||
.normalizeEmail()
|
||||
.withMessage('Invalid email address')
|
||||
]
|
||||
};
|
||||
|
||||
/**
|
||||
* Sanitize comment text
|
||||
*/
|
||||
function sanitizeComment(text) {
|
||||
if (!text) return '';
|
||||
|
||||
// Remove excessive whitespace
|
||||
text = text.replace(/\s+/g, ' ').trim();
|
||||
|
||||
// Remove zero-width characters
|
||||
text = text.replace(/[\u200B-\u200D\uFEFF]/g, '');
|
||||
|
||||
// Remove control characters
|
||||
text = text.replace(/[\x00-\x1F\x7F]/g, '');
|
||||
|
||||
// Limit consecutive special characters
|
||||
text = text.replace(/([!?.]){4,}/g, '$1$1$1');
|
||||
|
||||
// Remove script tags and other dangerous HTML (basic sanitization)
|
||||
text = text.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '');
|
||||
text = text.replace(/<iframe[^>]*>[\s\S]*?<\/iframe>/gi, '');
|
||||
text = text.replace(/<object[^>]*>[\s\S]*?<\/object>/gi, '');
|
||||
text = text.replace(/<embed[^>]*>/gi, '');
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate feedback type parameter
|
||||
*/
|
||||
const validateFeedbackType = param('feedbackType')
|
||||
.isIn(['rating', 'like', 'comment', 'favorite'])
|
||||
.withMessage('Invalid feedback type');
|
||||
|
||||
/**
|
||||
* Validate photo ID parameter
|
||||
*/
|
||||
const validatePhotoId = param('photoId')
|
||||
.isInt({ min: 1 })
|
||||
.withMessage('Invalid photo ID');
|
||||
|
||||
/**
|
||||
* Validate event ID parameter
|
||||
*/
|
||||
const validateEventId = param('eventId')
|
||||
.isInt({ min: 1 })
|
||||
.withMessage('Invalid event ID');
|
||||
|
||||
/**
|
||||
* Get validation rules based on feedback type
|
||||
*/
|
||||
function getValidationRules(feedbackType) {
|
||||
return feedbackValidationRules[feedbackType] || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation middleware for feedback submission
|
||||
*/
|
||||
const validateFeedbackSubmission = [
|
||||
body('feedback_type')
|
||||
.isIn(['rating', 'like', 'comment', 'favorite'])
|
||||
.withMessage('Invalid feedback type'),
|
||||
|
||||
// Conditional validation based on feedback type
|
||||
body('rating')
|
||||
.if(body('feedback_type').equals('rating'))
|
||||
.isInt({ min: 1, max: 5 })
|
||||
.withMessage('Rating must be between 1 and 5'),
|
||||
|
||||
body('comment_text')
|
||||
.if(body('feedback_type').equals('comment'))
|
||||
.trim()
|
||||
.notEmpty()
|
||||
.withMessage('Comment cannot be empty')
|
||||
.isLength({ min: 1, max: 1000 })
|
||||
.withMessage('Comment must be between 1 and 1000 characters')
|
||||
.customSanitizer(value => sanitizeComment(value)),
|
||||
|
||||
body('guest_name')
|
||||
.optional()
|
||||
.trim()
|
||||
.isLength({ max: 100 })
|
||||
.withMessage('Name must be less than 100 characters')
|
||||
.matches(/^[a-zA-Z0-9\s\-'.]+$/)
|
||||
.withMessage('Name contains invalid characters'),
|
||||
|
||||
body('guest_email')
|
||||
.optional()
|
||||
.trim()
|
||||
.isEmail()
|
||||
.normalizeEmail()
|
||||
.withMessage('Invalid email address')
|
||||
];
|
||||
|
||||
/**
|
||||
* Validation for feedback settings
|
||||
*/
|
||||
const validateFeedbackSettings = [
|
||||
body('feedback_enabled').optional().isBoolean(),
|
||||
body('allow_ratings').optional().isBoolean(),
|
||||
body('allow_likes').optional().isBoolean(),
|
||||
body('allow_comments').optional().isBoolean(),
|
||||
body('allow_favorites').optional().isBoolean(),
|
||||
body('require_name_email').optional().isBoolean(),
|
||||
body('moderate_comments').optional().isBoolean(),
|
||||
body('show_feedback_to_guests').optional().isBoolean()
|
||||
];
|
||||
|
||||
/**
|
||||
* Validation for word filters
|
||||
*/
|
||||
const validateWordFilter = [
|
||||
body('word')
|
||||
.trim()
|
||||
.notEmpty()
|
||||
.withMessage('Word cannot be empty')
|
||||
.isLength({ min: 2, max: 100 })
|
||||
.withMessage('Word must be between 2 and 100 characters'),
|
||||
body('severity')
|
||||
.optional()
|
||||
.isIn(['mild', 'moderate', 'severe'])
|
||||
.withMessage('Invalid severity level')
|
||||
];
|
||||
|
||||
/**
|
||||
* Check validation results middleware
|
||||
*/
|
||||
const checkValidation = (req, res, next) => {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
error: 'Validation failed',
|
||||
errors: errors.array()
|
||||
});
|
||||
}
|
||||
next();
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate guest identity requirements
|
||||
*/
|
||||
async function validateGuestRequirements(settings, guestData) {
|
||||
if (!settings.require_name_email) {
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
const errors = [];
|
||||
|
||||
if (!guestData.guest_name || guestData.guest_name.trim().length === 0) {
|
||||
errors.push('Name is required');
|
||||
}
|
||||
|
||||
if (!guestData.guest_email || !validator.isEmail(guestData.guest_email)) {
|
||||
errors.push('Valid email is required');
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
return {
|
||||
valid: false,
|
||||
errors
|
||||
};
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
feedbackValidationRules,
|
||||
validateFeedbackType,
|
||||
validatePhotoId,
|
||||
validateEventId,
|
||||
validateFeedbackSubmission,
|
||||
validateFeedbackSettings,
|
||||
validateWordFilter,
|
||||
checkValidation,
|
||||
getValidationRules,
|
||||
sanitizeComment,
|
||||
validateGuestRequirements
|
||||
};
|
||||
Reference in New Issue
Block a user