b31f7e6f34
Mirror to GitHub / mirror (push) Successful in 38s
Test and Lint / backend-test (push) Successful in 1m26s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m9s
Version and Release / version-bump (push) Successful in 48s
Version and Release / trigger-drone (push) Successful in 3s
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>
312 lines
8.1 KiB
JavaScript
312 lines
8.1 KiB
JavaScript
const { db } = require('../database/db');
|
|
const logger = require('../utils/logger');
|
|
|
|
class FeedbackModerationService {
|
|
constructor() {
|
|
this.wordFiltersCache = null;
|
|
this.cacheExpiry = null;
|
|
this.CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
|
|
}
|
|
|
|
/**
|
|
* Get word filters (with caching)
|
|
*/
|
|
async getWordFilters() {
|
|
try {
|
|
// Check cache
|
|
if (this.wordFiltersCache && this.cacheExpiry && Date.now() < this.cacheExpiry) {
|
|
return this.wordFiltersCache;
|
|
}
|
|
|
|
// Fetch from database
|
|
const filters = await db('feedback_word_filters')
|
|
.where('is_active', true)
|
|
.select('word', 'severity');
|
|
|
|
// Update cache
|
|
this.wordFiltersCache = filters;
|
|
this.cacheExpiry = Date.now() + this.CACHE_DURATION;
|
|
|
|
return filters;
|
|
} catch (error) {
|
|
logger.error('Error getting word filters:', error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clear word filters cache
|
|
*/
|
|
clearCache() {
|
|
this.wordFiltersCache = null;
|
|
this.cacheExpiry = null;
|
|
}
|
|
|
|
/**
|
|
* Check if text contains inappropriate content
|
|
*/
|
|
async moderateText(text) {
|
|
try {
|
|
if (!text || typeof text !== 'string') {
|
|
return { approved: true };
|
|
}
|
|
|
|
const filters = await this.getWordFilters();
|
|
const violations = [];
|
|
const lowerText = text.toLowerCase();
|
|
|
|
for (const filter of filters) {
|
|
// Create regex for whole word matching
|
|
const regex = new RegExp(`\\b${this.escapeRegex(filter.word.toLowerCase())}\\b`, 'gi');
|
|
if (regex.test(lowerText)) {
|
|
violations.push({
|
|
word: filter.word,
|
|
severity: filter.severity
|
|
});
|
|
}
|
|
}
|
|
|
|
// Check for severe violations
|
|
if (violations.some(v => v.severity === 'severe')) {
|
|
return {
|
|
approved: false,
|
|
reason: 'Content contains prohibited words',
|
|
violations: violations.filter(v => v.severity === 'severe')
|
|
};
|
|
}
|
|
|
|
// Check for moderate violations
|
|
if (violations.some(v => v.severity === 'moderate')) {
|
|
return {
|
|
approved: false,
|
|
reason: 'Content requires moderation',
|
|
violations
|
|
};
|
|
}
|
|
|
|
// Check for mild violations (may just flag for review)
|
|
if (violations.length > 0) {
|
|
return {
|
|
approved: true,
|
|
flagged: true,
|
|
reason: 'Content contains potentially inappropriate words',
|
|
violations
|
|
};
|
|
}
|
|
|
|
// Additional checks
|
|
const additionalChecks = this.performAdditionalChecks(text);
|
|
if (!additionalChecks.passed) {
|
|
return {
|
|
approved: false,
|
|
reason: additionalChecks.reason
|
|
};
|
|
}
|
|
|
|
return { approved: true };
|
|
} catch (error) {
|
|
logger.error('Error moderating text:', error);
|
|
// In case of error, err on the side of caution
|
|
return {
|
|
approved: false,
|
|
reason: 'Moderation system error'
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Perform additional content checks
|
|
*/
|
|
performAdditionalChecks(text) {
|
|
// Check for excessive caps
|
|
const capsRatio = (text.match(/[A-Z]/g) || []).length / text.length;
|
|
if (text.length > 10 && capsRatio > 0.7) {
|
|
return {
|
|
passed: false,
|
|
reason: 'Excessive use of capital letters'
|
|
};
|
|
}
|
|
|
|
// Check for spam patterns
|
|
if (this.detectSpamPatterns(text)) {
|
|
return {
|
|
passed: false,
|
|
reason: 'Content appears to be spam'
|
|
};
|
|
}
|
|
|
|
// Check for excessive special characters
|
|
const specialCharRatio = (text.match(/[!@#$%^&*()]/g) || []).length / text.length;
|
|
if (text.length > 10 && specialCharRatio > 0.3) {
|
|
return {
|
|
passed: false,
|
|
reason: 'Excessive use of special characters'
|
|
};
|
|
}
|
|
|
|
return { passed: true };
|
|
}
|
|
|
|
/**
|
|
* Detect common spam patterns
|
|
*/
|
|
detectSpamPatterns(text) {
|
|
const spamPatterns = [
|
|
/\b(buy|cheap|discount|offer|sale|deal)\s+(now|today|here)/gi,
|
|
/\b(click|visit|check)\s+(here|link|this)/gi,
|
|
/\b(viagra|cialis|pills|drugs)\b/gi,
|
|
/\b(casino|betting|poker|slots)\b/gi,
|
|
/\b(make|earn)\s+\$?\d+/gi,
|
|
/https?:\/\/[^\s]+/gi, // URLs (might want to allow in some cases)
|
|
/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, // Email addresses
|
|
/\b\d{3,}\s?\d{3,}\s?\d{4,}\b/g // Phone numbers
|
|
];
|
|
|
|
return spamPatterns.some(pattern => pattern.test(text));
|
|
}
|
|
|
|
/**
|
|
* Escape special regex characters
|
|
*/
|
|
escapeRegex(string) {
|
|
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
}
|
|
|
|
/**
|
|
* Add word filter
|
|
*/
|
|
async addWordFilter(word, severity = 'moderate') {
|
|
try {
|
|
await db('feedback_word_filters').insert({
|
|
word: word.toLowerCase(),
|
|
severity,
|
|
is_active: true,
|
|
created_at: new Date()
|
|
});
|
|
|
|
this.clearCache();
|
|
logger.info(`Added word filter: ${word} (${severity})`);
|
|
|
|
return true;
|
|
} catch (error) {
|
|
if (error.code === 'SQLITE_CONSTRAINT' || error.code === '23505') {
|
|
throw new Error('Word filter already exists');
|
|
}
|
|
logger.error('Error adding word filter:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update word filter
|
|
*/
|
|
async updateWordFilter(id, updates) {
|
|
try {
|
|
await db('feedback_word_filters')
|
|
.where('id', id)
|
|
.update(updates);
|
|
|
|
this.clearCache();
|
|
return true;
|
|
} catch (error) {
|
|
logger.error('Error updating word filter:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Delete word filter
|
|
*/
|
|
async deleteWordFilter(id) {
|
|
try {
|
|
await db('feedback_word_filters')
|
|
.where('id', id)
|
|
.delete();
|
|
|
|
this.clearCache();
|
|
return true;
|
|
} catch (error) {
|
|
logger.error('Error deleting word filter:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get all word filters (for admin)
|
|
*/
|
|
async getAllWordFilters() {
|
|
try {
|
|
return await db('feedback_word_filters')
|
|
.orderBy('severity', 'desc')
|
|
.orderBy('word', 'asc');
|
|
} catch (error) {
|
|
logger.error('Error getting all word filters:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sanitize text for display (remove but don't reject)
|
|
*/
|
|
sanitizeText(text) {
|
|
// Remove excessive whitespace
|
|
text = text.replace(/\s+/g, ' ').trim();
|
|
|
|
// Remove zero-width characters
|
|
text = text.replace(/[\u200B-\u200D\uFEFF]/g, '');
|
|
|
|
// Limit consecutive special characters
|
|
text = text.replace(/([!?.]){3,}/g, '$1$1');
|
|
|
|
return text;
|
|
}
|
|
|
|
/**
|
|
* Check if user should be rate limited based on previous violations
|
|
*/
|
|
async checkUserReputation(guestIdentifier, eventId) {
|
|
try {
|
|
// Count recent violations
|
|
const recentViolations = await db('photo_feedback')
|
|
.where('guest_identifier', guestIdentifier)
|
|
.where('event_id', eventId)
|
|
.where('is_hidden', true)
|
|
.where('created_at', '>', new Date(Date.now() - 24 * 60 * 60 * 1000)) // Last 24 hours
|
|
.count('id as count')
|
|
.first();
|
|
|
|
// If user has multiple violations, they might be problematic
|
|
if (recentViolations && recentViolations.count > 3) {
|
|
return {
|
|
trusted: false,
|
|
reason: 'Multiple recent violations'
|
|
};
|
|
}
|
|
|
|
// Check total approved comments
|
|
const approvedComments = await db('photo_feedback')
|
|
.where('guest_identifier', guestIdentifier)
|
|
.where('event_id', eventId)
|
|
.where('feedback_type', 'comment')
|
|
.where('is_approved', true)
|
|
.where('is_hidden', false)
|
|
.count('id as count')
|
|
.first();
|
|
|
|
// User with many approved comments is trusted
|
|
if (approvedComments && approvedComments.count > 10) {
|
|
return {
|
|
trusted: true,
|
|
autoApprove: true
|
|
};
|
|
}
|
|
|
|
return { trusted: true };
|
|
} catch (error) {
|
|
logger.error('Error checking user reputation:', error);
|
|
return { trusted: true }; // Default to trusting in case of error
|
|
}
|
|
}
|
|
}
|
|
|
|
module.exports = new FeedbackModerationService(); |