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