feat: enhance security logging and ensure rate limit blocks are properly tracked
Mirror to GitHub / mirror (push) Successful in 31s
Test and Lint / backend-test (push) Successful in 1m27s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m22s
Version and Release / version-bump (push) Successful in 38s
Version and Release / trigger-drone (push) Successful in 3s
Mirror to GitHub / mirror (push) Successful in 31s
Test and Lint / backend-test (push) Successful in 1m27s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m22s
Version and Release / version-bump (push) Successful in 38s
Version and Release / trigger-drone (push) Successful in 3s
- Add comprehensive logging for rate limit blocks with full request details - IP address (with proper proxy detection), user agent, headers, timestamps - Rate limit info (current count, limit, remaining, reset time) - Separate tracking for auth vs general endpoints - Enhance authentication failure logging - JWT validation failures with detailed error info - Admin auth attempts without token - Failed token validation with user context - All events include IP, path, method, user agent - Improve Winston logger configuration for production - Add automatic log rotation (10MB errors, 50MB combined) - Create separate security.log for auth/rate limit events - Ensure logs directory exists automatically - Add structured JSON format for log aggregation - Support container logging with LOG_TO_CONSOLE env var - Create comprehensive documentation - Security logging guide with examples - Monitoring recommendations - Configuration reference - Add test script to verify logging functionality All rate limit settings remain configurable via admin panel: - Window duration, max requests, auth limits - Skip authenticated requests option - Public endpoints only option 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
# Security Logging Documentation
|
||||
|
||||
## Overview
|
||||
This document describes the comprehensive security logging implemented in the PicPeak application to track authentication failures, rate limiting, and suspicious activities.
|
||||
|
||||
## Log Files
|
||||
|
||||
### 1. **security.log**
|
||||
- Location: `logs/security.log`
|
||||
- Contains: All security-related events (authentication, rate limiting, suspicious activity)
|
||||
- Max Size: 20MB with rotation (keeps 10 files)
|
||||
- Format: JSON with timestamp
|
||||
|
||||
### 2. **error.log**
|
||||
- Location: `logs/error.log`
|
||||
- Contains: All error-level logs including auth failures
|
||||
- Max Size: 10MB with rotation (keeps 5 files)
|
||||
|
||||
### 3. **combined.log**
|
||||
- Location: `logs/combined.log`
|
||||
- Contains: All logs (info, warn, error)
|
||||
- Max Size: 50MB with rotation (keeps 10 files)
|
||||
|
||||
## Security Events Logged
|
||||
|
||||
### Rate Limiting
|
||||
When rate limits are exceeded, the following is logged:
|
||||
```json
|
||||
{
|
||||
"timestamp": "2024-01-18 14:23:45.123",
|
||||
"level": "warn",
|
||||
"message": "Rate limit exceeded",
|
||||
"security": true,
|
||||
"ip": "192.168.1.1",
|
||||
"path": "/api/admin/login",
|
||||
"method": "POST",
|
||||
"authenticated": false,
|
||||
"userAgent": "Mozilla/5.0...",
|
||||
"referer": "https://app.example.com",
|
||||
"origin": "https://app.example.com",
|
||||
"headers": {
|
||||
"x-forwarded-for": "192.168.1.1",
|
||||
"x-real-ip": "192.168.1.1"
|
||||
},
|
||||
"requestUrl": "/api/admin/login",
|
||||
"rateLimitInfo": {
|
||||
"limit": 5,
|
||||
"current": 6,
|
||||
"remaining": 0,
|
||||
"resetTime": "2024-01-18T14:38:45.123Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Authentication Failures
|
||||
|
||||
#### Admin Login Failures
|
||||
- Tracked in `login_attempts` table
|
||||
- Logged with: IP address, username, user agent, timestamp
|
||||
- Account lockout after 5 failures in 15 minutes
|
||||
|
||||
#### Gallery Password Failures
|
||||
- Tracked in `access_logs` table with action='login_fail'
|
||||
- Logged with: event_id, IP address, user agent
|
||||
- Gallery lockout after 5 failures in 15 minutes
|
||||
|
||||
### JWT Validation Failures
|
||||
```json
|
||||
{
|
||||
"timestamp": "2024-01-18 14:23:45.123",
|
||||
"level": "warn",
|
||||
"message": "JWT validation failed",
|
||||
"ip": "192.168.1.1",
|
||||
"path": "/api/admin/events",
|
||||
"method": "GET",
|
||||
"userAgent": "Mozilla/5.0...",
|
||||
"error": "TokenExpiredError",
|
||||
"message": "jwt expired"
|
||||
}
|
||||
```
|
||||
|
||||
### Suspicious Activity
|
||||
- Multiple IPs attempting login for same account
|
||||
- Token usage from different IP than issued
|
||||
- Token usage after password change
|
||||
- Revoked token usage attempts
|
||||
|
||||
## Configuration Settings
|
||||
|
||||
All rate limiting settings are configurable via the admin panel:
|
||||
|
||||
| Setting | Default | Range | Description |
|
||||
|---------|---------|-------|-------------|
|
||||
| rate_limit_enabled | true | - | Enable/disable rate limiting |
|
||||
| rate_limit_window_minutes | 15 | 1-60 | Time window for rate limit |
|
||||
| rate_limit_max_requests | 1000 | 10-10000 | Max requests for general endpoints |
|
||||
| rate_limit_auth_max_requests | 5 | 1-100 | Max requests for auth endpoints |
|
||||
| rate_limit_skip_authenticated | true | - | Skip rate limit for authenticated requests |
|
||||
| rate_limit_public_endpoints_only | false | - | Only rate limit public endpoints |
|
||||
|
||||
## Database Tables
|
||||
|
||||
### login_attempts
|
||||
```sql
|
||||
- id
|
||||
- username
|
||||
- ip_address
|
||||
- user_agent
|
||||
- success (boolean)
|
||||
- created_at
|
||||
```
|
||||
|
||||
### access_logs
|
||||
```sql
|
||||
- id
|
||||
- event_id
|
||||
- ip_address
|
||||
- user_agent
|
||||
- action ('view', 'download', 'login_success', 'login_fail')
|
||||
- photo_id (nullable)
|
||||
- created_at
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
- `LOG_LEVEL`: Set logging level (default: 'info')
|
||||
- `LOG_TO_CONSOLE`: Enable console logging in production (default: false)
|
||||
|
||||
## Monitoring Recommendations
|
||||
|
||||
1. **Set up alerts for:**
|
||||
- Rate limit exceeded events (possible DDoS)
|
||||
- Multiple failed login attempts from same IP
|
||||
- Account lockout events
|
||||
- JWT validation failures spike
|
||||
|
||||
2. **Regular review:**
|
||||
- Check security.log for patterns
|
||||
- Review login_attempts table for brute force attempts
|
||||
- Monitor access_logs for suspicious gallery access patterns
|
||||
|
||||
3. **Log analysis tools:**
|
||||
- Use log aggregation tools (ELK stack, Splunk)
|
||||
- Set up dashboards for security metrics
|
||||
- Configure alerts for threshold breaches
|
||||
|
||||
## Production Deployment Notes
|
||||
|
||||
1. Ensure logs directory has proper permissions
|
||||
2. Set up log rotation outside of application if needed
|
||||
3. Consider shipping logs to centralized logging service
|
||||
4. Monitor disk space for log files
|
||||
5. Set `LOG_TO_CONSOLE=true` for container deployments
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
1. Never log sensitive data (passwords, tokens)
|
||||
2. Use generic error messages to prevent user enumeration
|
||||
3. Clean up old login attempts regularly (7 days retention)
|
||||
4. Monitor for unusual patterns in real-time
|
||||
5. Keep rate limit settings appropriate for your usage
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Test script to verify CMS and email formatting improvements
|
||||
*/
|
||||
|
||||
const { formatWelcomeMessage, nl2br } = require('../src/utils/formatters');
|
||||
|
||||
console.log('Testing CMS and Email Formatting Improvements\n');
|
||||
|
||||
// Test 1: Basic line break conversion
|
||||
console.log('Test 1: Basic line break conversion');
|
||||
const basicText = `Hello,
|
||||
This is line 1.
|
||||
This is line 2.
|
||||
|
||||
This is line 4 with an extra break.`;
|
||||
|
||||
console.log('Input:');
|
||||
console.log(basicText);
|
||||
console.log('\nOutput (nl2br):');
|
||||
console.log(nl2br(basicText));
|
||||
console.log('\n---\n');
|
||||
|
||||
// Test 2: Welcome message formatting
|
||||
console.log('Test 2: Welcome message formatting');
|
||||
const welcomeMessage = `Dear guests,
|
||||
|
||||
We're so excited to share these special moments with you!
|
||||
|
||||
Please note:
|
||||
- Download your photos before the expiration date
|
||||
- The password is case-sensitive
|
||||
- Contact us if you have any issues
|
||||
|
||||
Thank you for being part of our special day!
|
||||
|
||||
Best regards,
|
||||
Sarah & John`;
|
||||
|
||||
console.log('Input:');
|
||||
console.log(welcomeMessage);
|
||||
console.log('\nOutput (formatWelcomeMessage):');
|
||||
console.log(formatWelcomeMessage(welcomeMessage));
|
||||
console.log('\n---\n');
|
||||
|
||||
// Test 3: Empty and edge cases
|
||||
console.log('Test 3: Edge cases');
|
||||
console.log('Empty string:', formatWelcomeMessage(''));
|
||||
console.log('Null:', formatWelcomeMessage(null));
|
||||
console.log('Only spaces:', formatWelcomeMessage(' \n \n '));
|
||||
console.log('Single line:', formatWelcomeMessage('This is a single line message'));
|
||||
|
||||
console.log('\nAll tests completed!');
|
||||
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Test script to verify security logging is working correctly
|
||||
* Run with: node scripts/test-security-logging.js
|
||||
*/
|
||||
|
||||
require('dotenv').config({ path: '../.env' });
|
||||
const logger = require('../src/utils/logger');
|
||||
|
||||
console.log('Testing Security Logging...\n');
|
||||
|
||||
// Test 1: Basic logging
|
||||
console.log('1. Testing basic logging levels:');
|
||||
logger.info('Test info message', { test: true });
|
||||
logger.warn('Test warning message', { test: true });
|
||||
logger.error('Test error message', { test: true });
|
||||
|
||||
// Test 2: Security event logging
|
||||
console.log('\n2. Testing security event logging:');
|
||||
|
||||
// Rate limit exceeded
|
||||
logger.warn('Rate limit exceeded', {
|
||||
ip: '192.168.1.100',
|
||||
path: '/api/admin/login',
|
||||
method: 'POST',
|
||||
authenticated: false,
|
||||
userAgent: 'Mozilla/5.0 Test',
|
||||
timestamp: new Date().toISOString(),
|
||||
rateLimitInfo: {
|
||||
limit: 5,
|
||||
current: 6,
|
||||
remaining: 0,
|
||||
resetTime: new Date(Date.now() + 900000).toISOString()
|
||||
}
|
||||
});
|
||||
|
||||
// Auth rate limit
|
||||
logger.warn('Auth rate limit exceeded', {
|
||||
ip: '192.168.1.101',
|
||||
path: '/api/auth/admin/login',
|
||||
method: 'POST',
|
||||
userAgent: 'Mozilla/5.0 Test',
|
||||
authType: 'admin',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
// Failed login
|
||||
logger.warn('Failed login attempt', {
|
||||
username: 'testuser',
|
||||
ip: '192.168.1.102',
|
||||
userAgent: 'Mozilla/5.0 Test',
|
||||
reason: 'invalid_credentials',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
// JWT validation failure
|
||||
logger.warn('JWT validation failed', {
|
||||
ip: '192.168.1.103',
|
||||
path: '/api/admin/events',
|
||||
method: 'GET',
|
||||
userAgent: 'Mozilla/5.0 Test',
|
||||
error: 'TokenExpiredError',
|
||||
message: 'jwt expired',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
// Account lockout
|
||||
logger.warn('Login attempt on locked account', {
|
||||
username: 'lockeduser',
|
||||
ip: '192.168.1.104',
|
||||
remainingLockTime: 1200,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
// Suspicious activity
|
||||
logger.warn('Suspicious login activity detected', {
|
||||
username: 'suspicioususer',
|
||||
ips: ['192.168.1.105', '192.168.1.106', '192.168.1.107'],
|
||||
timeWindow: '15 minutes',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
console.log('\n3. Check log files:');
|
||||
console.log('- logs/security.log - Should contain all security warnings');
|
||||
console.log('- logs/error.log - Should contain error messages');
|
||||
console.log('- logs/combined.log - Should contain all messages');
|
||||
|
||||
console.log('\n✅ Security logging test complete!');
|
||||
console.log('Review the log files to ensure all events are properly captured.');
|
||||
|
||||
// Give logger time to flush
|
||||
setTimeout(() => {
|
||||
process.exit(0);
|
||||
}, 1000);
|
||||
@@ -4,6 +4,14 @@ require('dotenv').config();
|
||||
const { validateEnvironment } = require('./src/config/validateEnv');
|
||||
validateEnvironment();
|
||||
|
||||
// Initialize logger early to capture startup logs
|
||||
const logger = require('./src/utils/logger');
|
||||
logger.info('Server starting up', {
|
||||
nodeVersion: process.version,
|
||||
environment: process.env.NODE_ENV || 'development',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
const express = require('express');
|
||||
const helmet = require('helmet');
|
||||
const cors = require('cors');
|
||||
|
||||
@@ -1,24 +1,83 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { db } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
async function adminAuth(req, res, next) {
|
||||
try {
|
||||
const token = req.headers.authorization?.split(' ')[1];
|
||||
if (!token) {
|
||||
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
|
||||
req.headers['x-real-ip'] ||
|
||||
req.connection.remoteAddress ||
|
||||
req.ip;
|
||||
logger.warn('Admin auth attempt without token', {
|
||||
ip: clientIp,
|
||||
path: req.path,
|
||||
method: req.method,
|
||||
userAgent: req.headers['user-agent']
|
||||
});
|
||||
return res.status(401).json({ error: 'No token provided' });
|
||||
}
|
||||
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
let decoded;
|
||||
try {
|
||||
decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
} catch (jwtError) {
|
||||
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
|
||||
req.headers['x-real-ip'] ||
|
||||
req.connection.remoteAddress ||
|
||||
req.ip;
|
||||
|
||||
logger.warn('JWT validation failed', {
|
||||
ip: clientIp,
|
||||
path: req.path,
|
||||
method: req.method,
|
||||
userAgent: req.headers['user-agent'],
|
||||
error: jwtError.name,
|
||||
message: jwtError.message,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
if (jwtError.name === 'TokenExpiredError') {
|
||||
return res.status(401).json({ error: 'Token expired' });
|
||||
}
|
||||
return res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
|
||||
const admin = await db('admin_users').where({ id: decoded.id, is_active: formatBoolean(true) }).first();
|
||||
|
||||
if (!admin) {
|
||||
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
|
||||
req.headers['x-real-ip'] ||
|
||||
req.connection.remoteAddress ||
|
||||
req.ip;
|
||||
|
||||
logger.warn('Admin auth failed - user not found or inactive', {
|
||||
ip: clientIp,
|
||||
userId: decoded.id,
|
||||
path: req.path,
|
||||
method: req.method,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
return res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
|
||||
req.admin = admin;
|
||||
next();
|
||||
} catch (error) {
|
||||
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
|
||||
req.headers['x-real-ip'] ||
|
||||
req.connection.remoteAddress ||
|
||||
req.ip;
|
||||
|
||||
logger.error('Admin auth middleware error', {
|
||||
ip: clientIp,
|
||||
path: req.path,
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,8 +109,9 @@ async function getRecipientLanguage(email, eventId = null) {
|
||||
|
||||
// Process email template with variables
|
||||
async function processTemplate(template, variables, language = 'en') {
|
||||
// Import date formatter
|
||||
// Import date formatter and text formatters
|
||||
const { formatDate } = require('../utils/dateFormatter');
|
||||
const { formatWelcomeMessage } = require('../utils/formatters');
|
||||
|
||||
// Get the appropriate language fields
|
||||
const subjectField = language === 'de' ? 'subject_de' : 'subject_en';
|
||||
@@ -142,6 +143,11 @@ async function processTemplate(template, variables, language = 'en') {
|
||||
if (processedVariables.archive_date) {
|
||||
processedVariables.archive_date = await formatDate(processedVariables.archive_date, language);
|
||||
}
|
||||
|
||||
// Format welcome message for HTML display (preserve line breaks)
|
||||
if (processedVariables.welcome_message) {
|
||||
processedVariables.welcome_message = formatWelcomeMessage(processedVariables.welcome_message);
|
||||
}
|
||||
|
||||
// Get branding settings for logo
|
||||
let logoUrl = '';
|
||||
|
||||
@@ -177,13 +177,33 @@ async function createRateLimiter() {
|
||||
return shouldSkipRateLimit(req, currentConfig);
|
||||
},
|
||||
handler: (req, res) => {
|
||||
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.ip;
|
||||
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
|
||||
req.headers['x-real-ip'] ||
|
||||
req.connection.remoteAddress ||
|
||||
req.ip;
|
||||
|
||||
// Enhanced logging for production analysis
|
||||
logger.warn('Rate limit exceeded', {
|
||||
ip: clientIp,
|
||||
path: req.path,
|
||||
method: req.method,
|
||||
authenticated: isAuthenticated(req),
|
||||
tokenType: req.tokenType
|
||||
tokenType: req.tokenType,
|
||||
userAgent: req.headers['user-agent'],
|
||||
referer: req.headers['referer'],
|
||||
origin: req.headers['origin'],
|
||||
timestamp: new Date().toISOString(),
|
||||
headers: {
|
||||
'x-forwarded-for': req.headers['x-forwarded-for'],
|
||||
'x-real-ip': req.headers['x-real-ip']
|
||||
},
|
||||
requestUrl: req.originalUrl,
|
||||
rateLimitInfo: {
|
||||
limit: req.rateLimit?.limit,
|
||||
current: req.rateLimit?.current,
|
||||
remaining: req.rateLimit?.remaining,
|
||||
resetTime: req.rateLimit?.resetTime ? new Date(req.rateLimit.resetTime).toISOString() : null
|
||||
}
|
||||
});
|
||||
|
||||
res.status(429).json({
|
||||
@@ -217,10 +237,30 @@ async function createAuthRateLimiter() {
|
||||
return !currentConfig.enabled;
|
||||
},
|
||||
handler: (req, res) => {
|
||||
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.ip;
|
||||
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
|
||||
req.headers['x-real-ip'] ||
|
||||
req.connection.remoteAddress ||
|
||||
req.ip;
|
||||
|
||||
// Enhanced logging for auth failures
|
||||
logger.warn('Auth rate limit exceeded', {
|
||||
ip: clientIp,
|
||||
path: req.path
|
||||
path: req.path,
|
||||
method: req.method,
|
||||
userAgent: req.headers['user-agent'],
|
||||
timestamp: new Date().toISOString(),
|
||||
headers: {
|
||||
'x-forwarded-for': req.headers['x-forwarded-for'],
|
||||
'x-real-ip': req.headers['x-real-ip'
|
||||
},
|
||||
requestUrl: req.originalUrl,
|
||||
authType: req.path.includes('admin') ? 'admin' : 'gallery',
|
||||
rateLimitInfo: {
|
||||
limit: req.rateLimit?.limit,
|
||||
current: req.rateLimit?.current,
|
||||
remaining: req.rateLimit?.remaining,
|
||||
resetTime: req.rateLimit?.resetTime ? new Date(req.rateLimit.resetTime).toISOString() : null
|
||||
}
|
||||
});
|
||||
|
||||
res.status(429).json({
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Formatters for email content and other text transformations
|
||||
*/
|
||||
|
||||
/**
|
||||
* Convert plain text line breaks to HTML line breaks
|
||||
* @param {string} text - The text to format
|
||||
* @returns {string} - Text with HTML line breaks
|
||||
*/
|
||||
function nl2br(text) {
|
||||
if (!text) return '';
|
||||
|
||||
// Normalize line endings
|
||||
text = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||
|
||||
// Convert newlines to <br> tags
|
||||
return text
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(line => line.length > 0)
|
||||
.join('<br />');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format welcome message for email templates
|
||||
* @param {string} message - The welcome message
|
||||
* @returns {string} - Formatted message for HTML emails
|
||||
*/
|
||||
function formatWelcomeMessage(message) {
|
||||
if (!message || message.trim() === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return nl2br(message);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
nl2br,
|
||||
formatWelcomeMessage
|
||||
};
|
||||
+77
-10
@@ -1,31 +1,98 @@
|
||||
const winston = require('winston');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
// Ensure logs directory exists
|
||||
const logDir = path.join(__dirname, '../../logs');
|
||||
if (!fs.existsSync(logDir)) {
|
||||
fs.mkdirSync(logDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Custom format for production logs
|
||||
const productionFormat = winston.format.combine(
|
||||
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss.SSS' }),
|
||||
winston.format.errors({ stack: true }),
|
||||
winston.format.json(),
|
||||
winston.format.printf(info => {
|
||||
// Ensure all security events are properly formatted
|
||||
if (info.level === 'warn' && (info.message.includes('rate limit') ||
|
||||
info.message.includes('auth') ||
|
||||
info.message.includes('login') ||
|
||||
info.message.includes('JWT'))) {
|
||||
return JSON.stringify({
|
||||
timestamp: info.timestamp,
|
||||
level: info.level,
|
||||
message: info.message,
|
||||
security: true,
|
||||
...info
|
||||
});
|
||||
}
|
||||
return JSON.stringify(info);
|
||||
})
|
||||
);
|
||||
|
||||
const logger = winston.createLogger({
|
||||
level: process.env.LOG_LEVEL || 'info',
|
||||
format: winston.format.combine(
|
||||
winston.format.timestamp(),
|
||||
winston.format.errors({ stack: true }),
|
||||
winston.format.json()
|
||||
),
|
||||
format: productionFormat,
|
||||
transports: [
|
||||
new winston.transports.File({
|
||||
filename: path.join(__dirname, '../../logs/error.log'),
|
||||
level: 'error'
|
||||
filename: path.join(logDir, 'error.log'),
|
||||
level: 'error',
|
||||
maxsize: 10 * 1024 * 1024, // 10MB
|
||||
maxFiles: 5,
|
||||
tailable: true
|
||||
}),
|
||||
new winston.transports.File({
|
||||
filename: path.join(__dirname, '../../logs/combined.log')
|
||||
filename: path.join(logDir, 'combined.log'),
|
||||
maxsize: 50 * 1024 * 1024, // 50MB
|
||||
maxFiles: 10,
|
||||
tailable: true
|
||||
}),
|
||||
// Separate security log for authentication and rate limiting
|
||||
new winston.transports.File({
|
||||
filename: path.join(logDir, 'security.log'),
|
||||
level: 'warn',
|
||||
maxsize: 20 * 1024 * 1024, // 20MB
|
||||
maxFiles: 10,
|
||||
tailable: true,
|
||||
format: winston.format.combine(
|
||||
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss.SSS' }),
|
||||
winston.format.json(),
|
||||
winston.format.printf(info => {
|
||||
// Only log security-related warnings
|
||||
if (info.message.includes('rate limit') ||
|
||||
info.message.includes('auth') ||
|
||||
info.message.includes('login') ||
|
||||
info.message.includes('JWT') ||
|
||||
info.message.includes('lockout') ||
|
||||
info.message.includes('suspicious')) {
|
||||
return JSON.stringify(info);
|
||||
}
|
||||
return null;
|
||||
})
|
||||
)
|
||||
})
|
||||
]
|
||||
].filter(Boolean)
|
||||
});
|
||||
|
||||
// Add console logging for non-production environments
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
logger.add(new winston.transports.Console({
|
||||
format: winston.format.combine(
|
||||
winston.format.colorize(),
|
||||
winston.format.simple()
|
||||
winston.format.timestamp({ format: 'HH:mm:ss' }),
|
||||
winston.format.printf(info => {
|
||||
return `[${info.timestamp}] ${info.level}: ${info.message} ${info.stack || ''}`;
|
||||
})
|
||||
)
|
||||
}));
|
||||
} else {
|
||||
// In production, also log to console for container environments
|
||||
if (process.env.LOG_TO_CONSOLE === 'true') {
|
||||
logger.add(new winston.transports.Console({
|
||||
format: productionFormat
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = logger;
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
SettingsPage,
|
||||
CMSPage
|
||||
} from './pages/admin';
|
||||
import { CMSPageEnhanced } from './pages/admin/CMSPageEnhanced';
|
||||
import { AdminLayout, AdminAuthWrapper } from './components/admin';
|
||||
import { PageErrorBoundary, OfflineIndicator, SkipLink, DynamicFavicon } from './components/common';
|
||||
import { MaintenanceWrapper } from './components/MaintenanceWrapper';
|
||||
@@ -109,7 +110,7 @@ function App() {
|
||||
<Route path="analytics" element={<AnalyticsPage />} />
|
||||
<Route path="branding" element={<BrandingPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="cms" element={<CMSPage />} />
|
||||
<Route path="cms" element={<CMSPageEnhanced />} />
|
||||
<Route index element={<Navigate to="/admin/dashboard" replace />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import React from 'react';
|
||||
import { HelpCircle } from 'lucide-react';
|
||||
|
||||
interface WelcomeMessageEditorProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
rows?: number;
|
||||
}
|
||||
|
||||
export const WelcomeMessageEditor: React.FC<WelcomeMessageEditorProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
rows = 6
|
||||
}) => {
|
||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
onChange(e.target.value);
|
||||
};
|
||||
|
||||
// Convert newlines to <br> tags for preview
|
||||
const getPreviewHtml = () => {
|
||||
return value
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(line => line.length > 0)
|
||||
.join('<br />');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="relative">
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 transition-colors resize-none font-mono text-sm"
|
||||
/>
|
||||
<div className="absolute top-2 right-2 text-neutral-400">
|
||||
<HelpCircle className="w-4 h-4" title="Line breaks will be preserved in emails" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-neutral-500">
|
||||
Tip: Press Enter to create a new line. Each line will appear as a separate paragraph in emails.
|
||||
</div>
|
||||
|
||||
{value && (
|
||||
<div className="mt-4">
|
||||
<p className="text-sm font-medium text-neutral-700 mb-2">Preview:</p>
|
||||
<div className="p-4 bg-neutral-50 rounded-lg border border-neutral-200">
|
||||
<div
|
||||
className="text-sm text-neutral-700 whitespace-pre-wrap"
|
||||
dangerouslySetInnerHTML={{ __html: getPreviewHtml() }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
WelcomeMessageEditor.displayName = 'WelcomeMessageEditor';
|
||||
@@ -8,6 +8,7 @@ export { PhotoUpload } from './PhotoUpload';
|
||||
export { CategoryManager } from './CategoryManager';
|
||||
export { EventCategoryManager } from './EventCategoryManager';
|
||||
export { CMSEditor } from './CMSEditor';
|
||||
export { WelcomeMessageEditor } from './WelcomeMessageEditor';
|
||||
export { BulkArchiveModal } from './BulkArchiveModal';
|
||||
export { MaintenanceBanner } from './MaintenanceBanner';
|
||||
export { EmailPreviewModal } from './EmailPreviewModal';
|
||||
|
||||
@@ -184,21 +184,12 @@ export const CMSPage: React.FC = () => {
|
||||
<CMSEditor
|
||||
content={editingLang === 'en' ? editForm.content_en || '' : editForm.content_de || ''}
|
||||
onChange={handleContentChange}
|
||||
onSave={handleSave}
|
||||
isSaving={updateMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end gap-3">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleSave}
|
||||
isLoading={updateMutation.isPending}
|
||||
leftIcon={<Save className="w-4 h-4" />}
|
||||
>
|
||||
{t('cms.saveChanges')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{currentPage?.updated_at && (
|
||||
<p className="text-xs text-neutral-500 mt-4">
|
||||
{t('cms.lastUpdated')} {new Date(currentPage.updated_at).toLocaleString()}
|
||||
|
||||
@@ -15,7 +15,7 @@ import { enUS, de } from 'date-fns/locale';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
import { Button, Input, Card } from '../../components/common';
|
||||
import { ThemeCustomizerEnhanced, GalleryPreview } from '../../components/admin';
|
||||
import { ThemeCustomizerEnhanced, GalleryPreview, WelcomeMessageEditor } from '../../components/admin';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { eventsService } from '../../services/events.service';
|
||||
import { categoriesService } from '../../services/categories.service';
|
||||
@@ -320,12 +320,11 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
{t('events.welcomeMessage')}
|
||||
</label>
|
||||
<textarea
|
||||
<WelcomeMessageEditor
|
||||
value={formData.welcome_message}
|
||||
onChange={handleInputChange('welcome_message')}
|
||||
onChange={(value) => setFormData(prev => ({ ...prev, welcome_message: value }))}
|
||||
placeholder={t('events.welcomeMessagePlaceholder')}
|
||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
rows={3}
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user