Implement comprehensive fixes for remaining critical issues

This commit addresses the remaining high-priority issues identified in the
comprehensive codebase analysis, implementing proper notification systems,
error handling, input validation, and race condition fixes.

1. CRITICAL: Implement Stripe webhook notifications
   - Created new NotificationService for centralized notification handling
   - Supports both in-app notifications and email queuing
   - Implemented all 4 missing webhook notifications:
     * Subscription downgrade notifications
     * Payment success receipts
     * Payment failure alerts with recovery action
     * Trial ending reminders with days remaining calculation
   - Notifications stored in database and emitted via Socket.io
   - File: src/backend/services/notificationService.js (NEW)
   - Updated: src/backend/routes/webhooks/stripeWebhook.js

2. HIGH: Add comprehensive error handling to socket event handlers
   - Wrapped all socket event handlers in try-catch blocks
   - Emit error events back to clients when operations fail
   - Prevents server crashes from unhandled socket errors
   - Provides user feedback for failed socket operations
   - File: src/backend/services/socketService.js

3. HIGH: Fix race condition in Chat component
   - Added activeConversationRef to track current conversation
   - Check conversation ID before updating messages after async load
   - Clear messages immediately when switching conversations
   - Prevents stale messages from appearing when rapidly switching
   - File: src/frontend/components/Chat/Chat.jsx

4. HIGH: Add input validation to messaging service
   - Validate userId and conversationId are valid strings
   - Ensure message content is not empty and under 10K chars
   - Validate contentType against allowed types
   - Validate metadata structure
   - Provides clear error messages for invalid input
   - File: src/backend/services/messagingService.js

5. MEDIUM: Replace hardcoded URLs with environment variables
   - Updated AuthContext to use VITE_API_URL env variable
   - Maintains localhost fallback for development
   - File: src/frontend/contexts/AuthContext.jsx

6. Documentation: Update .env.example
   - Added FRONTEND_URL configuration
   - Documented ALLOW_DEV_BYPASS security flag
   - Added critical warnings for TURN server configuration
   - Added Stripe configuration variables
   - File: .env.example

These fixes significantly improve:
- User experience (notifications for all payment events)
- System reliability (proper error handling, race condition fixes)
- Security (input validation prevents malicious input)
- Maintainability (proper environment configuration)
This commit is contained in:
Claude 2026-01-19 06:41:28 +00:00
parent 13d926a9c5
commit 1dcb357313
No known key found for this signature in database
7 changed files with 384 additions and 27 deletions

View file

@ -4,6 +4,11 @@ DATABASE_URL=postgresql://user:password@localhost:5432/aethex_passport
# Server Configuration # Server Configuration
PORT=3000 PORT=3000
NODE_ENV=development NODE_ENV=development
FRONTEND_URL=http://localhost:5173
# Development Security (ONLY for development, DO NOT enable in production)
# Allows bypassing authentication - requires BOTH NODE_ENV=development AND ALLOW_DEV_BYPASS=true
ALLOW_DEV_BYPASS=true
# Blockchain Configuration (for .aethex domain verification) # Blockchain Configuration (for .aethex domain verification)
RPC_ENDPOINT=https://polygon-mainnet.infura.io/v3/YOUR_INFURA_KEY RPC_ENDPOINT=https://polygon-mainnet.infura.io/v3/YOUR_INFURA_KEY
@ -16,7 +21,13 @@ JWT_SECRET=your-secret-key-here
RATE_LIMIT_WINDOW_MS=900000 RATE_LIMIT_WINDOW_MS=900000
RATE_LIMIT_MAX_REQUESTS=100 RATE_LIMIT_MAX_REQUESTS=100
# TURN Server Configuration (for WebRTC NAT traversal) # TURN Server Configuration (for WebRTC NAT traversal)
# CRITICAL: These MUST be set in production - no defaults allowed
TURN_SERVER_HOST=turn.example.com TURN_SERVER_HOST=turn.example.com
TURN_SERVER_PORT=3478 TURN_SERVER_PORT=3478
TURN_SECRET=your-turn-secret-key TURN_SECRET=your-turn-secret-key
TURN_TTL=86400 TURN_TTL=86400
# Stripe Configuration (for payments)
STRIPE_SECRET_KEY=sk_test_...
STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...

View file

