Refactor code and improve internationalization support
Some checks failed
Build & Push Docker Image / build (push) Has been cancelled

- Updated import statements to remove invisible characters.
- Standardized comments to use a consistent hyphen format.
- Adjusted username validation error messages for consistency.
- Enhanced email sending functions to include language support.
- Added email internationalization configuration for dynamic translations.
- Updated calendar and federation routes to include language in user queries.
- Improved user feedback messages in German and English for clarity.
This commit is contained in:
2026-03-02 16:14:54 +01:00
parent c2c10f9a4b
commit b5218046c9
15 changed files with 356 additions and 217 deletions

View File

@@ -1,4 +1,4 @@
import { Router } from 'express';
import { Router } from 'express';
import bcrypt from 'bcryptjs';
import { v4 as uuidv4 } from 'uuid';
import { getDb } from '../config/database.js';
@@ -26,7 +26,7 @@ router.post('/users', authenticateToken, requireAdmin, async (req, res) => {
const usernameRegex = /^[a-zA-Z0-9_-]{3,30}$/;
if (!usernameRegex.test(name)) {
return res.status(400).json({ error: 'Username may only contain letters, numbers, _ and - (330 chars)' });
return res.status(400).json({ error: 'Username may only contain letters, numbers, _ and - (3-30 chars)' });
}
if (password.length < 8) {
@@ -211,7 +211,7 @@ router.post('/invites', authenticateToken, requireAdmin, async (req, res) => {
if (isMailerConfigured()) {
try {
await sendInviteEmail(email.toLowerCase(), inviteUrl, appName);
await sendInviteEmail(email.toLowerCase(), inviteUrl, appName, 'en');
} catch (mailErr) {
log.admin.warn(`Invite email failed (non-fatal): ${mailErr.message}`);
}

View File

@@ -1,4 +1,4 @@
import { Router } from 'express';
import { Router } from 'express';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import { v4 as uuidv4 } from 'uuid';
@@ -37,7 +37,7 @@ const EMAIL_RE = /^[^\s@]{1,64}@[^\s@]{1,253}\.[^\s@]{2,}$/;
// Simple format check for theme/language IDs (actual validation happens on the frontend)
const SAFE_ID_RE = /^[a-zA-Z0-9_-]{1,50}$/;
// Allowlist for CSS color values only permits hsl(), hex (#rgb/#rrggbb) and plain names
// Allowlist for CSS color values - only permits hsl(), hex (#rgb/#rrggbb) and plain names
const SAFE_COLOR_RE = /^(?:#[0-9a-fA-F]{3,8}|hsl\(\d{1,3},\s*\d{1,3}%,\s*\d{1,3}%\)|[a-zA-Z]{1,30})$/;
const MIN_PASSWORD_LENGTH = 8;
@@ -145,7 +145,7 @@ router.post('/register', registerLimiter, async (req, res) => {
const usernameRegex = /^[a-zA-Z0-9_-]{3,30}$/;
if (!usernameRegex.test(username)) {
return res.status(400).json({ error: 'Username may only contain letters, numbers, _ and - (330 chars)' });
return res.status(400).json({ error: 'Username may only contain letters, numbers, _ and - (3-30 chars)' });
}
// M1: email format
@@ -200,7 +200,7 @@ router.post('/register', registerLimiter, async (req, res) => {
}
try {
await sendVerificationEmail(email.toLowerCase(), display_name, verifyUrl, appName);
await sendVerificationEmail(email.toLowerCase(), display_name, verifyUrl, appName, 'en');
} catch (mailErr) {
log.auth.error(`Verification mail failed: ${mailErr.message}`);
// Account is created but email failed — user can resend from login page
@@ -210,7 +210,7 @@ router.post('/register', registerLimiter, async (req, res) => {
return res.status(201).json({ needsVerification: true, message: 'Verification email has been sent' });
}
// No SMTP configured register and login immediately (legacy behaviour)
// No SMTP configured - register and login immediately (legacy behaviour)
const result = await db.run(
'INSERT INTO users (name, display_name, email, password_hash, email_verified) VALUES (?, ?, ?, ?, 1)',
[username, display_name, email.toLowerCase(), hash]
@@ -278,7 +278,7 @@ router.post('/resend-verification', resendVerificationLimiter, async (req, res)
}
const db = getDb();
const user = await db.get('SELECT id, name, display_name, email_verified, verification_resend_at FROM users WHERE email = ?', [email.toLowerCase()]);
const user = await db.get('SELECT id, name, display_name, language, email_verified, verification_resend_at FROM users WHERE email = ?', [email.toLowerCase()]);
if (!user || user.email_verified) {
// Don't reveal whether account exists
@@ -313,7 +313,7 @@ router.post('/resend-verification', resendVerificationLimiter, async (req, res)
}
try {
await sendVerificationEmail(email.toLowerCase(), user.display_name || user.name, verifyUrl, appName);
await sendVerificationEmail(email.toLowerCase(), user.display_name || user.name, verifyUrl, appName, user.language || 'en');
} catch (mailErr) {
log.auth.error(`Resend verification mail failed: ${mailErr.message}`);
return res.status(502).json({ error: 'Email could not be sent. Please check your SMTP configuration.' });
@@ -335,7 +335,7 @@ router.post('/login', loginLimiter, async (req, res) => {
return res.status(400).json({ error: 'Email and password are required' });
}
// M1: basic email format check invalid format can never match a real account
// M1: basic email format check - invalid format can never match a real account
if (!EMAIL_RE.test(email)) {
return res.status(401).json({ error: 'Invalid credentials' });
}
@@ -361,7 +361,7 @@ router.post('/login', loginLimiter, async (req, res) => {
}
});
// POST /api/auth/logout revoke JWT via DragonflyDB blacklist
// POST /api/auth/logout - revoke JWT via DragonflyDB blacklist
router.post('/logout', authenticateToken, async (req, res) => {
try {
const authHeader = req.headers.authorization;
@@ -432,7 +432,7 @@ router.put('/profile', authenticateToken, profileLimiter, async (req, res) => {
if (name && name !== req.user.name) {
const usernameRegex = /^[a-zA-Z0-9_-]{3,30}$/;
if (!usernameRegex.test(name)) {
return res.status(400).json({ error: 'Username may only contain letters, numbers, _ and - (330 chars)' });
return res.status(400).json({ error: 'Username may only contain letters, numbers, _ and - (3-30 chars)' });
}
const existingUsername = await db.get('SELECT id FROM users WHERE LOWER(name) = LOWER(?) AND id != ?', [name, req.user.id]);
if (existingUsername) {
@@ -504,7 +504,7 @@ router.post('/avatar', authenticateToken, avatarLimiter, async (req, res) => {
return res.status(400).json({ error: 'Only image files are allowed' });
}
// M15: stream-level size limit abort as soon as 2 MB is exceeded
// M15: stream-level size limit - abort as soon as 2 MB is exceeded
const MAX_AVATAR_SIZE = 2 * 1024 * 1024;
const buffer = await new Promise((resolve, reject) => {
const chunks = [];

View File

@@ -1,4 +1,4 @@
import { Router } from 'express';
import { Router } from 'express';
import crypto from 'crypto';
import { getDb } from '../config/database.js';
import { authenticateToken } from '../middleware/auth.js';
@@ -289,7 +289,7 @@ router.post('/events/:id/share', authenticateToken, async (req, res) => {
);
// Send notification email (fire-and-forget)
const targetUser = await db.get('SELECT name, display_name, email FROM users WHERE id = ?', [user_id]);
const targetUser = await db.get('SELECT name, display_name, email, language FROM users WHERE id = ?', [user_id]);
if (targetUser?.email) {
const appUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`;
const inboxUrl = `${appUrl}/federation/inbox`;
@@ -301,7 +301,7 @@ router.post('/events/:id/share', authenticateToken, async (req, res) => {
(targetUser.display_name && targetUser.display_name !== '') ? targetUser.display_name : targetUser.name,
fromDisplay,
event.title, event.start_time, event.end_time, event.description,
inboxUrl, appName
inboxUrl, appName, targetUser.language || 'en'
).catch(mailErr => {
log.server.warn('Calendar local share mail failed (non-fatal):', mailErr.message);
});
@@ -423,7 +423,7 @@ router.delete('/local-invitations/:id', authenticateToken, async (req, res) => {
if (inv.status === 'pending') {
await db.run("UPDATE calendar_local_invitations SET status = 'declined' WHERE id = ?", [inv.id]);
} else {
// Accepted/declined remove the share too if it was accepted
// Accepted/declined - remove the share too if it was accepted
if (inv.status === 'accepted') {
await db.run('DELETE FROM calendar_event_shares WHERE event_id = ? AND user_id = ?', [inv.event_id, req.user.id]);
}
@@ -587,7 +587,7 @@ router.post(['/receive-event', '/calendar-event'], calendarFederationLimiter, as
// Find local user
const { username } = parseAddress(to_user);
const db = getDb();
const targetUser = await db.get('SELECT id, name, email FROM users WHERE LOWER(name) = LOWER(?)', [username]);
const targetUser = await db.get('SELECT id, name, email, language FROM users WHERE LOWER(name) = LOWER(?)', [username]);
if (!targetUser) return res.status(404).json({ error: 'User not found on this instance' });
// Check duplicate (already in invitations or already accepted into calendar)
@@ -619,7 +619,7 @@ router.post(['/receive-event', '/calendar-event'], calendarFederationLimiter, as
sendCalendarInviteEmail(
targetUser.email, targetUser.name, from_user,
title, start_time, end_time, description || null,
inboxUrl, appName
inboxUrl, appName, targetUser.language || 'en'
).catch(mailErr => {
log.server.warn('Calendar invite mail failed (non-fatal):', mailErr.message);
});

View File

@@ -1,4 +1,4 @@
import { Router } from 'express';
import { Router } from 'express';
import { v4 as uuidv4 } from 'uuid';
import { rateLimit } from 'express-rate-limit';
import { getDb } from '../config/database.js';
@@ -220,14 +220,14 @@ router.post('/receive', federationReceiveLimiter, async (req, res) => {
} catch { /* column may not exist on very old installs */ }
}
// Send notification email (truly fire-and-forget never blocks the response)
// Send notification email (truly fire-and-forget - never blocks the response)
if (targetUser.email) {
const appUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`;
const inboxUrl = `${appUrl}/federation/inbox`;
const appName = process.env.APP_NAME || 'Redlight';
sendFederationInviteEmail(
targetUser.email, targetUser.name, from_user,
room_name, message || null, inboxUrl, appName
room_name, message || null, inboxUrl, appName, targetUser.language || 'en'
).catch(mailErr => {
log.federation.warn('Federation invite mail failed (non-fatal):', mailErr.message);
});
@@ -559,7 +559,7 @@ router.post('/calendar-event-deleted', federationReceiveLimiter, async (req, res
try {
// Users with pending/declined invitations
const invUsers = await db.all(
`SELECT u.email, u.name, ci.title, ci.from_user
`SELECT u.email, u.name, u.language, ci.title, ci.from_user
FROM calendar_invitations ci
JOIN users u ON ci.to_user_id = u.id
WHERE ci.event_uid = ? AND ci.from_user LIKE ?`,
@@ -567,7 +567,7 @@ router.post('/calendar-event-deleted', federationReceiveLimiter, async (req, res
);
// Users who already accepted (event in their calendar)
const calUsers = await db.all(
`SELECT u.email, u.name, ce.title, ce.federated_from AS from_user
`SELECT u.email, u.name, u.language, ce.title, ce.federated_from AS from_user
FROM calendar_events ce
JOIN users u ON ce.user_id = u.id
WHERE ce.uid = ? AND ce.federated_from LIKE ?`,
@@ -603,7 +603,7 @@ router.post('/calendar-event-deleted', federationReceiveLimiter, async (req, res
if (affectedUsers.length > 0) {
const appName = process.env.APP_NAME || 'Redlight';
for (const u of affectedUsers) {
sendCalendarEventDeletedEmail(u.email, u.name, u.from_user, u.title, appName)
sendCalendarEventDeletedEmail(u.email, u.name, u.from_user, u.title, appName, u.language || 'en')
.catch(mailErr => {
log.federation.warn(`Calendar deletion mail to ${u.email} failed (non-fatal): ${mailErr.message}`);
});

View File

@@ -1,4 +1,4 @@
import { Router } from 'express';
import { Router } from 'express';
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
@@ -648,7 +648,7 @@ router.post('/:uid/presentation', authenticateToken, async (req, res) => {
const room = await db.get('SELECT * FROM rooms WHERE uid = ? AND user_id = ?', [req.params.uid, req.user.id]);
if (!room) return res.status(404).json({ error: 'Room not found or no permission' });
// M16: stream-level size limit abort as soon as 50 MB is exceeded
// M16: stream-level size limit - abort as soon as 50 MB is exceeded
const MAX_PRESENTATION_SIZE = 50 * 1024 * 1024;
const buffer = await new Promise((resolve, reject) => {
const chunks = [];