Refactor code and improve internationalization support
Some checks failed
Build & Push Docker Image / build (push) Has been cancelled
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:
216
README.md
216
README.md
@@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
52
server/config/emaili18n.js
Normal file
52
server/config/emaili18n.js
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;">"${safeMessage}"</p>` : ''}
|
${safeMessage ? `<p style="margin:12px 0 0 0;font-size:13px;color:#a6adc8;font-style:italic;">"${safeMessage}"</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;">"${safeDesc}"</p>` : ''}
|
${safeDesc ? `<p style="margin:10px 0 0 0;font-size:13px;color:#a6adc8;font-style:italic;">"${safeDesc}"</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}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 - (3–30 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}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 - (3–30 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 - (3–30 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 = [];
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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}`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 = [];
|
||||||
|
|||||||
@@ -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 (3–30 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 (3–30 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."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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 (3–30 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 - (3–30 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}."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user