@ -2,6 +2,7 @@ const express = require('express');
const router = express.Router(); const router = express.Router();
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY); const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
const db = require('../../database/db'); const db = require('../../database/db');
const notificationService = require('../../services/notificationService');
/** /**
* Stripe webhook endpoint * Stripe webhook endpoint
@ -143,8 +144,8 @@ async function handleSubscriptionDeleted(subscription) {
console.log(`[Webhook] User ${userId} downgraded to free tier`); console.log(`[Webhook] User ${userId} downgraded to free tier`);
// TODO: Send notification to user about downgrade // Send notification to user about downgrade
// Could trigger email, in-app notification, etc. await notificationService.notifySubscriptionDowngrade(userId);
} }
} }
@ -177,7 +178,13 @@ async function handlePaymentSucceeded(invoice) {
console.log(`[Webhook] Payment logged for user ${subResult.rows[0].user_id}`); console.log(`[Webhook] Payment logged for user ${subResult.rows[0].user_id}`);
// TODO: Send receipt email to user // Send receipt email to user
const amount = invoice.amount_paid / 100;
await notificationService.notifyPaymentSuccess(
subResult.rows[0].user_id,
amount,
invoice.id
);
} }
} }
} }
@ -213,8 +220,10 @@ async function handlePaymentFailed(invoice) {
console.log(`[Webhook] Failed payment logged for user ${userId}`); console.log(`[Webhook] Failed payment logged for user ${userId}`);
// TODO: Send notification to user about failed payment // Send notification to user about failed payment
// Include link to update payment method const amount = invoice.amount_due / 100;
const reason = invoice.last_payment_error?.message || 'Unknown error';
await notificationService.notifyPaymentFailed(userId, amount, reason);
} }
} }
} }
@ -232,9 +241,16 @@ async function handleTrialWillEnd(subscription) {
); );
if (subResult.rows.length > 0) { if (subResult.rows.length > 0) {
// TODO: Send notification to user that trial is ending const userId = subResult.rows[0].user_id;
// Remind them to add payment method
console.log(`[Webhook] Trial ending notification for user ${subResult.rows[0].user_id}`); // Calculate days remaining in trial
const trialEnd = new Date(subscription.trial_end * 1000);
const now = new Date();
const daysRemaining = Math.ceil((trialEnd - now) / (1000 * 60 * 60 * 24));
// Send notification to user that trial is ending
await notificationService.notifyTrialEnding(userId, daysRemaining);
console.log(`[Webhook] Trial ending notification sent to user ${userId}`);
} }
} }

View file

@ -263,9 +263,37 @@ class MessagingService {
* Send a message * Send a message
*/ */
async sendMessage(userId, conversationId, { content, contentType = 'text', metadata = null, replyToId = null, identityId = null }) { async sendMessage(userId, conversationId, { content, contentType = 'text', metadata = null, replyToId = null, identityId = null }) {
// Validate required parameters
if (!userId || typeof userId !== 'string') {
throw new Error('Invalid userId parameter');
}
if (!conversationId || typeof conversationId !== 'string') {
throw new Error('Invalid conversationId parameter');
}
if (!content || typeof content !== 'string' || content.trim().length === 0) {
throw new Error('Message content cannot be empty');
}
if (content.length > 10000) {
throw new Error('Message content exceeds maximum length of 10000 characters');
}
// Validate contentType
const validContentTypes = ['text', 'image', 'file', 'audio', 'video', 'code'];
if (!validContentTypes.includes(contentType)) {
throw new Error(`Invalid contentType. Must be one of: ${validContentTypes.join(', ')}`);
}
// Validate metadata if provided
if (metadata !== null && typeof metadata !== 'object') {
throw new Error('metadata must be an object or null');
}
// Verify user is participant // Verify user is participant
await this.verifyParticipant(conversationId, userId); await this.verifyParticipant(conversationId, userId);
// In production, content should already be encrypted by client // In production, content should already be encrypted by client
// For now, we'll just store it as-is // For now, we'll just store it as-is
const contentEncrypted = content; const contentEncrypted = content;

View file

@ -0,0 +1,223 @@
/**
* Notification Service
* Handles sending notifications to users via multiple channels
*/
const db = require('../database/db');
class NotificationService {
/**
* Send notification to user
* @param {string} userId - User ID to notify
* @param {Object} notification - Notification details
* @param {string} notification.type - Type of notification (email, in-app, both)
* @param {string} notification.category - Category (payment, subscription, system, etc.)
* @param {string} notification.title - Notification title
* @param {string} notification.message - Notification message
* @param {Object} notification.metadata - Additional metadata
*/
async sendNotification(userId, notification) {
const { type = 'in-app', category, title, message, metadata = {} } = notification;
try {
// Store notification in database
const result = await db.query(
`INSERT INTO notifications
(user_id, type, category, title, message, metadata, read, created_at)
VALUES ($1, $2, $3, $4, $5, $6, false, NOW())
RETURNING *`,
[userId, type, category, title, message, JSON.stringify(metadata)]
);
const savedNotification = result.rows[0];
// If email notification requested, queue email
if (type === 'email' || type === 'both') {
await this.queueEmail(userId, notification);
}
// Emit real-time notification via Socket.io if available
if (global.io) {
global.io.to(`user:${userId}`).emit('notification', {
id: savedNotification.id,
category,
title,
message,
metadata,
createdAt: savedNotification.created_at
});
}
return savedNotification;
} catch (error) {
console.error('Failed to send notification:', error);
// Don't throw - notifications should be best-effort
// Log error but don't fail the parent operation
return null;
}
}
/**
* Queue email for sending
* In production, this would integrate with an email service like SendGrid, SES, etc.
*/
async queueEmail(userId, notification) {
const { title, message, metadata = {} } = notification;
try {
// Get user email
const userResult = await db.query(
`SELECT email, username FROM users WHERE id = $1`,
[userId]
);
if (userResult.rows.length === 0) {
console.error(`User ${userId} not found for email notification`);
return;
}
const user = userResult.rows[0];
// Queue email in database (to be processed by email worker)
await db.query(
`INSERT INTO email_queue
(user_id, recipient_email, subject, body, template, metadata, status, created_at)
VALUES ($1, $2, $3, $4, $5, $6, 'pending', NOW())`,
[
userId,
user.email,
title,
message,
metadata.template || 'default',
JSON.stringify({ ...metadata, username: user.username })
]
);
console.log(`Email queued for ${user.email}: ${title}`);
} catch (error) {
console.error('Failed to queue email:', error);
}
}
/**
* Send subscription downgrade notification
*/
async notifySubscriptionDowngrade(userId) {
return this.sendNotification(userId, {
type: 'both',
category: 'subscription',
title: 'Subscription Cancelled',
message: 'Your premium subscription has been cancelled and your account has been downgraded to the free tier. You still have access to premium features until the end of your billing period.',
metadata: {
template: 'subscription-downgrade',
action: {
label: 'Resubscribe',
url: '/premium'
}
}
});
}
/**
* Send payment receipt
*/
async notifyPaymentSuccess(userId, amount, invoiceId) {
return this.sendNotification(userId, {
type: 'email',
category: 'payment',
title: 'Payment Receipt',
message: `Your payment of $${amount.toFixed(2)} has been processed successfully. Thank you for your continued subscription!`,
metadata: {
template: 'payment-receipt',
amount,
invoiceId,
action: {
label: 'View Invoice',
url: `/billing/invoices/${invoiceId}`
}
}
});
}
/**
* Send payment failure notification
*/
async notifyPaymentFailed(userId, amount, reason = 'Unknown') {
return this.sendNotification(userId, {
type: 'both',
category: 'payment',
title: 'Payment Failed',
message: `We were unable to process your payment of $${amount.toFixed(2)}. Please update your payment method to continue your subscription.`,
metadata: {
template: 'payment-failed',
amount,
reason,
action: {
label: 'Update Payment Method',
url: '/settings/billing'
}
}
});
}
/**
* Send trial ending notification
*/
async notifyTrialEnding(userId, daysRemaining = 3) {
return this.sendNotification(userId, {
type: 'both',
category: 'subscription',
title: 'Trial Ending Soon',
message: `Your premium trial will end in ${daysRemaining} days. Add a payment method to continue enjoying premium features without interruption.`,
metadata: {
template: 'trial-ending',
daysRemaining,
action: {
label: 'Add Payment Method',
url: '/settings/billing'
}
}
});
}
/**
* Get unread notifications for user
*/
async getUnreadNotifications(userId, limit = 50) {
const result = await db.query(
`SELECT * FROM notifications
WHERE user_id = $1 AND read = false
ORDER BY created_at DESC
LIMIT $2`,
[userId, limit]
);
return result.rows;
}
/**
* Mark notification as read
*/
async markAsRead(notificationId, userId) {
await db.query(
`UPDATE notifications
SET read = true, read_at = NOW()
WHERE id = $1 AND user_id = $2`,
[notificationId, userId]
);
}
/**
* Mark all notifications as read for user
*/
async markAllAsRead(userId) {
await db.query(
`UPDATE notifications
SET read = true, read_at = NOW()
WHERE user_id = $1 AND read = false`,
[userId]
);
}
}
module.exports = new NotificationService();

View file

@ -86,21 +86,88 @@ class SocketService {
// Join all conversations user is part of // Join all conversations user is part of
this.joinUserConversations(socket, userId); this.joinUserConversations(socket, userId);
// Handle events // Handle events with error handling
socket.on('join_conversation', (data) => this.handleJoinConversation(socket, data)); socket.on('join_conversation', async (data) => {
socket.on('leave_conversation', (data) => this.handleLeaveConversation(socket, data)); try {
socket.on('typing_start', (data) => this.handleTypingStart(socket, data)); await this.handleJoinConversation(socket, data);
socket.on('typing_stop', (data) => this.handleTypingStop(socket, data)); } catch (error) {
socket.on('call_signal', (data) => this.handleCallSignal(socket, data)); console.error('Error handling join_conversation:', error);
socket.emit('error', { event: 'join_conversation', message: error.message });
}
});
socket.on('leave_conversation', async (data) => {
try {
await this.handleLeaveConversation(socket, data);
} catch (error) {
console.error('Error handling leave_conversation:', error);
socket.emit('error', { event: 'leave_conversation', message: error.message });
}
});
socket.on('typing_start', async (data) => {
try {
await this.handleTypingStart(socket, data);
} catch (error) {
console.error('Error handling typing_start:', error);
socket.emit('error', { event: 'typing_start', message: error.message });
}
});
socket.on('typing_stop', async (data) => {
try {
await this.handleTypingStop(socket, data);
} catch (error) {
console.error('Error handling typing_stop:', error);
socket.emit('error', { event: 'typing_stop', message: error.message });
}
});
socket.on('call_signal', async (data) => {
try {
await this.handleCallSignal(socket, data);
} catch (error) {
console.error('Error handling call_signal:', error);
socket.emit('error', { event: 'call_signal', message: error.message });
}
});
// Call signaling events // Call signaling events
socket.on('call:offer', (data) => this.handleCallOffer(socket, data)); socket.on('call:offer', async (data) => {
socket.on('call:answer', (data) => this.handleCallAnswer(socket, data)); try {
socket.on('call:ice-candidate', (data) => this.handleIceCandidate(socket, data)); await this.handleCallOffer(socket, data);
} catch (error) {
console.error('Error handling call:offer:', error);
socket.emit('call:error', { type: 'offer', message: error.message });
}
});
socket.on('call:answer', async (data) => {
try {
await this.handleCallAnswer(socket, data);
} catch (error) {
console.error('Error handling call:answer:', error);
socket.emit('call:error', { type: 'answer', message: error.message });
}
});
socket.on('call:ice-candidate', async (data) => {
try {
await this.handleIceCandidate(socket, data);
} catch (error) {
console.error('Error handling call:ice-candidate:', error);
socket.emit('call:error', { type: 'ice-candidate', message: error.message });
}
});
// Disconnect handler // Disconnect handler
socket.on('disconnect', () => { socket.on('disconnect', async () => {
this.handleDisconnect(socket, userId); try {
await this.handleDisconnect(socket, userId);
} catch (error) {
console.error('Error handling disconnect:', error);
// Don't emit error as socket is disconnecting
}
}); });
} }

View file

@ -17,8 +17,9 @@ export default function Chat() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [typingUsers, setTypingUsers] = useState(new Set()); const [typingUsers, setTypingUsers] = useState(new Set());
const [error, setError] = useState(null); const [error, setError] = useState(null);
const typingTimeoutRef = useRef(null); const typingTimeoutRef = useRef(null);
const activeConversationRef = useRef(null);
// Load conversations on mount // Load conversations on mount
useEffect(() => { useEffect(() => {
@ -101,7 +102,9 @@ export default function Chat() {
const data = await response.json(); const data = await response.json();
if (data.success) { // Only update messages if this conversation is still active
// This prevents race conditions when rapidly switching conversations
if (data.success && activeConversationRef.current === conversationId) {
setMessages(data.messages); setMessages(data.messages);
// Mark as read // Mark as read
@ -116,8 +119,16 @@ export default function Chat() {
// Select conversation // Select conversation
const selectConversation = async (conversation) => { const selectConversation = async (conversation) => {
const conversationId = conversation.id;
// Update state and ref immediately to prevent race conditions
setActiveConversation(conversation); setActiveConversation(conversation);
await loadMessages(conversation.id); activeConversationRef.current = conversationId;
// Clear current messages while loading
setMessages([]);
await loadMessages(conversationId);
}; };
// Handle new message // Handle new message

View file

@ -33,7 +33,8 @@ export function AuthProvider({ children }) {
const login = async (email, password) => { const login = async (email, password) => {
// Mock login - in production, call actual API // Mock login - in production, call actual API
try { try {
const response = await fetch('http://localhost:3000/api/auth/login', { const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:3000';
const response = await fetch(`${apiUrl}/api/auth/login`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }) body: JSON.stringify({ email, password })