diff --git a/server/middleware/auth.js b/server/middleware/auth.js index 5997a96..ef7004f 100644 --- a/server/middleware/auth.js +++ b/server/middleware/auth.js @@ -57,3 +57,14 @@ export function generateToken(userId) { const jti = uuidv4(); return jwt.sign({ userId, jti }, JWT_SECRET, { expiresIn: '7d' }); } + +/** + * Build the public base URL for the application. + * Prefers APP_URL env var. Falls back to X-Forwarded-Proto + Host header + * so that links are correct behind a TLS-terminating reverse proxy. + */ +export function getBaseUrl(req) { + if (process.env.APP_URL) return process.env.APP_URL.replace(/\/+$/, ''); + const proto = req.get('x-forwarded-proto')?.split(',')[0]?.trim() || req.protocol; + return `${proto}://${req.get('host')}`; +} diff --git a/server/routes/admin.js b/server/routes/admin.js index 4ead484..8a3d1fe 100644 --- a/server/routes/admin.js +++ b/server/routes/admin.js @@ -2,7 +2,7 @@ import bcrypt from 'bcryptjs'; import { v4 as uuidv4 } from 'uuid'; import { getDb } from '../config/database.js'; -import { authenticateToken, requireAdmin } from '../middleware/auth.js'; +import { authenticateToken, requireAdmin, getBaseUrl } from '../middleware/auth.js'; import { isMailerConfigured, sendInviteEmail } from '../config/mailer.js'; import { log } from '../config/logger.js'; import { @@ -208,7 +208,7 @@ router.post('/invites', authenticateToken, requireAdmin, async (req, res) => { ); // Send invite email if SMTP is configured - const baseUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`; + const baseUrl = getBaseUrl(req); const inviteUrl = `${baseUrl}/register?invite=${token}`; // Load app name diff --git a/server/routes/auth.js b/server/routes/auth.js index 6dc1529..21afa75 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.js @@ -9,7 +9,7 @@ import { rateLimit } from 'express-rate-limit'; import { RedisStore } from 'rate-limit-redis'; import { getDb } from '../config/database.js'; import redis from '../config/redis.js'; -import { authenticateToken, generateToken } from '../middleware/auth.js'; +import { authenticateToken, generateToken, getBaseUrl } from '../middleware/auth.js'; import { isMailerConfigured, sendVerificationEmail } from '../config/mailer.js'; import { log } from '../config/logger.js'; @@ -189,7 +189,7 @@ router.post('/register', registerLimiter, async (req, res) => { } // Build verification URL - const baseUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`; + const baseUrl = getBaseUrl(req); const verifyUrl = `${baseUrl}/verify-email?token=${verificationToken}`; // Load app name from branding settings @@ -303,7 +303,7 @@ router.post('/resend-verification', resendVerificationLimiter, async (req, res) [verificationToken, expires, now, user.id] ); - const baseUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`; + const baseUrl = getBaseUrl(req); const verifyUrl = `${baseUrl}/verify-email?token=${verificationToken}`; const brandingSetting = await db.get("SELECT value FROM settings WHERE key = 'branding'"); diff --git a/server/routes/caldav.js b/server/routes/caldav.js index f413207..cfe7fee 100644 --- a/server/routes/caldav.js +++ b/server/routes/caldav.js @@ -14,6 +14,7 @@ import { Router, text } from 'express'; import crypto from 'crypto'; import { getDb } from '../config/database.js'; import { log } from '../config/logger.js'; +import { getBaseUrl } from '../middleware/auth.js'; const router = Router(); @@ -239,12 +240,8 @@ function validateCalDAVUser(req, res, next) { next(); } -// ── Base URL helper ──────────────────────────────────────────────────────── -function baseUrl(req) { - const proto = req.get('x-forwarded-proto') || req.protocol; - const host = req.get('x-forwarded-host') || req.get('host'); - return `${proto}://${host}`; -} +// ── Base URL helper (uses shared getBaseUrl from auth.js) ────────────────── +const baseUrl = getBaseUrl; // ── PROPFIND response builders ───────────────────────────────────────────── diff --git a/server/routes/calendar.js b/server/routes/calendar.js index 5bbb9a1..051cb3d 100644 --- a/server/routes/calendar.js +++ b/server/routes/calendar.js @@ -1,7 +1,7 @@ import { Router } from 'express'; import crypto from 'crypto'; import { getDb } from '../config/database.js'; -import { authenticateToken } from '../middleware/auth.js'; +import { authenticateToken, getBaseUrl } from '../middleware/auth.js'; import { log } from '../config/logger.js'; import { sendCalendarInviteEmail } from '../config/mailer.js'; import { @@ -304,7 +304,7 @@ router.post('/events/:id/share', authenticateToken, async (req, res) => { // Send notification email (fire-and-forget) const targetUser = await db.get('SELECT name, display_name, email, language FROM users WHERE id = ?', [user_id]); if (targetUser?.email) { - const appUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`; + const appUrl = getBaseUrl(req); const inboxUrl = `${appUrl}/federation/inbox`; const appName = process.env.APP_NAME || 'Redlight'; const senderUser = await db.get('SELECT name, display_name FROM users WHERE id = ?', [req.user.id]); @@ -469,7 +469,7 @@ router.get('/events/:id/ics', authenticateToken, async (req, res) => { } // Build room join URL if linked - const baseUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`; + const baseUrl = getBaseUrl(req); let location = ''; if (event.room_uid) { location = `${baseUrl}/join/${event.room_uid}`; @@ -506,7 +506,7 @@ router.post('/events/:id/federation', authenticateToken, async (req, res) => { const event = await db.get('SELECT * FROM calendar_events WHERE id = ? AND user_id = ?', [req.params.id, req.user.id]); if (!event) return res.status(404).json({ error: 'Event not found or no permission' }); - const baseUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`; + const baseUrl = getBaseUrl(req); let joinUrl = null; if (event.room_uid) { joinUrl = `${baseUrl}/join/${event.room_uid}`; @@ -626,7 +626,7 @@ router.post(['/receive-event', '/calendar-event'], calendarFederationLimiter, as // Send notification email (fire-and-forget) if (targetUser.email) { - const appUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`; + const appUrl = getBaseUrl(req); const inboxUrl = `${appUrl}/federation/inbox`; const appName = process.env.APP_NAME || 'Redlight'; sendCalendarInviteEmail( diff --git a/server/routes/federation.js b/server/routes/federation.js index 45d6b4a..bff4a4a 100644 --- a/server/routes/federation.js +++ b/server/routes/federation.js @@ -2,7 +2,7 @@ import { v4 as uuidv4 } from 'uuid'; import { rateLimit } from 'express-rate-limit'; import { getDb } from '../config/database.js'; -import { authenticateToken } from '../middleware/auth.js'; +import { authenticateToken, getBaseUrl } from '../middleware/auth.js'; import { sendFederationInviteEmail, sendCalendarEventDeletedEmail } from '../config/mailer.js'; import { log } from '../config/logger.js'; import { createNotification } from '../config/notifications.js'; @@ -84,7 +84,7 @@ router.post('/invite', authenticateToken, async (req, res) => { // Build guest join URL for the remote user // If the room has an access code, embed it so the recipient can join without manual entry - const baseUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`; + const baseUrl = getBaseUrl(req); const joinUrl = room.access_code ? `${baseUrl}/join/${room.uid}?ac=${encodeURIComponent(room.access_code)}` : `${baseUrl}/join/${room.uid}`; @@ -236,7 +236,7 @@ router.post('/receive', federationReceiveLimiter, async (req, res) => { // Send notification email (truly fire-and-forget - never blocks the response) if (targetUser.email) { - const appUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`; + const appUrl = getBaseUrl(req); const inboxUrl = `${appUrl}/federation/inbox`; const appName = process.env.APP_NAME || 'Redlight'; sendFederationInviteEmail( diff --git a/server/routes/oauth.js b/server/routes/oauth.js index f28caf4..42f80f1 100644 --- a/server/routes/oauth.js +++ b/server/routes/oauth.js @@ -19,7 +19,7 @@ import { Router } from 'express'; import { rateLimit } from 'express-rate-limit'; import { v4 as uuidv4 } from 'uuid'; import { getDb } from '../config/database.js'; -import { generateToken } from '../middleware/auth.js'; +import { generateToken, getBaseUrl } from '../middleware/auth.js'; import { log } from '../config/logger.js'; import { getOAuthConfig, @@ -91,7 +91,7 @@ router.get('/authorize', async (req, res) => { const state = await createOAuthState('oidc', codeVerifier, returnTo); // Build callback URL - const baseUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`; + const baseUrl = getBaseUrl(req); const redirectUri = `${baseUrl}/api/oauth/callback`; // Build authorization URL @@ -119,7 +119,7 @@ router.get('/callback', callbackLimiter, async (req, res) => { const { code, state, error: oauthError, error_description } = req.query; // Build frontend error redirect helper - const baseUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`; + const baseUrl = getBaseUrl(req); const errorRedirect = (msg) => res.redirect(`${baseUrl}/oauth/callback?error=${encodeURIComponent(msg)}`); @@ -253,7 +253,7 @@ router.get('/callback', callbackLimiter, async (req, res) => { res.redirect(`${baseUrl}/oauth/callback?token=${encodeURIComponent(token)}&return_to=${encodeURIComponent(returnTo)}`); } catch (err) { log.auth.error(`OAuth callback error: ${err.message}`); - const baseUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`; + const baseUrl = getBaseUrl(req); res.redirect(`${baseUrl}/oauth/callback?error=${encodeURIComponent('OAuth authentication failed. Please try again.')}`); } }); diff --git a/server/routes/rooms.js b/server/routes/rooms.js index f82f074..ee35163 100644 --- a/server/routes/rooms.js +++ b/server/routes/rooms.js @@ -5,7 +5,7 @@ import path from 'path'; import { fileURLToPath } from 'url'; import { rateLimit } from 'express-rate-limit'; import { getDb } from '../config/database.js'; -import { authenticateToken } from '../middleware/auth.js'; +import { authenticateToken, getBaseUrl } from '../middleware/auth.js'; import { log } from '../config/logger.js'; import { createNotification } from '../config/notifications.js'; import { @@ -49,7 +49,7 @@ const router = Router(); // Build avatar URL for a user (uploaded image or generated initials) function getUserAvatarURL(req, user) { - const baseUrl = `${req.protocol}://${req.get('host')}`; + const baseUrl = getBaseUrl(req); if (user.avatar_image) { return `${baseUrl}/api/auth/avatar/${user.avatar_image}`; } @@ -475,7 +475,7 @@ router.post('/:uid/start', authenticateToken, async (req, res) => { } } - const baseUrl = `${req.protocol}://${req.get('host')}`; + const baseUrl = getBaseUrl(req); const loginURL = `${baseUrl}/join/${room.uid}`; const presentationUrl = room.presentation_file ? `${baseUrl}/uploads/presentations/${room.presentation_file}` @@ -623,7 +623,7 @@ router.post('/:uid/guest-join', guestJoinLimiter, async (req, res) => { // If meeting not running but anyone_can_start, create it if (!running && room.anyone_can_start) { - const baseUrl = `${req.protocol}://${req.get('host')}`; + const baseUrl = getBaseUrl(req); const loginURL = `${baseUrl}/join/${room.uid}`; await createMeeting(room, baseUrl, loginURL); } @@ -634,7 +634,7 @@ router.post('/:uid/guest-join', guestJoinLimiter, async (req, res) => { isModerator = true; } - const baseUrl = `${req.protocol}://${req.get('host')}`; + const baseUrl = getBaseUrl(req); const guestAvatarURL = `${baseUrl}/api/auth/avatar/initials/${encodeURIComponent(name.trim())}`; const joinUrl = await joinMeeting(room.uid, name.trim(), isModerator, guestAvatarURL); res.json({ joinUrl }); diff --git a/src/i18n/de.json b/src/i18n/de.json index 8fa3b50..9a923a6 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -507,6 +507,7 @@ "linkedRoom": "Verknüpfter Raum", "noRoom": "Kein Raum (kein Videomeeting)", "linkedRoomHint": "Verknüpfe einen Raum, um die Beitritts-URL automatisch ins Event einzufügen.", + "timezone": "Zeitzone", "color": "Farbe", "eventCreated": "Event erstellt!", "eventUpdated": "Event aktualisiert!", diff --git a/src/i18n/en.json b/src/i18n/en.json index f58b4eb..7d62fc2 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -507,6 +507,7 @@ "linkedRoom": "Linked Room", "noRoom": "No room (no video meeting)", "linkedRoomHint": "Link a room to automatically include the join-URL in the event.", + "timezone": "Timezone", "color": "Color", "eventCreated": "Event created!", "eventUpdated": "Event updated!", diff --git a/src/pages/Calendar.jsx b/src/pages/Calendar.jsx index dbf92f5..23e5883 100644 --- a/src/pages/Calendar.jsx +++ b/src/pages/Calendar.jsx @@ -554,6 +554,9 @@ export default function Calendar() { /> +

+ {t('calendar.timezone')}: {getLocalTimezone()} +

@@ -606,6 +609,7 @@ export default function Calendar() { {new Date(showDetail.start_time).toLocaleString()} - {new Date(showDetail.end_time).toLocaleString()} + ({getLocalTimezone()})
{showDetail.description && ( @@ -846,3 +850,15 @@ function formatTime(dateStr) { const d = new Date(dateStr); return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } + +function getLocalTimezone() { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone; + } catch { + const offset = -new Date().getTimezoneOffset(); + const sign = offset >= 0 ? '+' : '-'; + const h = String(Math.floor(Math.abs(offset) / 60)).padStart(2, '0'); + const m = String(Math.abs(offset) % 60).padStart(2, '0'); + return `UTC${sign}${h}:${m}`; + } +}