dc1419c051
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>
236 lines
6.3 KiB
JavaScript
236 lines
6.3 KiB
JavaScript
const crypto = require('crypto');
|
|
const { db } = require('../database/db');
|
|
const logger = require('../utils/logger');
|
|
|
|
/**
|
|
* Generate a unique identifier for the guest
|
|
*/
|
|
function generateGuestIdentifier(req) {
|
|
const ip = req.ip || req.connection.remoteAddress || 'unknown';
|
|
const userAgent = req.headers['user-agent'] || 'unknown';
|
|
return crypto
|
|
.createHash('sha256')
|
|
.update(`${ip}:${userAgent}`)
|
|
.digest('hex');
|
|
}
|
|
|
|
/**
|
|
* Get rate limit settings from app_settings
|
|
*/
|
|
async function getRateLimitSettings() {
|
|
try {
|
|
const settings = await db('app_settings')
|
|
.where('setting_key', 'feedback_rate_limits')
|
|
.first();
|
|
|
|
if (settings && settings.setting_value) {
|
|
return JSON.parse(settings.setting_value);
|
|
}
|
|
|
|
// Default settings
|
|
return {
|
|
rating: { max: 100, window: 3600 }, // 100 ratings per hour
|
|
comment: { max: 20, window: 3600 }, // 20 comments per hour
|
|
like: { max: 200, window: 3600 }, // 200 likes per hour
|
|
favorite: { max: 100, window: 3600 } // 100 favorites per hour
|
|
};
|
|
} catch (error) {
|
|
logger.error('Error getting rate limit settings:', error);
|
|
// Return defaults on error
|
|
return {
|
|
rating: { max: 100, window: 3600 },
|
|
comment: { max: 20, window: 3600 },
|
|
like: { max: 200, window: 3600 },
|
|
favorite: { max: 100, window: 3600 }
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if action is rate limited
|
|
*/
|
|
async function checkRateLimit(identifier, eventId, actionType) {
|
|
try {
|
|
const settings = await getRateLimitSettings();
|
|
const limit = settings[actionType] || { max: 100, window: 3600 };
|
|
|
|
// Clean old entries (older than window)
|
|
const cutoff = new Date(Date.now() - limit.window * 1000);
|
|
await db('feedback_rate_limits')
|
|
.where('window_start', '<', cutoff)
|
|
.delete();
|
|
|
|
// Count recent actions
|
|
const recentActions = await db('feedback_rate_limits')
|
|
.where({
|
|
identifier,
|
|
event_id: eventId,
|
|
action_type: actionType
|
|
})
|
|
.where('window_start', '>', cutoff)
|
|
.sum('action_count as total')
|
|
.first();
|
|
|
|
const currentCount = recentActions?.total || 0;
|
|
|
|
if (currentCount >= limit.max) {
|
|
return {
|
|
limited: true,
|
|
limit: limit.max,
|
|
window: limit.window,
|
|
current: currentCount,
|
|
resetAt: new Date(Date.now() + limit.window * 1000)
|
|
};
|
|
}
|
|
|
|
return {
|
|
limited: false,
|
|
limit: limit.max,
|
|
window: limit.window,
|
|
current: currentCount,
|
|
remaining: limit.max - currentCount
|
|
};
|
|
} catch (error) {
|
|
logger.error('Error checking rate limit:', error);
|
|
// Allow action on error to avoid blocking legitimate users
|
|
return { limited: false };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Record an action for rate limiting
|
|
*/
|
|
async function recordAction(identifier, eventId, actionType) {
|
|
try {
|
|
await db('feedback_rate_limits').insert({
|
|
identifier,
|
|
event_id: eventId,
|
|
action_type: actionType,
|
|
action_count: 1,
|
|
window_start: new Date()
|
|
});
|
|
} catch (error) {
|
|
logger.error('Error recording rate limit action:', error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Middleware factory for feedback rate limiting
|
|
*/
|
|
function feedbackRateLimit(actionType) {
|
|
return async (req, res, next) => {
|
|
try {
|
|
// Extract event ID from params or body
|
|
const eventId = req.params.eventId || req.body?.event_id;
|
|
if (!eventId) {
|
|
return res.status(400).json({ error: 'Event ID required' });
|
|
}
|
|
|
|
// Generate guest identifier
|
|
const identifier = generateGuestIdentifier(req);
|
|
req.guestIdentifier = identifier;
|
|
|
|
// Check rate limit
|
|
const rateLimitStatus = await checkRateLimit(identifier, eventId, actionType);
|
|
|
|
// Set rate limit headers
|
|
res.set({
|
|
'X-RateLimit-Limit': rateLimitStatus.limit,
|
|
'X-RateLimit-Remaining': rateLimitStatus.remaining || 0,
|
|
'X-RateLimit-Reset': rateLimitStatus.resetAt ? rateLimitStatus.resetAt.toISOString() : new Date().toISOString()
|
|
});
|
|
|
|
if (rateLimitStatus.limited) {
|
|
logger.warn(`Rate limit exceeded for ${actionType}`, {
|
|
identifier: identifier.substring(0, 16) + '...',
|
|
eventId,
|
|
actionType
|
|
});
|
|
|
|
return res.status(429).json({
|
|
error: 'Too many requests',
|
|
message: `Rate limit exceeded. Please try again later.`,
|
|
retryAfter: rateLimitStatus.window
|
|
});
|
|
}
|
|
|
|
// Record the action after successful processing
|
|
res.on('finish', async () => {
|
|
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
await recordAction(identifier, eventId, actionType);
|
|
}
|
|
});
|
|
|
|
next();
|
|
} catch (error) {
|
|
logger.error('Error in rate limit middleware:', error);
|
|
// Allow request to proceed on error
|
|
next();
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* IP-based rate limiting for more strict control
|
|
*/
|
|
function strictRateLimit(options = {}) {
|
|
const {
|
|
windowMs = 15 * 60 * 1000, // 15 minutes
|
|
max = 100, // limit each IP to 100 requests per windowMs
|
|
message = 'Too many requests from this IP, please try again later.',
|
|
skipSuccessfulRequests = false
|
|
} = options;
|
|
|
|
const store = new Map();
|
|
|
|
// Clean up old entries periodically
|
|
setInterval(() => {
|
|
const now = Date.now();
|
|
for (const [key, data] of store.entries()) {
|
|
if (data.resetTime < now) {
|
|
store.delete(key);
|
|
}
|
|
}
|
|
}, windowMs);
|
|
|
|
return (req, res, next) => {
|
|
const ip = req.ip || req.connection.remoteAddress;
|
|
const now = Date.now();
|
|
const resetTime = now + windowMs;
|
|
|
|
let data = store.get(ip);
|
|
if (!data || data.resetTime < now) {
|
|
data = {
|
|
count: 0,
|
|
resetTime
|
|
};
|
|
store.set(ip, data);
|
|
}
|
|
|
|
if (data.count >= max) {
|
|
return res.status(429).json({
|
|
error: 'Too many requests',
|
|
message,
|
|
retryAfter: Math.ceil((data.resetTime - now) / 1000)
|
|
});
|
|
}
|
|
|
|
if (!skipSuccessfulRequests || res.statusCode >= 400) {
|
|
data.count++;
|
|
}
|
|
|
|
res.setHeader('X-RateLimit-Limit', max);
|
|
res.setHeader('X-RateLimit-Remaining', Math.max(0, max - data.count));
|
|
res.setHeader('X-RateLimit-Reset', new Date(data.resetTime).toISOString());
|
|
|
|
next();
|
|
};
|
|
}
|
|
|
|
module.exports = {
|
|
feedbackRateLimit,
|
|
strictRateLimit,
|
|
generateGuestIdentifier,
|
|
checkRateLimit,
|
|
recordAction
|
|
}; |