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

216
README.md
View File

@@ -1,4 +1,4 @@
# 🔴 Redlight # 🔴 Redlight
A modern, self-hosted BigBlueButton frontend with beautiful themes, federation, and powerful features. A modern, self-hosted BigBlueButton frontend with beautiful themes, federation, and powerful features.
@@ -10,52 +10,52 @@ A modern, self-hosted BigBlueButton frontend with beautiful themes, federation,
## ✨ Features ## ✨ Features
### Core Features ### Core Features
- 🎥 **Video Conferencing** Integrated BigBlueButton support for professional video meetings - 🎥 **Video Conferencing** - Integrated BigBlueButton support for professional video meetings
- 🎨 **15+ Themes** Dracula, Nord, Catppuccin, Rosé Pine, Gruvbox, and more - 🎨 **15+ Themes** - Dracula, Nord, Catppuccin, Rosé Pine, Gruvbox, and more
- 📝 **Room Management** Create unlimited rooms with custom settings, access codes, and moderator codes - 📝 **Room Management** - Create unlimited rooms with custom settings, access codes, and moderator codes
- 🔐 **User Management** Registration, login, role-based access control (Admin/User) - 🔐 **User Management** - Registration, login, role-based access control (Admin/User)
- 📹 **Recording Management** View, publish, and delete meeting recordings per room - 📹 **Recording Management** - View, publish, and delete meeting recordings per room
- 🌍 **Multi-Language Support** German (Deutsch) and English built-in, easily extensible - 🌍 **Multi-Language Support** - German (Deutsch) and English built-in, easily extensible
- ✉️ **Email Verification** Optional SMTP-based email verification for user registration - ✉️ **Email Verification** - Optional SMTP-based email verification for user registration
- 👤 **User Profiles** Customizable avatars, themes, and language preferences - 👤 **User Profiles** - Customizable avatars, themes, and language preferences
- 📱 **Responsive Design** Works seamlessly on mobile, tablet, and desktop - 📱 **Responsive Design** - Works seamlessly on mobile, tablet, and desktop
- 🌐 **Federation** Invite users from remote Redlight instances via Ed25519-signed messages - 🌐 **Federation** - Invite users from remote Redlight instances via Ed25519-signed messages
- 🐉 **DragonflyDB / Redis** JWT blacklisting for secure token revocation on logout - 🐉 **DragonflyDB / Redis** - JWT blacklisting for secure token revocation on logout
### Admin Features ### Admin Features
- 👥 **User Administration** Manage users and roles - 👥 **User Administration** - Manage users and roles
- 🏢 **Branding Customization** Custom app name, logos, and default theme - 🏢 **Branding Customization** - Custom app name, logos, and default theme
- 📊 **Dashboard** Overview of system statistics - 📊 **Dashboard** - Overview of system statistics
- 🔧 **Settings Management** System-wide configuration - 🔧 **Settings Management** - System-wide configuration
### Room Features ### Room Features
- 🔑 **Access Codes** Restrict room access with optional passwords - 🔑 **Access Codes** - Restrict room access with optional passwords
- 🔐 **Moderator Codes** Separate code to grant moderator privileges - 🔐 **Moderator Codes** - Separate code to grant moderator privileges
- 🚪 **Guest Access** Allow unauthenticated users to join meetings (rate-limited) - 🚪 **Guest Access** - Allow unauthenticated users to join meetings (rate-limited)
- ⏱️ **Max Participants** Set limits on concurrent participants - ⏱️ **Max Participants** - Set limits on concurrent participants
- 🎤 **Mute on Join** Automatically mute new participants - 🎤 **Mute on Join** - Automatically mute new participants
-**Approval Mode** Require moderator approval for participants -**Approval Mode** - Require moderator approval for participants
- 🎙️ **Anyone Can Start** Allow participants to start the meeting - 🎙️ **Anyone Can Start** - Allow participants to start the meeting
- 📹 **Recording Settings** Control whether meetings are recorded - 📹 **Recording Settings** - Control whether meetings are recorded
- 📊 **Presentation Upload** Upload PDF, PPTX, ODP, or image files as default slides - 📊 **Presentation Upload** - Upload PDF, PPTX, ODP, or image files as default slides
- 🤝 **Room Sharing** Share rooms with other registered users - 🤝 **Room Sharing** - Share rooms with other registered users
### Security ### Security
- 🛡️ **Comprehensive Rate Limiting** Login, register, profile, avatar, guest-join, and federation endpoints - 🛡️ **Comprehensive Rate Limiting** - Login, register, profile, avatar, guest-join, and federation endpoints
- 🔒 **Input Validation** Email format, field length limits, ID format checks, color format validation - 🔒 **Input Validation** - Email format, field length limits, ID format checks, color format validation
- 🕐 **Timing-Safe Comparisons** Access codes and moderator codes compared with `crypto.timingSafeEqual` - 🕐 **Timing-Safe Comparisons** - Access codes and moderator codes compared with `crypto.timingSafeEqual`
- 📏 **Streaming Upload Limits** Avatar (5 MB) and presentation (50 MB) uploads reject early without buffering - 📏 **Streaming Upload Limits** - Avatar (5 MB) and presentation (50 MB) uploads reject early without buffering
- 🧹 **XSS Prevention** HTML-escaped emails, XML-escaped BBB parameters, SVG sanitization - 🧹 **XSS Prevention** - HTML-escaped emails, XML-escaped BBB parameters, SVG sanitization
- 🔐 **JWT Blacklist** Token revocation via DragonflyDB/Redis on logout - 🔐 **JWT Blacklist** - Token revocation via DragonflyDB/Redis on logout
- 🌐 **CORS Restriction** Locked to `APP_URL` in production - 🌐 **CORS Restriction** - Locked to `APP_URL` in production
- ⚙️ **Configurable Trust Proxy** `TRUST_PROXY` env var for reverse proxy setups - ⚙️ **Configurable Trust Proxy** - `TRUST_PROXY` env var for reverse proxy setups
### Developer Features ### Developer Features
- 🐳 **Docker Support** Easy deployment with Docker Compose (includes PostgreSQL + DragonflyDB) - 🐳 **Docker Support** - Easy deployment with Docker Compose (includes PostgreSQL + DragonflyDB)
- 🗄️ **Database Flexibility** SQLite (default) or PostgreSQL support - 🗄️ **Database Flexibility** - SQLite (default) or PostgreSQL support
- 🔌 **REST API** Comprehensive API for custom integrations - 🔌 **REST API** - Comprehensive API for custom integrations
- 📦 **Open Source** Full source code transparency - 📦 **Open Source** - Full source code transparency
- 🛠️ **Self-Hosted** Complete data privacy and control - 🛠️ **Self-Hosted** - Complete data privacy and control
--- ---
@@ -103,7 +103,7 @@ A modern, self-hosted BigBlueButton frontend with beautiful themes, federation,
```env ```env
BBB_URL=https://your-bbb-server.com/bigbluebutton/api/ BBB_URL=https://your-bbb-server.com/bigbluebutton/api/
BBB_SECRET=your-bbb-shared-secret BBB_SECRET=your-bbb-shared-secret
JWT_SECRET=your-secret-key # REQUIRED app won't start without this JWT_SECRET=your-secret-key # REQUIRED - app won't start without this
APP_URL=https://your-domain.com # Used for CORS and email links APP_URL=https://your-domain.com # Used for CORS and email links
DATABASE_URL=postgres://user:password@postgres:5432/redlight DATABASE_URL=postgres://user:password@postgres:5432/redlight
@@ -165,7 +165,7 @@ A modern, self-hosted BigBlueButton frontend with beautiful themes, federation,
- **Frontend**: React 18, Tailwind CSS, React Router, Lucide Icons - **Frontend**: React 18, Tailwind CSS, React Router, Lucide Icons
- **Backend**: Node.js 20, Express, JWT, Bcrypt - **Backend**: Node.js 20, Express, JWT, Bcrypt
- **Database**: SQLite / PostgreSQL with better-sqlite3 / pg - **Database**: SQLite / PostgreSQL with better-sqlite3 / pg
- **Cache**: DragonflyDB / Redis (ioredis) JWT blacklisting - **Cache**: DragonflyDB / Redis (ioredis) - JWT blacklisting
- **Email**: Nodemailer - **Email**: Nodemailer
- **Build**: Vite - **Build**: Vite
@@ -199,77 +199,77 @@ redlight/
## 🔐 Security ## 🔐 Security
- **JWT Authentication** Secure token-based auth with 7-day expiration and `jti`-based blacklisting via DragonflyDB/Redis - **JWT Authentication** - Secure token-based auth with 7-day expiration and `jti`-based blacklisting via DragonflyDB/Redis
- **Mandatory JWT Secret** Server refuses to start without a `JWT_SECRET` env var - **Mandatory JWT Secret** - Server refuses to start without a `JWT_SECRET` env var
- **HTTPS Ready** Configure behind reverse proxy (nginx, Caddy); trust proxy via `TRUST_PROXY` env - **HTTPS Ready** - Configure behind reverse proxy (nginx, Caddy); trust proxy via `TRUST_PROXY` env
- **Password Hashing** bcryptjs with salt rounds 12, minimum 8-character passwords - **Password Hashing** - bcryptjs with salt rounds 12, minimum 8-character passwords
- **Email Verification** Optional SMTP-based email verification with resend support - **Email Verification** - Optional SMTP-based email verification with resend support
- **CORS Protection** Restricted to `APP_URL` in production, open in development - **CORS Protection** - Restricted to `APP_URL` in production, open in development
- **Rate Limiting** Login, register, profile, password, avatar, guest-join, and federation endpoints - **Rate Limiting** - Login, register, profile, password, avatar, guest-join, and federation endpoints
- **Input Validation** Email regex, field length limits, ID format checks, hex-color format checks - **Input Validation** - Email regex, field length limits, ID format checks, hex-color format checks
- **Timing-Safe Comparisons** Access codes and moderator codes compared via `crypto.timingSafeEqual` - **Timing-Safe Comparisons** - Access codes and moderator codes compared via `crypto.timingSafeEqual`
- **Upload Safety** Streaming body size limits (avatar 5 MB, presentation 50 MB) abort early without buffering - **Upload Safety** - Streaming body size limits (avatar 5 MB, presentation 50 MB) abort early without buffering
- **XSS / Injection Prevention** HTML-escaped emails, XML-escaped BBB API parameters, SVG logos served as `attachment` - **XSS / Injection Prevention** - HTML-escaped emails, XML-escaped BBB API parameters, SVG logos served as `attachment`
- **Admin Isolation** Role-based access control with strict admin checks - **Admin Isolation** - Role-based access control with strict admin checks
--- ---
## 📦 API Endpoints ## 📦 API Endpoints
### Authentication ### Authentication
- `POST /api/auth/register` Register new user - `POST /api/auth/register` - Register new user
- `POST /api/auth/login` Login user - `POST /api/auth/login` - Login user
- `POST /api/auth/logout` Logout (blacklists JWT) - `POST /api/auth/logout` - Logout (blacklists JWT)
- `GET /api/auth/verify-email?token=...` Verify email with token - `GET /api/auth/verify-email?token=...` - Verify email with token
- `POST /api/auth/resend-verification` Resend verification email - `POST /api/auth/resend-verification` - Resend verification email
- `GET /api/auth/me` Get current user info - `GET /api/auth/me` - Get current user info
- `PUT /api/auth/profile` Update profile (theme, language, display name) - `PUT /api/auth/profile` - Update profile (theme, language, display name)
- `PUT /api/auth/password` Change password - `PUT /api/auth/password` - Change password
- `POST /api/auth/avatar` Upload avatar image - `POST /api/auth/avatar` - Upload avatar image
- `DELETE /api/auth/avatar` Remove avatar image - `DELETE /api/auth/avatar` - Remove avatar image
### Rooms ### Rooms
- `GET /api/rooms` List user's rooms (owned + shared) - `GET /api/rooms` - List user's rooms (owned + shared)
- `POST /api/rooms` Create new room - `POST /api/rooms` - Create new room
- `GET /api/rooms/:uid` Get room details - `GET /api/rooms/:uid` - Get room details
- `PUT /api/rooms/:uid` Update room - `PUT /api/rooms/:uid` - Update room
- `DELETE /api/rooms/:uid` Delete room - `DELETE /api/rooms/:uid` - Delete room
- `POST /api/rooms/:uid/start` Start meeting - `POST /api/rooms/:uid/start` - Start meeting
- `POST /api/rooms/:uid/join` Join meeting as authenticated user - `POST /api/rooms/:uid/join` - Join meeting as authenticated user
- `POST /api/rooms/:uid/guest-join` Join meeting as guest (rate-limited) - `POST /api/rooms/:uid/guest-join` - Join meeting as guest (rate-limited)
- `POST /api/rooms/:uid/end` End meeting - `POST /api/rooms/:uid/end` - End meeting
- `GET /api/rooms/:uid/running` Check if meeting is running - `GET /api/rooms/:uid/running` - Check if meeting is running
- `GET /api/rooms/:uid/shares` List shared users - `GET /api/rooms/:uid/shares` - List shared users
- `POST /api/rooms/:uid/shares` Share room with user - `POST /api/rooms/:uid/shares` - Share room with user
- `DELETE /api/rooms/:uid/shares/:userId` Remove share - `DELETE /api/rooms/:uid/shares/:userId` - Remove share
- `POST /api/rooms/:uid/presentation` Upload default presentation (PDF, PPTX, ODP, images) - `POST /api/rooms/:uid/presentation` - Upload default presentation (PDF, PPTX, ODP, images)
- `DELETE /api/rooms/:uid/presentation` Remove presentation - `DELETE /api/rooms/:uid/presentation` - Remove presentation
### Recordings ### Recordings
- `GET /api/recordings/:roomUid` List room recordings - `GET /api/recordings/:roomUid` - List room recordings
- `PUT /api/recordings/:recordingId` Publish/unpublish recording - `PUT /api/recordings/:recordingId` - Publish/unpublish recording
- `DELETE /api/recordings/:recordingId` Delete recording - `DELETE /api/recordings/:recordingId` - Delete recording
### Admin ### Admin
- `GET /api/admin/users` List all users - `GET /api/admin/users` - List all users
- `GET /api/admin/stats` System statistics - `GET /api/admin/stats` - System statistics
- `POST /api/admin/users` Create user (admin) - `POST /api/admin/users` - Create user (admin)
- `PUT /api/admin/users/:id` Update user - `PUT /api/admin/users/:id` - Update user
- `DELETE /api/admin/users/:id` Delete user - `DELETE /api/admin/users/:id` - Delete user
### Branding ### Branding
- `GET /api/branding` Get branding settings - `GET /api/branding` - Get branding settings
- `PUT /api/branding` Update branding (admin only) - `PUT /api/branding` - Update branding (admin only)
- `POST /api/branding/logo` Upload custom logo - `POST /api/branding/logo` - Upload custom logo
- `DELETE /api/branding/logo` Remove custom logo - `DELETE /api/branding/logo` - Remove custom logo
### Federation ### Federation
- `GET /.well-known/redlight` Instance discovery (domain, public key) - `GET /.well-known/redlight` - Instance discovery (domain, public key)
- `POST /api/federation/invite` Send invitation to remote user - `POST /api/federation/invite` - Send invitation to remote user
- `POST /api/federation/receive` Receive invitation from remote instance (rate-limited) - `POST /api/federation/receive` - Receive invitation from remote instance (rate-limited)
- `GET /api/federation/invitations` List received invitations - `GET /api/federation/invitations` - List received invitations
- `PUT /api/federation/invitations/:id` Accept / decline invitation - `PUT /api/federation/invitations/:id` - Accept / decline invitation
- `DELETE /api/federation/invitations/:id` Delete invitation - `DELETE /api/federation/invitations/:id` - Delete invitation
--- ---
@@ -313,26 +313,26 @@ docker-compose up -d
``` ```
Services: Services:
- **redlight** Node.js application - **redlight** - Node.js application
- **postgres** PostgreSQL database - **postgres** - PostgreSQL database
- **dragonfly** DragonflyDB (Redis-compatible) for JWT blacklisting - **dragonfly** - DragonflyDB (Redis-compatible) for JWT blacklisting
### Environment Variables ### Environment Variables
| Variable | Required | Default | Description | | Variable | Required | Default | Description |
|----------|----------|---------|-------------| |----------|----------|---------|-------------|
| `BBB_URL` | Yes | | BigBlueButton API URL | | `BBB_URL` | Yes | - | BigBlueButton API URL |
| `BBB_SECRET` | Yes | | BigBlueButton shared secret | | `BBB_SECRET` | Yes | - | BigBlueButton shared secret |
| `JWT_SECRET` | Yes | | Secret for signing JWTs (server won't start without it) | | `JWT_SECRET` | Yes | - | Secret for signing JWTs (server won't start without it) |
| `APP_URL` | Recommended | | Public URL of the app (used for CORS + email links) | | `APP_URL` | Recommended | - | Public URL of the app (used for CORS + email links) |
| `DATABASE_URL` | No | SQLite | PostgreSQL connection string | | `DATABASE_URL` | No | SQLite | PostgreSQL connection string |
| `REDIS_URL` | No | `redis://localhost:6379` | DragonflyDB / Redis URL | | `REDIS_URL` | No | `redis://localhost:6379` | DragonflyDB / Redis URL |
| `TRUST_PROXY` | No | `loopback` | Express trust proxy setting (number or string) | | `TRUST_PROXY` | No | `loopback` | Express trust proxy setting (number or string) |
| `SMTP_HOST` | No | | SMTP server for email verification | | `SMTP_HOST` | No | - | SMTP server for email verification |
| `SMTP_PORT` | No | `587` | SMTP port | | `SMTP_PORT` | No | `587` | SMTP port |
| `SMTP_USER` | No | | SMTP username | | `SMTP_USER` | No | - | SMTP username |
| `SMTP_PASS` | No | | SMTP password | | `SMTP_PASS` | No | - | SMTP password |
| `FEDERATION_DOMAIN` | No | | Domain for federation (enables cross-instance invites) | | `FEDERATION_DOMAIN` | No | - | Domain for federation (enables cross-instance invites) |
### Production Deployment ### Production Deployment
@@ -419,7 +419,7 @@ curl "https://your-bbb-server/bigbluebutton/api/getMeetings?checksum=..."
## 📝 License ## 📝 License
This project is licensed under the MIT License see [LICENSE](LICENSE) file for details. This project is licensed under the MIT License - see [LICENSE](LICENSE) file for details.
--- ---

