feat: add getBaseUrl function for consistent base URL generation across routes
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m28s
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m28s
feat(calendar): display local timezone in calendar view feat(i18n): add timezone label to German and English translations
This commit is contained in:
@@ -57,3 +57,14 @@ export function generateToken(userId) {
|
|||||||
const jti = uuidv4();
|
const jti = uuidv4();
|
||||||
return jwt.sign({ userId, jti }, JWT_SECRET, { expiresIn: '7d' });
|
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')}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
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';
|
||||||
import { authenticateToken, requireAdmin } from '../middleware/auth.js';
|
import { authenticateToken, requireAdmin, getBaseUrl } from '../middleware/auth.js';
|
||||||
import { isMailerConfigured, sendInviteEmail } from '../config/mailer.js';
|
import { isMailerConfigured, sendInviteEmail } from '../config/mailer.js';
|
||||||
import { log } from '../config/logger.js';
|
import { log } from '../config/logger.js';
|
||||||
import {
|
import {
|
||||||
@@ -208,7 +208,7 @@ router.post('/invites', authenticateToken, requireAdmin, async (req, res) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Send invite email if SMTP is configured
|
// 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}`;
|
const inviteUrl = `${baseUrl}/register?invite=${token}`;
|
||||||
|
|
||||||
// Load app name
|
// Load app name
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { rateLimit } from 'express-rate-limit';
|
|||||||
import { RedisStore } from 'rate-limit-redis';
|
import { RedisStore } from 'rate-limit-redis';
|
||||||
import { getDb } from '../config/database.js';
|
import { getDb } from '../config/database.js';
|
||||||
import redis from '../config/redis.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 { isMailerConfigured, sendVerificationEmail } from '../config/mailer.js';
|
||||||
import { log } from '../config/logger.js';
|
import { log } from '../config/logger.js';
|
||||||
|
|
||||||
@@ -189,7 +189,7 @@ router.post('/register', registerLimiter, async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build verification URL
|
// 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}`;
|
const verifyUrl = `${baseUrl}/verify-email?token=${verificationToken}`;
|
||||||
|
|
||||||
// Load app name from branding settings
|
// Load app name from branding settings
|
||||||
@@ -303,7 +303,7 @@ router.post('/resend-verification', resendVerificationLimiter, async (req, res)
|
|||||||
[verificationToken, expires, now, user.id]
|
[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 verifyUrl = `${baseUrl}/verify-email?token=${verificationToken}`;
|
||||||
|
|
||||||
const brandingSetting = await db.get("SELECT value FROM settings WHERE key = 'branding'");
|
const brandingSetting = await db.get("SELECT value FROM settings WHERE key = 'branding'");
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { Router, text } from 'express';
|
|||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import { getDb } from '../config/database.js';
|
import { getDb } from '../config/database.js';
|
||||||
import { log } from '../config/logger.js';
|
import { log } from '../config/logger.js';
|
||||||
|
import { getBaseUrl } from '../middleware/auth.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -239,12 +240,8 @@ function validateCalDAVUser(req, res, next) {
|
|||||||
next();
|
next();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Base URL helper ────────────────────────────────────────────────────────
|
// ── Base URL helper (uses shared getBaseUrl from auth.js) ──────────────────
|
||||||
function baseUrl(req) {
|
const baseUrl = getBaseUrl;
|
||||||
const proto = req.get('x-forwarded-proto') || req.protocol;
|
|
||||||
const host = req.get('x-forwarded-host') || req.get('host');
|
|
||||||
return `${proto}://${host}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── PROPFIND response builders ─────────────────────────────────────────────
|
// ── PROPFIND response builders ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
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, getBaseUrl } from '../middleware/auth.js';
|
||||||
import { log } from '../config/logger.js';
|
import { log } from '../config/logger.js';
|
||||||
import { sendCalendarInviteEmail } from '../config/mailer.js';
|
import { sendCalendarInviteEmail } from '../config/mailer.js';
|
||||||
import {
|
import {
|
||||||
@@ -304,7 +304,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, language 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 = getBaseUrl(req);
|
||||||
const inboxUrl = `${appUrl}/federation/inbox`;
|
const inboxUrl = `${appUrl}/federation/inbox`;
|
||||||
const appName = process.env.APP_NAME || 'Redlight';
|
const appName = process.env.APP_NAME || 'Redlight';
|
||||||
const senderUser = await db.get('SELECT name, display_name FROM users WHERE id = ?', [req.user.id]);
|
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
|
// Build room join URL if linked
|
||||||
const baseUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`;
|
const baseUrl = getBaseUrl(req);
|
||||||
let location = '';
|
let location = '';
|
||||||
if (event.room_uid) {
|
if (event.room_uid) {
|
||||||
location = `${baseUrl}/join/${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]);
|
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' });
|
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;
|
let joinUrl = null;
|
||||||
if (event.room_uid) {
|
if (event.room_uid) {
|
||||||
joinUrl = `${baseUrl}/join/${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)
|
// Send notification email (fire-and-forget)
|
||||||
if (targetUser.email) {
|
if (targetUser.email) {
|
||||||
const appUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`;
|
const appUrl = getBaseUrl(req);
|
||||||
const inboxUrl = `${appUrl}/federation/inbox`;
|
const inboxUrl = `${appUrl}/federation/inbox`;
|
||||||
const appName = process.env.APP_NAME || 'Redlight';
|
const appName = process.env.APP_NAME || 'Redlight';
|
||||||
sendCalendarInviteEmail(
|
sendCalendarInviteEmail(
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
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';
|
||||||
import { authenticateToken } from '../middleware/auth.js';
|
import { authenticateToken, getBaseUrl } from '../middleware/auth.js';
|
||||||
import { sendFederationInviteEmail, sendCalendarEventDeletedEmail } from '../config/mailer.js';
|
import { sendFederationInviteEmail, sendCalendarEventDeletedEmail } from '../config/mailer.js';
|
||||||
import { log } from '../config/logger.js';
|
import { log } from '../config/logger.js';
|
||||||
import { createNotification } from '../config/notifications.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
|
// 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
|
// 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
|
const joinUrl = room.access_code
|
||||||
? `${baseUrl}/join/${room.uid}?ac=${encodeURIComponent(room.access_code)}`
|
? `${baseUrl}/join/${room.uid}?ac=${encodeURIComponent(room.access_code)}`
|
||||||
: `${baseUrl}/join/${room.uid}`;
|
: `${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)
|
// 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 = getBaseUrl(req);
|
||||||
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(
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { Router } from 'express';
|
|||||||
import { rateLimit } from 'express-rate-limit';
|
import { rateLimit } from 'express-rate-limit';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { getDb } from '../config/database.js';
|
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 { log } from '../config/logger.js';
|
||||||
import {
|
import {
|
||||||
getOAuthConfig,
|
getOAuthConfig,
|
||||||
@@ -91,7 +91,7 @@ router.get('/authorize', async (req, res) => {
|
|||||||
const state = await createOAuthState('oidc', codeVerifier, returnTo);
|
const state = await createOAuthState('oidc', codeVerifier, returnTo);
|
||||||
|
|
||||||
// Build callback URL
|
// Build callback URL
|
||||||
const baseUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`;
|
const baseUrl = getBaseUrl(req);
|
||||||
const redirectUri = `${baseUrl}/api/oauth/callback`;
|
const redirectUri = `${baseUrl}/api/oauth/callback`;
|
||||||
|
|
||||||
// Build authorization URL
|
// Build authorization URL
|
||||||
@@ -119,7 +119,7 @@ router.get('/callback', callbackLimiter, async (req, res) => {
|
|||||||
const { code, state, error: oauthError, error_description } = req.query;
|
const { code, state, error: oauthError, error_description } = req.query;
|
||||||
|
|
||||||
// Build frontend error redirect helper
|
// Build frontend error redirect helper
|
||||||
const baseUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`;
|
const baseUrl = getBaseUrl(req);
|
||||||
const errorRedirect = (msg) =>
|
const errorRedirect = (msg) =>
|
||||||
res.redirect(`${baseUrl}/oauth/callback?error=${encodeURIComponent(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)}`);
|
res.redirect(`${baseUrl}/oauth/callback?token=${encodeURIComponent(token)}&return_to=${encodeURIComponent(returnTo)}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.auth.error(`OAuth callback error: ${err.message}`);
|
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.')}`);
|
res.redirect(`${baseUrl}/oauth/callback?error=${encodeURIComponent('OAuth authentication failed. Please try again.')}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import path from 'path';
|
|||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import { rateLimit } from 'express-rate-limit';
|
import { rateLimit } from 'express-rate-limit';
|
||||||
import { getDb } from '../config/database.js';
|
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 { log } from '../config/logger.js';
|
||||||
import { createNotification } from '../config/notifications.js';
|
import { createNotification } from '../config/notifications.js';
|
||||||
import {
|
import {
|
||||||
@@ -49,7 +49,7 @@ const router = Router();
|
|||||||
|
|
||||||
// Build avatar URL for a user (uploaded image or generated initials)
|
// Build avatar URL for a user (uploaded image or generated initials)
|
||||||
function getUserAvatarURL(req, user) {
|
function getUserAvatarURL(req, user) {
|
||||||
const baseUrl = `${req.protocol}://${req.get('host')}`;
|
const baseUrl = getBaseUrl(req);
|
||||||
if (user.avatar_image) {
|
if (user.avatar_image) {
|
||||||
return `${baseUrl}/api/auth/avatar/${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 loginURL = `${baseUrl}/join/${room.uid}`;
|
||||||
const presentationUrl = room.presentation_file
|
const presentationUrl = room.presentation_file
|
||||||
? `${baseUrl}/uploads/presentations/${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 meeting not running but anyone_can_start, create it
|
||||||
if (!running && room.anyone_can_start) {
|
if (!running && room.anyone_can_start) {
|
||||||
const baseUrl = `${req.protocol}://${req.get('host')}`;
|
const baseUrl = getBaseUrl(req);
|
||||||
const loginURL = `${baseUrl}/join/${room.uid}`;
|
const loginURL = `${baseUrl}/join/${room.uid}`;
|
||||||
await createMeeting(room, baseUrl, loginURL);
|
await createMeeting(room, baseUrl, loginURL);
|
||||||
}
|
}
|
||||||
@@ -634,7 +634,7 @@ router.post('/:uid/guest-join', guestJoinLimiter, async (req, res) => {
|
|||||||
isModerator = true;
|
isModerator = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseUrl = `${req.protocol}://${req.get('host')}`;
|
const baseUrl = getBaseUrl(req);
|
||||||
const guestAvatarURL = `${baseUrl}/api/auth/avatar/initials/${encodeURIComponent(name.trim())}`;
|
const guestAvatarURL = `${baseUrl}/api/auth/avatar/initials/${encodeURIComponent(name.trim())}`;
|
||||||
const joinUrl = await joinMeeting(room.uid, name.trim(), isModerator, guestAvatarURL);
|
const joinUrl = await joinMeeting(room.uid, name.trim(), isModerator, guestAvatarURL);
|
||||||
res.json({ joinUrl });
|
res.json({ joinUrl });
|
||||||
|
|||||||
@@ -507,6 +507,7 @@
|
|||||||
"linkedRoom": "Verknüpfter Raum",
|
"linkedRoom": "Verknüpfter Raum",
|
||||||
"noRoom": "Kein Raum (kein Videomeeting)",
|
"noRoom": "Kein Raum (kein Videomeeting)",
|
||||||
"linkedRoomHint": "Verknüpfe einen Raum, um die Beitritts-URL automatisch ins Event einzufügen.",
|
"linkedRoomHint": "Verknüpfe einen Raum, um die Beitritts-URL automatisch ins Event einzufügen.",
|
||||||
|
"timezone": "Zeitzone",
|
||||||
"color": "Farbe",
|
"color": "Farbe",
|
||||||
"eventCreated": "Event erstellt!",
|
"eventCreated": "Event erstellt!",
|
||||||
"eventUpdated": "Event aktualisiert!",
|
"eventUpdated": "Event aktualisiert!",
|
||||||
|
|||||||
@@ -507,6 +507,7 @@
|
|||||||
"linkedRoom": "Linked Room",
|
"linkedRoom": "Linked Room",
|
||||||
"noRoom": "No room (no video meeting)",
|
"noRoom": "No room (no video meeting)",
|
||||||
"linkedRoomHint": "Link a room to automatically include the join-URL in the event.",
|
"linkedRoomHint": "Link a room to automatically include the join-URL in the event.",
|
||||||
|
"timezone": "Timezone",
|
||||||
"color": "Color",
|
"color": "Color",
|
||||||
"eventCreated": "Event created!",
|
"eventCreated": "Event created!",
|
||||||
"eventUpdated": "Event updated!",
|
"eventUpdated": "Event updated!",
|
||||||
|
|||||||
@@ -554,6 +554,9 @@ export default function Calendar() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-xs text-th-text-s -mt-2">
|
||||||
|
{t('calendar.timezone')}: {getLocalTimezone()}
|
||||||
|
</p>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('calendar.linkedRoom')}</label>
|
<label className="block text-sm font-medium text-th-text mb-1.5">{t('calendar.linkedRoom')}</label>
|
||||||
@@ -606,6 +609,7 @@ export default function Calendar() {
|
|||||||
<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>
|
||||||
|
<span className="text-xs opacity-70">({getLocalTimezone()})</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showDetail.description && (
|
{showDetail.description && (
|
||||||
@@ -846,3 +850,15 @@ function formatTime(dateStr) {
|
|||||||
const d = new Date(dateStr);
|
const d = new Date(dateStr);
|
||||||
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
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}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user