View File

@@ -1,4 +1,4 @@
import crypto from 'crypto'; import crypto from 'crypto';
import xml2js from 'xml2js'; import xml2js from 'xml2js';
import { log, fmtDuration, fmtStatus, fmtMethod, fmtReturncode, sanitizeBBBParams } from './logger.js'; import { log, fmtDuration, fmtStatus, fmtMethod, fmtReturncode, sanitizeBBBParams } from './logger.js';
@@ -98,7 +98,7 @@ export async function createMeeting(room, logoutURL, loginURL = null, presentati
params.lockSettingsLockOnJoin = 'true'; params.lockSettingsLockOnJoin = 'true';
} }
// Build optional presentation XML body escape URL to prevent XML injection // Build optional presentation XML body - escape URL to prevent XML injection
let xmlBody = null; let xmlBody = null;
if (presentationUrl) { if (presentationUrl) {
const safeUrl = presentationUrl const safeUrl = presentationUrl

View File

@@ -1,4 +1,4 @@
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import path from 'path'; import path from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { log } from './logger.js'; import { log } from './logger.js';
@@ -106,7 +106,7 @@ class PostgresAdapter {
// ── Public API ────────────────────────────────────────────────────────────── // ── Public API ──────────────────────────────────────────────────────────────
export function getDb() { export function getDb() {
if (!db) { if (!db) {
throw new Error('Database not initialised call initDatabase() first'); throw new Error('Database not initialised - call initDatabase() first');
} }
return db; return db;
} }

View File

@@ -0,0 +1,52 @@
import { createRequire } from 'module';
import { fileURLToPath } from 'url';
import path from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const require = createRequire(import.meta.url);
const cache = {};
function load(lang) {
if (cache[lang]) return cache[lang];
try {
cache[lang] = require(path.resolve(__dirname, '../../src/i18n', `${lang}.json`));
return cache[lang];
} catch {
if (lang !== 'en') return load('en');
cache[lang] = {};
return cache[lang];
}
}
/**
* Translate a dot-separated key for the given language.
* Interpolates {placeholder} tokens from params.
* Unresolved tokens are left as-is so callers can do HTML substitution afterwards.
*
* @param {string} lang Language code, e.g. 'en', 'de'
* @param {string} keyPath Dot-separated key, e.g. 'email.verify.subject'
* @param {Record<string,string>} [params] Values to interpolate
* @returns {string}
*/
export function t(lang, keyPath, params = {}) {
const keys = keyPath.split('.');
function resolve(dict) {
let val = dict;
for (const k of keys) {
val = val?.[k];
}
return typeof val === 'string' ? val : undefined;
}
let value = resolve(load(lang));
// Fallback to English
if (value === undefined) value = resolve(load('en'));
if (value === undefined) return keyPath;
return value.replace(/\{(\w+)\}/g, (match, k) =>
params[k] !== undefined ? String(params[k]) : match
);
}

View File

@@ -1,5 +1,6 @@
import nodemailer from 'nodemailer'; import nodemailer from 'nodemailer';
import { log } from './logger.js'; import { log } from './logger.js';
import { t } from './emaili18n.js';
let transporter; let transporter;
@@ -21,7 +22,7 @@ export function initMailer() {
const pass = process.env.SMTP_PASS; const pass = process.env.SMTP_PASS;
if (!host || !user || !pass) { if (!host || !user || !pass) {
log.mailer.warn('SMTP not configured email verification disabled'); log.mailer.warn('SMTP not configured - email verification disabled');
return false; return false;
} }
@@ -45,17 +46,17 @@ export function isMailerConfigured() {
/** /**
* Send the verification email with a clickable link. * Send the verification email with a clickable link.
* @param {string} to recipient email * @param {string} to - recipient email
* @param {string} name user's display name * @param {string} name - user's display name
* @param {string} verifyUrl full verification URL * @param {string} verifyUrl - full verification URL
* @param {string} appName branding app name (default "Redlight") * @param {string} appName - branding app name (default "Redlight")
*/ */
// S3: sanitize name for use in email From header (strip quotes, newlines, control chars) // S3: sanitize name for use in email From header (strip quotes, newlines, control chars)
function sanitizeHeaderValue(str) { function sanitizeHeaderValue(str) {
return String(str).replace(/["\\\r\n\x00-\x1f]/g, '').trim().slice(0, 100); return String(str).replace(/["\\\r\n\x00-\x1f]/g, '').trim().slice(0, 100);
} }
export async function sendVerificationEmail(to, name, verifyUrl, appName = 'Redlight') { export async function sendVerificationEmail(to, name, verifyUrl, appName = 'Redlight', lang = 'en') {
if (!transporter) { if (!transporter) {
throw new Error('SMTP not configured'); throw new Error('SMTP not configured');
} }
@@ -68,41 +69,41 @@ export async function sendVerificationEmail(to, name, verifyUrl, appName = 'Redl
await transporter.sendMail({ await transporter.sendMail({
from: `"${headerAppName}" <${from}>`, from: `"${headerAppName}" <${from}>`,
to, to,
subject: `${headerAppName} Verify your email`, subject: t(lang, 'email.verify.subject', { appName: headerAppName }),
html: ` html: `
<div style="font-family:Arial,sans-serif;max-width:520px;margin:0 auto;padding:32px;background:#1e1e2e;color:#cdd6f4;border-radius:12px;"> <div style="font-family:Arial,sans-serif;max-width:520px;margin:0 auto;padding:32px;background:#1e1e2e;color:#cdd6f4;border-radius:12px;">
<h2 style="color:#cba6f7;margin-top:0;">Hey ${safeName} 👋</h2> <h2 style="color:#cba6f7;margin-top:0;">${t(lang, 'email.greeting', { name: safeName })}</h2>
<p>Please verify your email address by clicking the button below:</p> <p>${t(lang, 'email.verify.intro')}</p>
<p style="text-align:center;margin:28px 0;"> <p style="text-align:center;margin:28px 0;">
<a href="${verifyUrl}" <a href="${verifyUrl}"
style="display:inline-block;background:#cba6f7;color:#1e1e2e;padding:12px 32px;border-radius:8px;text-decoration:none;font-weight:bold;"> style="display:inline-block;background:#cba6f7;color:#1e1e2e;padding:12px 32px;border-radius:8px;text-decoration:none;font-weight:bold;">
Verify Email ${t(lang, 'email.verify.button')}
</a> </a>
</p> </p>
<p style="font-size:13px;color:#7f849c;"> <p style="font-size:13px;color:#7f849c;">
Or copy this link in your browser:<br/> ${t(lang, 'email.linkHint')}<br/>
<a href="${verifyUrl}" style="color:#89b4fa;word-break:break-all;">${escapeHtml(verifyUrl)}</a> <a href="${verifyUrl}" style="color:#89b4fa;word-break:break-all;">${escapeHtml(verifyUrl)}</a>
</p> </p>
<p style="font-size:13px;color:#7f849c;">This link is valid for 24 hours.</p> <p style="font-size:13px;color:#7f849c;">${t(lang, 'email.verify.validity')}</p>
<hr style="border:none;border-top:1px solid #313244;margin:24px 0;"/> <hr style="border:none;border-top:1px solid #313244;margin:24px 0;"/>
<p style="font-size:12px;color:#585b70;">If you didn't register, please ignore this email.</p> <p style="font-size:12px;color:#585b70;">${t(lang, 'email.verify.footer')}</p>
</div> </div>
`, `,
text: `Hey ${name},\n\nPlease verify your email: ${verifyUrl}\n\nThis link is valid for 24 hours.\n\n ${appName}`, text: `${t(lang, 'email.greeting', { name })}\n\n${t(lang, 'email.verify.intro')}\n${verifyUrl}\n\n${t(lang, 'email.verify.validity')}\n\n- ${appName}`,
}); });
} }
/** /**
* Send a federation meeting invitation email. * Send a federation meeting invitation email.
* @param {string} to recipient email * @param {string} to - recipient email
* @param {string} name recipient display name * @param {string} name - recipient display name
* @param {string} fromUser sender federated address (user@domain) * @param {string} fromUser - sender federated address (user@domain)
* @param {string} roomName name of the invited room * @param {string} roomName - name of the invited room
* @param {string} message optional personal message * @param {string} message - optional personal message
* @param {string} inboxUrl URL to the federation inbox * @param {string} inboxUrl - URL to the federation inbox
* @param {string} appName branding app name (default "Redlight") * @param {string} appName - branding app name (default "Redlight")
*/ */
export async function sendFederationInviteEmail(to, name, fromUser, roomName, message, inboxUrl, appName = 'Redlight') { export async function sendFederationInviteEmail(to, name, fromUser, roomName, message, inboxUrl, appName = 'Redlight', lang = 'en') {
if (!transporter) return; // silently skip if SMTP not configured if (!transporter) return; // silently skip if SMTP not configured
const from = process.env.SMTP_FROM || process.env.SMTP_USER; const from = process.env.SMTP_FROM || process.env.SMTP_USER;
@@ -113,37 +114,40 @@ export async function sendFederationInviteEmail(to, name, fromUser, roomName, me
const safeMessage = message ? escapeHtml(message) : null; const safeMessage = message ? escapeHtml(message) : null;
const safeAppName = escapeHtml(appName); const safeAppName = escapeHtml(appName);
const introHtml = t(lang, 'email.federationInvite.intro')
.replace('{fromUser}', `<strong style="color:#cdd6f4;">${safeFromUser}</strong>`);
await transporter.sendMail({ await transporter.sendMail({
from: `"${headerAppName}" <${from}>`, from: `"${headerAppName}" <${from}>`,
to, to,
subject: `${headerAppName} - Meeting invitation from ${sanitizeHeaderValue(fromUser)}`, subject: t(lang, 'email.federationInvite.subject', { appName: headerAppName, fromUser: sanitizeHeaderValue(fromUser) }),
html: ` html: `
<div style="font-family:Arial,sans-serif;max-width:520px;margin:0 auto;padding:32px;background:#1e1e2e;color:#cdd6f4;border-radius:12px;"> <div style="font-family:Arial,sans-serif;max-width:520px;margin:0 auto;padding:32px;background:#1e1e2e;color:#cdd6f4;border-radius:12px;">
<h2 style="color:#cba6f7;margin-top:0;">Hey ${safeName} 👋</h2> <h2 style="color:#cba6f7;margin-top:0;">${t(lang, 'email.greeting', { name: safeName })}</h2>
<p>You have received a meeting invitation from <strong style="color:#cdd6f4;">${safeFromUser}</strong>.</p> <p>${introHtml}</p>
<div style="background:#313244;border-radius:8px;padding:16px;margin:20px 0;"> <div style="background:#313244;border-radius:8px;padding:16px;margin:20px 0;">
<p style="margin:0 0 8px 0;font-size:13px;color:#7f849c;">Room:</p> <p style="margin:0 0 8px 0;font-size:13px;color:#7f849c;">${t(lang, 'email.federationInvite.roomLabel')}</p>
<p style="margin:0;font-size:16px;font-weight:bold;color:#cdd6f4;">${safeRoomName}</p> <p style="margin:0;font-size:16px;font-weight:bold;color:#cdd6f4;">${safeRoomName}</p>
${safeMessage ? `<p style="margin:12px 0 0 0;font-size:13px;color:#a6adc8;font-style:italic;">&quot;${safeMessage}&quot;</p>` : ''} ${safeMessage ? `<p style="margin:12px 0 0 0;font-size:13px;color:#a6adc8;font-style:italic;">&quot;${safeMessage}&quot;</p>` : ''}
</div> </div>
<p style="text-align:center;margin:28px 0;"> <p style="text-align:center;margin:28px 0;">
<a href="${inboxUrl}" <a href="${inboxUrl}"
style="display:inline-block;background:#cba6f7;color:#1e1e2e;padding:12px 32px;border-radius:8px;text-decoration:none;font-weight:bold;"> style="display:inline-block;background:#cba6f7;color:#1e1e2e;padding:12px 32px;border-radius:8px;text-decoration:none;font-weight:bold;">
View Invitation ${t(lang, 'email.viewInvitation')}
</a> </a>
</p> </p>
<hr style="border:none;border-top:1px solid #313244;margin:24px 0;"/> <hr style="border:none;border-top:1px solid #313244;margin:24px 0;"/>
<p style="font-size:12px;color:#585b70;">Open the link above to accept or decline the invitation.</p> <p style="font-size:12px;color:#585b70;">${t(lang, 'email.invitationFooter')}</p>
</div> </div>
`, `,
text: `Hey ${name},\n\nYou have received a meeting invitation from ${fromUser}.\nRoom: ${roomName}${message ? `\nMessage: "${message}"` : ''}\n\nView invitation: ${inboxUrl}\n\n ${appName}`, text: `${t(lang, 'email.greeting', { name })}\n\n${t(lang, 'email.federationInvite.intro', { fromUser })}\n${t(lang, 'email.federationInvite.roomLabel')} ${roomName}${message ? `\n"${message}"` : ''}\n\n${t(lang, 'email.viewInvitation')}: ${inboxUrl}\n\n- ${appName}`,
}); });
} }
/** /**
* Send a calendar event invitation email (federated). * Send a calendar event invitation email (federated).
*/ */
export async function sendCalendarInviteEmail(to, name, fromUser, title, startTime, endTime, description, inboxUrl, appName = 'Redlight') { export async function sendCalendarInviteEmail(to, name, fromUser, title, startTime, endTime, description, inboxUrl, appName = 'Redlight', lang = 'en') {
if (!transporter) return; if (!transporter) return;
const from = process.env.SMTP_FROM || process.env.SMTP_USER; const from = process.env.SMTP_FROM || process.env.SMTP_USER;
@@ -154,41 +158,44 @@ export async function sendCalendarInviteEmail(to, name, fromUser, title, startTi
const safeDesc = description ? escapeHtml(description) : null; const safeDesc = description ? escapeHtml(description) : null;
const formatDate = (iso) => { const formatDate = (iso) => {
try { return new Date(iso).toLocaleString('en-GB', { dateStyle: 'full', timeStyle: 'short' }); } try { return new Date(iso).toLocaleString(lang === 'de' ? 'de-DE' : 'en-GB', { dateStyle: 'full', timeStyle: 'short' }); }
catch { return iso; } catch { return iso; }
}; };
const introHtml = t(lang, 'email.calendarInvite.intro')
.replace('{fromUser}', `<strong style="color:#cdd6f4;">${safeFromUser}</strong>`);
await transporter.sendMail({ await transporter.sendMail({
from: `"${headerAppName}" <${from}>`, from: `"${headerAppName}" <${from}>`,
to, to,
subject: `${headerAppName} - Calendar invitation from ${sanitizeHeaderValue(fromUser)}`, subject: t(lang, 'email.calendarInvite.subject', { appName: headerAppName, fromUser: sanitizeHeaderValue(fromUser) }),
html: ` html: `
<div style="font-family:Arial,sans-serif;max-width:520px;margin:0 auto;padding:32px;background:#1e1e2e;color:#cdd6f4;border-radius:12px;"> <div style="font-family:Arial,sans-serif;max-width:520px;margin:0 auto;padding:32px;background:#1e1e2e;color:#cdd6f4;border-radius:12px;">
<h2 style="color:#cba6f7;margin-top:0;">Hey ${safeName} 👋</h2> <h2 style="color:#cba6f7;margin-top:0;">${t(lang, 'email.greeting', { name: safeName })}</h2>
<p>You have received a calendar invitation from <strong style="color:#cdd6f4;">${safeFromUser}</strong>.</p> <p>${introHtml}</p>
<div style="background:#313244;border-radius:8px;padding:16px;margin:20px 0;"> <div style="background:#313244;border-radius:8px;padding:16px;margin:20px 0;">
<p style="margin:0 0 4px 0;font-size:15px;font-weight:bold;color:#cdd6f4;">${safeTitle}</p> <p style="margin:0 0 4px 0;font-size:15px;font-weight:bold;color:#cdd6f4;">${safeTitle}</p>
<p style="margin:6px 0 0 0;font-size:13px;color:#a6adc8;">🕐 ${escapeHtml(formatDate(startTime))} ${escapeHtml(formatDate(endTime))}</p> <p style="margin:6px 0 0 0;font-size:13px;color:#a6adc8;">🕐 ${escapeHtml(formatDate(startTime))} - ${escapeHtml(formatDate(endTime))}</p>
${safeDesc ? `<p style="margin:10px 0 0 0;font-size:13px;color:#a6adc8;font-style:italic;">&quot;${safeDesc}&quot;</p>` : ''} ${safeDesc ? `<p style="margin:10px 0 0 0;font-size:13px;color:#a6adc8;font-style:italic;">&quot;${safeDesc}&quot;</p>` : ''}
</div> </div>
<p style="text-align:center;margin:28px 0;"> <p style="text-align:center;margin:28px 0;">
<a href="${inboxUrl}" <a href="${inboxUrl}"
style="display:inline-block;background:#cba6f7;color:#1e1e2e;padding:12px 32px;border-radius:8px;text-decoration:none;font-weight:bold;"> style="display:inline-block;background:#cba6f7;color:#1e1e2e;padding:12px 32px;border-radius:8px;text-decoration:none;font-weight:bold;">
View Invitation ${t(lang, 'email.viewInvitation')}
</a> </a>
</p> </p>
<hr style="border:none;border-top:1px solid #313244;margin:24px 0;"/> <hr style="border:none;border-top:1px solid #313244;margin:24px 0;"/>
<p style="font-size:12px;color:#585b70;">Open the link above to accept or decline the invitation.</p> <p style="font-size:12px;color:#585b70;">${t(lang, 'email.invitationFooter')}</p>
</div> </div>
`, `,
text: `Hey ${name},\n\nYou have received a calendar invitation from ${fromUser}.\nEvent: ${title}\nTime: ${formatDate(startTime)} \u2013 ${formatDate(endTime)}${description ? `\n\n"${description}"` : ''}\n\nView invitation: ${inboxUrl}\n\n\u2013 ${appName}`, text: `${t(lang, 'email.greeting', { name })}\n\n${t(lang, 'email.calendarInvite.intro', { fromUser })}\n${safeTitle}\n${formatDate(startTime)} \u2013 ${formatDate(endTime)}${description ? `\n\n"${description}"` : ''}\n\n${t(lang, 'email.viewInvitation')}: ${inboxUrl}\n\n\u2013 ${appName}`,
}); });
} }
/** /**
* Notify a user that a federated calendar event they received was deleted by the organiser. * Notify a user that a federated calendar event they received was deleted by the organiser.
*/ */
export async function sendCalendarEventDeletedEmail(to, name, fromUser, title, appName = 'Redlight') { export async function sendCalendarEventDeletedEmail(to, name, fromUser, title, appName = 'Redlight', lang = 'en') {
if (!transporter) return; if (!transporter) return;
const from = process.env.SMTP_FROM || process.env.SMTP_USER; const from = process.env.SMTP_FROM || process.env.SMTP_USER;
@@ -197,33 +204,36 @@ export async function sendCalendarEventDeletedEmail(to, name, fromUser, title, a
const safeFromUser = escapeHtml(fromUser); const safeFromUser = escapeHtml(fromUser);
const safeTitle = escapeHtml(title); const safeTitle = escapeHtml(title);
const introHtml = t(lang, 'email.calendarDeleted.intro')
.replace('{fromUser}', `<strong style="color:#cdd6f4;">${safeFromUser}</strong>`);
await transporter.sendMail({ await transporter.sendMail({
from: `"${headerAppName}" <${from}>`, from: `"${headerAppName}" <${from}>`,
to, to,
subject: `${headerAppName} Calendar event cancelled: ${sanitizeHeaderValue(title)}`, subject: t(lang, 'email.calendarDeleted.subject', { appName: headerAppName, title: sanitizeHeaderValue(title) }),
html: ` html: `
<div style="font-family:Arial,sans-serif;max-width:520px;margin:0 auto;padding:32px;background:#1e1e2e;color:#cdd6f4;border-radius:12px;"> <div style="font-family:Arial,sans-serif;max-width:520px;margin:0 auto;padding:32px;background:#1e1e2e;color:#cdd6f4;border-radius:12px;">
<h2 style="color:#f38ba8;margin-top:0;">Hey ${safeName} 👋</h2> <h2 style="color:#f38ba8;margin-top:0;">${t(lang, 'email.greeting', { name: safeName })}</h2>
<p>The following calendar event was deleted by the organiser (<strong style="color:#cdd6f4;">${safeFromUser}</strong>) and is no longer available:</p> <p>${introHtml}</p>
<div style="background:#313244;border-radius:8px;padding:16px;margin:20px 0;border-left:4px solid #f38ba8;"> <div style="background:#313244;border-radius:8px;padding:16px;margin:20px 0;border-left:4px solid #f38ba8;">
<p style="margin:0;font-size:15px;font-weight:bold;color:#cdd6f4;">${safeTitle}</p> <p style="margin:0;font-size:15px;font-weight:bold;color:#cdd6f4;">${safeTitle}</p>
</div> </div>
<p style="font-size:13px;color:#7f849c;">The event has been automatically removed from your calendar.</p> <p style="font-size:13px;color:#7f849c;">${t(lang, 'email.calendarDeleted.note')}</p>
<hr style="border:none;border-top:1px solid #313244;margin:24px 0;"/> <hr style="border:none;border-top:1px solid #313244;margin:24px 0;"/>
<p style="font-size:12px;color:#585b70;">This message was sent automatically by ${escapeHtml(appName)}.</p> <p style="font-size:12px;color:#585b70;">${t(lang, 'email.calendarDeleted.footer', { appName: escapeHtml(appName) })}</p>
</div> </div>
`, `,
text: `Hey ${name},\n\nThe calendar event "${title}" by ${fromUser} has been deleted and removed from your calendar.\n\n\u2013 ${appName}`, text: `${t(lang, 'email.greeting', { name })}\n\n${t(lang, 'email.calendarDeleted.intro', { fromUser })}\n"${title}"\n\n${t(lang, 'email.calendarDeleted.note')}\n\n\u2013 ${appName}`,
}); });
} }
/** /**
* Send a user registration invite email. * Send a user registration invite email.
* @param {string} to recipient email * @param {string} to - recipient email
* @param {string} inviteUrl full invite registration URL * @param {string} inviteUrl - full invite registration URL
* @param {string} appName branding app name (default "Redlight") * @param {string} appName - branding app name (default "Redlight")
*/ */
export async function sendInviteEmail(to, inviteUrl, appName = 'Redlight') { export async function sendInviteEmail(to, inviteUrl, appName = 'Redlight', lang = 'en') {
if (!transporter) { if (!transporter) {
throw new Error('SMTP not configured'); throw new Error('SMTP not configured');
} }
@@ -232,30 +242,33 @@ export async function sendInviteEmail(to, inviteUrl, appName = 'Redlight') {
const headerAppName = sanitizeHeaderValue(appName); const headerAppName = sanitizeHeaderValue(appName);
const safeAppName = escapeHtml(appName); const safeAppName = escapeHtml(appName);
const introHtml = t(lang, 'email.invite.intro')
.replace('{appName}', `<strong style="color:#cdd6f4;">${safeAppName}</strong>`);
await transporter.sendMail({ await transporter.sendMail({
from: `"${headerAppName}" <${from}>`, from: `"${headerAppName}" <${from}>`,
to, to,
subject: `${headerAppName} You've been invited`, subject: t(lang, 'email.invite.subject', { appName: headerAppName }),
html: ` html: `
<div style="font-family:Arial,sans-serif;max-width:520px;margin:0 auto;padding:32px;background:#1e1e2e;color:#cdd6f4;border-radius:12px;"> <div style="font-family:Arial,sans-serif;max-width:520px;margin:0 auto;padding:32px;background:#1e1e2e;color:#cdd6f4;border-radius:12px;">
<h2 style="color:#cba6f7;margin-top:0;">You've been invited! 🎉</h2> <h2 style="color:#cba6f7;margin-top:0;">${t(lang, 'email.invite.title')}</h2>
<p>You have been invited to create an account on <strong style="color:#cdd6f4;">${safeAppName}</strong>.</p> <p>${introHtml}</p>
<p>Click the button below to register:</p> <p>${t(lang, 'email.invite.prompt')}</p>
<p style="text-align:center;margin:28px 0;"> <p style="text-align:center;margin:28px 0;">
<a href="${inviteUrl}" <a href="${inviteUrl}"
style="display:inline-block;background:#cba6f7;color:#1e1e2e;padding:12px 32px;border-radius:8px;text-decoration:none;font-weight:bold;"> style="display:inline-block;background:#cba6f7;color:#1e1e2e;padding:12px 32px;border-radius:8px;text-decoration:none;font-weight:bold;">
Create Account ${t(lang, 'email.invite.button')}
</a> </a>
</p> </p>
<p style="font-size:13px;color:#7f849c;"> <p style="font-size:13px;color:#7f849c;">
Or copy this link in your browser:<br/> ${t(lang, 'email.linkHint')}<br/>
<a href="${inviteUrl}" style="color:#89b4fa;word-break:break-all;">${escapeHtml(inviteUrl)}</a> <a href="${inviteUrl}" style="color:#89b4fa;word-break:break-all;">${escapeHtml(inviteUrl)}</a>
</p> </p>
<p style="font-size:13px;color:#7f849c;">This link is valid for 7 days.</p> <p style="font-size:13px;color:#7f849c;">${t(lang, 'email.invite.validity')}</p>
<hr style="border:none;border-top:1px solid #313244;margin:24px 0;"/> <hr style="border:none;border-top:1px solid #313244;margin:24px 0;"/>
<p style="font-size:12px;color:#585b70;">If you didn't expect this invitation, you can safely ignore this email.</p> <p style="font-size:12px;color:#585b70;">${t(lang, 'email.invite.footer')}</p>
</div> </div>
`, `,
text: `You've been invited to create an account on ${appName}.\n\nRegister here: ${inviteUrl}\n\nThis link is valid for 7 days.\n\n ${appName}`, text: `${t(lang, 'email.invite.title')}\n\n${t(lang, 'email.invite.intro', { appName })}\n\n${t(lang, 'email.invite.prompt')}\n${inviteUrl}\n\n${t(lang, 'email.invite.validity')}\n\n- ${appName}`,
}); });
} }

View File

@@ -1,4 +1,4 @@
import 'dotenv/config'; import 'dotenv/config';
import express from 'express'; import express from 'express';
import cors from 'cors'; import cors from 'cors';
import path from 'path'; import path from 'path';
@@ -22,7 +22,7 @@ const __dirname = path.dirname(__filename);
const app = express(); const app = express();
const PORT = process.env.PORT || 3001; const PORT = process.env.PORT || 3001;
// Trust proxy configurable via TRUST_PROXY env var (default: 1 = one local reverse proxy) // Trust proxy - configurable via TRUST_PROXY env var (default: 1 = one local reverse proxy)
// Use a number to trust that many hops, or a string like 'loopback' / an IP/CIDR. // Use a number to trust that many hops, or a string like 'loopback' / an IP/CIDR.
const rawTrustProxy = process.env.TRUST_PROXY ?? 'loopback'; const rawTrustProxy = process.env.TRUST_PROXY ?? 'loopback';
const trustProxy = /^\d+$/.test(rawTrustProxy) ? parseInt(rawTrustProxy, 10) : rawTrustProxy; const trustProxy = /^\d+$/.test(rawTrustProxy) ? parseInt(rawTrustProxy, 10) : rawTrustProxy;

View File

@@ -1,4 +1,4 @@
import { Router } from 'express'; import { Router } from 'express';
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { getDb } from '../config/database.js'; 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}$/; const usernameRegex = /^[a-zA-Z0-9_-]{3,30}$/;
if (!usernameRegex.test(name)) { 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) { if (password.length < 8) {
@@ -211,7 +211,7 @@ router.post('/invites', authenticateToken, requireAdmin, async (req, res) => {
if (isMailerConfigured()) { if (isMailerConfigured()) {
try { try {
await sendInviteEmail(email.toLowerCase(), inviteUrl, appName); await sendInviteEmail(email.toLowerCase(), inviteUrl, appName, 'en');
} catch (mailErr) { } catch (mailErr) {
log.admin.warn(`Invite email failed (non-fatal): ${mailErr.message}`); 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 bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import { v4 as uuidv4 } from 'uuid'; 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) // Simple format check for theme/language IDs (actual validation happens on the frontend)
const SAFE_ID_RE = /^[a-zA-Z0-9_-]{1,50}$/; 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 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; const MIN_PASSWORD_LENGTH = 8;
@@ -145,7 +145,7 @@ router.post('/register', registerLimiter, async (req, res) => {
const usernameRegex = /^[a-zA-Z0-9_-]{3,30}$/; const usernameRegex = /^[a-zA-Z0-9_-]{3,30}$/;
if (!usernameRegex.test(username)) { 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 // M1: email format
@@ -200,7 +200,7 @@ router.post('/register', registerLimiter, async (req, res) => {
} }
try { try {
await sendVerificationEmail(email.toLowerCase(), display_name, verifyUrl, appName); await sendVerificationEmail(email.toLowerCase(), display_name, verifyUrl, appName, 'en');
} catch (mailErr) { } catch (mailErr) {
log.auth.error(`Verification mail failed: ${mailErr.message}`); log.auth.error(`Verification mail failed: ${mailErr.message}`);
// Account is created but email failed — user can resend from login page // 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' }); 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( const result = await db.run(
'INSERT INTO users (name, display_name, email, password_hash, email_verified) VALUES (?, ?, ?, ?, 1)', 'INSERT INTO users (name, display_name, email, password_hash, email_verified) VALUES (?, ?, ?, ?, 1)',
[username, display_name, email.toLowerCase(), hash] [username, display_name, email.toLowerCase(), hash]
@@ -278,7 +278,7 @@ router.post('/resend-verification', resendVerificationLimiter, async (req, res)
} }
const db = getDb(); 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) { if (!user || user.email_verified) {
// Don't reveal whether account exists // Don't reveal whether account exists
@@ -313,7 +313,7 @@ router.post('/resend-verification', resendVerificationLimiter, async (req, res)
} }
try { 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) { } catch (mailErr) {
log.auth.error(`Resend verification mail failed: ${mailErr.message}`); 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.' }); 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' }); 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)) { if (!EMAIL_RE.test(email)) {
return res.status(401).json({ error: 'Invalid credentials' }); 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) => { router.post('/logout', authenticateToken, async (req, res) => {
try { try {
const authHeader = req.headers.authorization; const authHeader = req.headers.authorization;
@@ -432,7 +432,7 @@ router.put('/profile', authenticateToken, profileLimiter, async (req, res) => {
if (name && name !== req.user.name) { if (name && name !== req.user.name) {
const usernameRegex = /^[a-zA-Z0-9_-]{3,30}$/; const usernameRegex = /^[a-zA-Z0-9_-]{3,30}$/;
if (!usernameRegex.test(name)) { 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]); const existingUsername = await db.get('SELECT id FROM users WHERE LOWER(name) = LOWER(?) AND id != ?', [name, req.user.id]);
if (existingUsername) { 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' }); 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 MAX_AVATAR_SIZE = 2 * 1024 * 1024;
const buffer = await new Promise((resolve, reject) => { const buffer = await new Promise((resolve, reject) => {
const chunks = []; const chunks = [];

View File

@@ -1,4 +1,4 @@
import { Router } from 'express'; import { Router } from 'express';
import crypto from 'crypto'; import crypto from 'crypto';
import { getDb } from '../config/database.js'; import { getDb } from '../config/database.js';
import { authenticateToken } from '../middleware/auth.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) // 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) { if (targetUser?.email) {
const appUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`; const appUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`;
const inboxUrl = `${appUrl}/federation/inbox`; 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, (targetUser.display_name && targetUser.display_name !== '') ? targetUser.display_name : targetUser.name,
fromDisplay, fromDisplay,
event.title, event.start_time, event.end_time, event.description, event.title, event.start_time, event.end_time, event.description,
inboxUrl, appName inboxUrl, appName, targetUser.language || 'en'
).catch(mailErr => { ).catch(mailErr => {
log.server.warn('Calendar local share mail failed (non-fatal):', mailErr.message); 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') { if (inv.status === 'pending') {
await db.run("UPDATE calendar_local_invitations SET status = 'declined' WHERE id = ?", [inv.id]); await db.run("UPDATE calendar_local_invitations SET status = 'declined' WHERE id = ?", [inv.id]);
} else { } 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') { if (inv.status === 'accepted') {
await db.run('DELETE FROM calendar_event_shares WHERE event_id = ? AND user_id = ?', [inv.event_id, req.user.id]); 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 // Find local user
const { username } = parseAddress(to_user); const { username } = parseAddress(to_user);
const db = getDb(); 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' }); if (!targetUser) return res.status(404).json({ error: 'User not found on this instance' });
// Check duplicate (already in invitations or already accepted into calendar) // Check duplicate (already in invitations or already accepted into calendar)
@@ -619,7 +619,7 @@ router.post(['/receive-event', '/calendar-event'], calendarFederationLimiter, as
sendCalendarInviteEmail( sendCalendarInviteEmail(
targetUser.email, targetUser.name, from_user, targetUser.email, targetUser.name, from_user,
title, start_time, end_time, description || null, title, start_time, end_time, description || null,
inboxUrl, appName inboxUrl, appName, targetUser.language || 'en'
).catch(mailErr => { ).catch(mailErr => {
log.server.warn('Calendar invite mail failed (non-fatal):', mailErr.message); 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 { v4 as uuidv4 } from 'uuid';
import { rateLimit } from 'express-rate-limit'; import { rateLimit } from 'express-rate-limit';
import { getDb } from '../config/database.js'; 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 */ } } 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) { if (targetUser.email) {
const appUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`; const appUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`;
const inboxUrl = `${appUrl}/federation/inbox`; const inboxUrl = `${appUrl}/federation/inbox`;
const appName = process.env.APP_NAME || 'Redlight'; const appName = process.env.APP_NAME || 'Redlight';
sendFederationInviteEmail( sendFederationInviteEmail(
targetUser.email, targetUser.name, from_user, targetUser.email, targetUser.name, from_user,
room_name, message || null, inboxUrl, appName room_name, message || null, inboxUrl, appName, targetUser.language || 'en'
).catch(mailErr => { ).catch(mailErr => {
log.federation.warn('Federation invite mail failed (non-fatal):', mailErr.message); 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 { try {
// Users with pending/declined invitations // Users with pending/declined invitations
const invUsers = await db.all( 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 FROM calendar_invitations ci
JOIN users u ON ci.to_user_id = u.id JOIN users u ON ci.to_user_id = u.id
WHERE ci.event_uid = ? AND ci.from_user LIKE ?`, 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) // Users who already accepted (event in their calendar)
const calUsers = await db.all( 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 FROM calendar_events ce
JOIN users u ON ce.user_id = u.id JOIN users u ON ce.user_id = u.id
WHERE ce.uid = ? AND ce.federated_from LIKE ?`, 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) { if (affectedUsers.length > 0) {
const appName = process.env.APP_NAME || 'Redlight'; const appName = process.env.APP_NAME || 'Redlight';
for (const u of affectedUsers) { 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 => { .catch(mailErr => {
log.federation.warn(`Calendar deletion mail to ${u.email} failed (non-fatal): ${mailErr.message}`); 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 crypto from 'crypto';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; 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]); 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' }); 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 MAX_PRESENTATION_SIZE = 50 * 1024 * 1024;
const buffer = await new Promise((resolve, reject) => { const buffer = await new Promise((resolve, reject) => {
const chunks = []; const chunks = [];

View File

@@ -1,4 +1,4 @@
{ {
"common": { "common": {
"appName": "Redlight", "appName": "Redlight",
"loading": "Laden...", "loading": "Laden...",
@@ -76,11 +76,11 @@
"emailNotVerified": "E-Mail-Adresse noch nicht verifiziert. Bitte prüfe dein Postfach.", "emailNotVerified": "E-Mail-Adresse noch nicht verifiziert. Bitte prüfe dein Postfach.",
"username": "Benutzername", "username": "Benutzername",
"usernamePlaceholder": "z.B. maxmuster", "usernamePlaceholder": "z.B. maxmuster",
"usernameHint": "Nur Buchstaben, Zahlen, _ und - erlaubt (330 Zeichen)", "usernameHint": "Nur Buchstaben, Zahlen, _ und - erlaubt (3-30 Zeichen)",
"displayName": "Anzeigename", "displayName": "Anzeigename",
"displayNamePlaceholder": "Max Mustermann", "displayNamePlaceholder": "Max Mustermann",
"usernameTaken": "Benutzername ist bereits vergeben", "usernameTaken": "Benutzername ist bereits vergeben",
"usernameInvalid": "Benutzername darf nur Buchstaben, Zahlen, _ und - enthalten (330 Zeichen)", "usernameInvalid": "Benutzername darf nur Buchstaben, Zahlen, _ und - enthalten (3-30 Zeichen)",
"usernameRequired": "Benutzername ist erforderlich", "usernameRequired": "Benutzername ist erforderlich",
"displayNameRequired": "Anzeigename ist erforderlich", "displayNameRequired": "Anzeigename ist erforderlich",
"emailVerificationBanner": "Deine E-Mail-Adresse wurde noch nicht verifiziert.", "emailVerificationBanner": "Deine E-Mail-Adresse wurde noch nicht verifiziert.",
@@ -394,7 +394,7 @@
"removeRoomConfirm": "Raum wirklich entfernen?", "removeRoomConfirm": "Raum wirklich entfernen?",
"roomRemoved": "Raum entfernt", "roomRemoved": "Raum entfernt",
"roomRemoveFailed": "Raum konnte nicht entfernt werden", "roomRemoveFailed": "Raum konnte nicht entfernt werden",
"acceptedSaved": "Einladung angenommen Raum wurde in deinem Dashboard gespeichert!", "acceptedSaved": "Einladung angenommen - Raum wurde in deinem Dashboard gespeichert!",
"meetingId": "Meeting ID", "meetingId": "Meeting ID",
"maxParticipants": "Max. Teilnehmer", "maxParticipants": "Max. Teilnehmer",
"recordingOn": "Aufnahme aktiviert", "recordingOn": "Aufnahme aktiviert",
@@ -412,7 +412,7 @@
"calendarEvent": "Kalendereinladung", "calendarEvent": "Kalendereinladung",
"calendarAccepted": "Kalender-Event angenommen und in deinen Kalender eingetragen!", "calendarAccepted": "Kalender-Event angenommen und in deinen Kalender eingetragen!",
"localCalendarEvent": "Lokale Kalendereinladung", "localCalendarEvent": "Lokale Kalendereinladung",
"calendarLocalAccepted": "Einladung angenommen Event wurde in deinen Kalender eingetragen!", "calendarLocalAccepted": "Einladung angenommen - Event wurde in deinen Kalender eingetragen!",
"invitationRemoved": "Einladung entfernt", "invitationRemoved": "Einladung entfernt",
"removeInvitation": "Einladung entfernen" "removeInvitation": "Einladung entfernen"
}, },
@@ -475,5 +475,42 @@
"organizer": "Organisator", "organizer": "Organisator",
"federatedFrom": "Von Remote-Instanz", "federatedFrom": "Von Remote-Instanz",
"joinFederatedMeeting": "Remote-Meeting beitreten" "joinFederatedMeeting": "Remote-Meeting beitreten"
},
"email": {
"greeting": "Hey {name} 👋",
"viewInvitation": "Einladung anzeigen",
"invitationFooter": "Öffne den Link oben, um die Einladung anzunehmen oder abzulehnen.",
"linkHint": "Oder kopiere diesen Link in deinen Browser:",
"verify": {
"subject": "{appName} - E-Mail-Adresse bestätigen",
"intro": "Bitte bestätige deine E-Mail-Adresse, indem du auf den Button klickst:",
"button": "E-Mail bestätigen",
"validity": "Dieser Link ist 24 Stunden gültig.",
"footer": "Falls du dich nicht registriert hast, kannst du diese E-Mail ignorieren."
},
"invite": {
"subject": "{appName} - Du wurdest eingeladen",
"title": "Du wurdest eingeladen! 🎉",
"intro": "Du wurdest eingeladen, ein Konto auf {appName} zu erstellen.",
"prompt": "Klicke auf den Button, um dich zu registrieren:",
"button": "Konto erstellen",
"validity": "Dieser Link ist 7 Tage gültig.",
"footer": "Falls du diese Einladung nicht erwartet hast, kannst du diese E-Mail ignorieren."
},
"federationInvite": {
"subject": "{appName} - Meeting-Einladung von {fromUser}",
"intro": "Du hast eine Meeting-Einladung von {fromUser} erhalten.",
"roomLabel": "Raum:"
},
"calendarInvite": {
"subject": "{appName} - Kalendereinladung von {fromUser}",
"intro": "Du hast eine Kalendereinladung von {fromUser} erhalten."
},
"calendarDeleted": {
"subject": "{appName} - Kalendereintrag abgesagt: {title}",
"intro": "Der folgende Kalendereintrag wurde vom Organisator ({fromUser}) gelöscht und ist nicht mehr verfügbar:",
"note": "Der Termin wurde automatisch aus deinem Kalender entfernt.",
"footer": "Diese Nachricht wurde automatisch von {appName} versendet."
}
} }
} }

View File

@@ -1,4 +1,4 @@
{ {
"common": { "common": {
"appName": "Redlight", "appName": "Redlight",
"loading": "Loading...", "loading": "Loading...",
@@ -76,11 +76,11 @@
"emailNotVerified": "Email not yet verified. Please check your inbox.", "emailNotVerified": "Email not yet verified. Please check your inbox.",
"username": "Username", "username": "Username",
"usernamePlaceholder": "e.g. johndoe", "usernamePlaceholder": "e.g. johndoe",
"usernameHint": "Letters, numbers, _ and - only (330 chars)", "usernameHint": "Letters, numbers, _ and - only (3-30 chars)",
"displayName": "Display Name", "displayName": "Display Name",
"displayNamePlaceholder": "John Doe", "displayNamePlaceholder": "John Doe",
"usernameTaken": "Username is already taken", "usernameTaken": "Username is already taken",
"usernameInvalid": "Username may only contain letters, numbers, _ and - (330 chars)", "usernameInvalid": "Username may only contain letters, numbers, _ and - (3-30 chars)",
"usernameRequired": "Username is required", "usernameRequired": "Username is required",
"displayNameRequired": "Display name is required", "displayNameRequired": "Display name is required",
"emailVerificationBanner": "Your email address has not been verified yet.", "emailVerificationBanner": "Your email address has not been verified yet.",
@@ -394,7 +394,7 @@
"removeRoomConfirm": "Really remove this room?", "removeRoomConfirm": "Really remove this room?",
"roomRemoved": "Room removed", "roomRemoved": "Room removed",
"roomRemoveFailed": "Could not remove room", "roomRemoveFailed": "Could not remove room",
"acceptedSaved": "Invitation accepted room saved to your dashboard!", "acceptedSaved": "Invitation accepted - room saved to your dashboard!",
"meetingId": "Meeting ID", "meetingId": "Meeting ID",
"maxParticipants": "Max. participants", "maxParticipants": "Max. participants",
"recordingOn": "Recording enabled", "recordingOn": "Recording enabled",
@@ -412,7 +412,7 @@
"calendarEvent": "Calendar Invitation", "calendarEvent": "Calendar Invitation",
"calendarAccepted": "Calendar event accepted and added to your calendar!", "calendarAccepted": "Calendar event accepted and added to your calendar!",
"localCalendarEvent": "Local Calendar Invitation", "localCalendarEvent": "Local Calendar Invitation",
"calendarLocalAccepted": "Invitation accepted event added to your calendar!", "calendarLocalAccepted": "Invitation accepted - event added to your calendar!",
"invitationRemoved": "Invitation removed", "invitationRemoved": "Invitation removed",
"removeInvitation": "Remove invitation" "removeInvitation": "Remove invitation"
}, },
@@ -475,5 +475,42 @@
"organizer": "Organizer", "organizer": "Organizer",
"federatedFrom": "From remote instance", "federatedFrom": "From remote instance",
"joinFederatedMeeting": "Join remote meeting" "joinFederatedMeeting": "Join remote meeting"
},
"email": {
"greeting": "Hey {name} 👋",
"viewInvitation": "View Invitation",
"invitationFooter": "Open the link above to accept or decline the invitation.",
"linkHint": "Or copy this link in your browser:",
"verify": {
"subject": "{appName} - Verify your email",
"intro": "Please verify your email address by clicking the button below:",
"button": "Verify Email",
"validity": "This link is valid for 24 hours.",
"footer": "If you didn't register, please ignore this email."
},
"invite": {
"subject": "{appName} - You've been invited",
"title": "You've been invited! 🎉",
"intro": "You have been invited to create an account on {appName}.",
"prompt": "Click the button below to register:",
"button": "Create Account",
"validity": "This link is valid for 7 days.",
"footer": "If you didn't expect this invitation, you can safely ignore this email."
},
"federationInvite": {
"subject": "{appName} - Meeting invitation from {fromUser}",
"intro": "You have received a meeting invitation from {fromUser}.",
"roomLabel": "Room:"
},
"calendarInvite": {
"subject": "{appName} - Calendar invitation from {fromUser}",
"intro": "You have received a calendar invitation from {fromUser}."
},
"calendarDeleted": {
"subject": "{appName} - Calendar event cancelled: {title}",
"intro": "The following calendar event was deleted by the organiser ({fromUser}) and is no longer available:",
"note": "The event has been automatically removed from your calendar.",
"footer": "This message was sent automatically by {appName}."
}
} }
} }

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useMemo } from 'react'; import { useState, useEffect, useMemo } from 'react';
import { import {
ChevronLeft, ChevronRight, Plus, Calendar as CalendarIcon, Clock, Video, ChevronLeft, ChevronRight, Plus, Calendar as CalendarIcon, Clock, Video,
Loader2, Download, Share2, Globe, Trash2, Edit, X, UserPlus, Send, ExternalLink, Loader2, Download, Share2, Globe, Trash2, Edit, X, UserPlus, Send, ExternalLink,
@@ -494,7 +494,7 @@ export default function Calendar() {
style={{ backgroundColor: ev.color || '#6366f1' }} style={{ backgroundColor: ev.color || '#6366f1' }}
> >
<div className="truncate">{ev.title}</div> <div className="truncate">{ev.title}</div>
<div className="opacity-80 text-[10px]">{formatTime(ev.start_time)} {formatTime(ev.end_time)}</div> <div className="opacity-80 text-[10px]">{formatTime(ev.start_time)} - {formatTime(ev.end_time)}</div>
</div> </div>
))} ))}
</div> </div>
@@ -604,7 +604,7 @@ export default function Calendar() {
<div className="flex items-center gap-2 text-sm text-th-text-s"> <div className="flex items-center gap-2 text-sm text-th-text-s">
<Clock size={14} /> <Clock size={14} />
<span> <span>
{new Date(showDetail.start_time).toLocaleString()} {new Date(showDetail.end_time).toLocaleString()} {new Date(showDetail.start_time).toLocaleString()} - {new Date(showDetail.end_time).toLocaleString()}
</span> </span>
</div> </div>

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Globe, Mail, Check, X, ExternalLink, Loader2, Inbox, Calendar, Trash2 } from 'lucide-react'; import { Globe, Mail, Check, X, ExternalLink, Loader2, Inbox, Calendar, Trash2 } from 'lucide-react';
import api from '../services/api'; import api from '../services/api';
import { useLanguage } from '../contexts/LanguageContext'; import { useLanguage } from '../contexts/LanguageContext';
@@ -210,7 +210,7 @@ export default function FederationInbox() {
{t('federation.from')}: <span className="font-medium text-th-text">{inv.from_user}</span> {t('federation.from')}: <span className="font-medium text-th-text">{inv.from_user}</span>
</p> </p>
<p className="text-sm text-th-text-s mt-1"> <p className="text-sm text-th-text-s mt-1">
{new Date(inv.start_time).toLocaleString()} {new Date(inv.end_time).toLocaleString()} {new Date(inv.start_time).toLocaleString()} - {new Date(inv.end_time).toLocaleString()}
</p> </p>
{inv.description && ( {inv.description && (
<p className="text-sm text-th-text-s mt-1 italic">"{inv.description}"</p> <p className="text-sm text-th-text-s mt-1 italic">"{inv.description}"</p>
@@ -249,7 +249,7 @@ export default function FederationInbox() {
{t('federation.from')}: <span className="font-medium text-th-text">{inv.from_name}</span> {t('federation.from')}: <span className="font-medium text-th-text">{inv.from_name}</span>
</p> </p>
<p className="text-sm text-th-text-s mt-1"> <p className="text-sm text-th-text-s mt-1">
{new Date(inv.start_time).toLocaleString()} {new Date(inv.end_time).toLocaleString()} {new Date(inv.start_time).toLocaleString()} - {new Date(inv.end_time).toLocaleString()}
</p> </p>
{inv.description && ( {inv.description && (
<p className="text-sm text-th-text-s mt-1 italic">"{inv.description}"</p> <p className="text-sm text-th-text-s mt-1 italic">"{inv.description}"</p>