Files
BetterDesk/web-nodejs/services/billingClockConfigService.js
T
UNITRONIX 9d7a08e7ab feat(commercialization): group packages, billing periods, and RD reports
Modernize Commercialization on packages/sessions IA: Settings Time tab with
BILLING_TIMEZONE, group-only packages, monthly period history with midnight
split, Web Remote report overlay, pending_report in Sessions, and package
preview as a centered modal. Includes panel form polish and desktop-client
API notes.

Refs #456

Thanks: INSOLVE (Honorary); Marco Jakobs (@jacotec); MyNameisStitch (@MyNameisStitch); Redspin (@playerumpknow); muopc (@muopc)
2026-10-18 22:45:17 +02:00

258 lines
8.3 KiB
JavaScript

'use strict';
const fs = require('fs');
const net = require('net');
const path = require('path');
const config = require('../config/config');
const { upsertEnvKey } = require('../lib/envMerge');
const updateService = require('./updateService');
const serverConnectionConfigService = require('./serverConnectionConfigService');
const { apiClient } = require('./betterdeskApi');
const CONSOLE_ROOT = path.join(__dirname, '..');
const ENV_PATH = path.join(CONSOLE_ROOT, '.env');
const ENV_KEYS = {
ntp_servers: 'NTP_SERVERS',
max_skew_ms: 'BILLING_MAX_CLOCK_SKEW_MS',
require_synced_clock: 'BILLING_REQUIRE_SYNCED_CLOCK',
trust_os_ntp: 'BILLING_TRUST_OS_NTP',
billing_timezone: 'BILLING_TIMEZONE',
};
/** Common IANA zones for the Settings picker (custom values still allowed if valid). */
const COMMON_TIMEZONES = [
'UTC',
'Europe/Warsaw',
'Europe/Berlin',
'Europe/London',
'Europe/Paris',
'Europe/Amsterdam',
'Europe/Madrid',
'Europe/Rome',
'Europe/Prague',
'Europe/Stockholm',
'Europe/Helsinki',
'Europe/Athens',
'Europe/Bucharest',
'Europe/Istanbul',
'Europe/Kyiv',
'America/New_York',
'America/Chicago',
'America/Denver',
'America/Los_Angeles',
'America/Sao_Paulo',
'Asia/Tokyo',
'Asia/Shanghai',
'Asia/Hong_Kong',
'Asia/Singapore',
'Asia/Kolkata',
'Asia/Dubai',
'Australia/Sydney',
'Pacific/Auckland',
];
const DEFAULTS = {
ntp_servers: 'pool.ntp.org,time.google.com,time.cloudflare.com',
max_skew_ms: 2000,
require_synced_clock: true,
trust_os_ntp: process.platform === 'linux',
billing_timezone: 'UTC',
};
function detectOsTimezone() {
try {
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
if (tz && isValidIanaTimezone(tz)) return tz;
} catch (_) { /* fall through */ }
const envTz = String(process.env.TZ || '').trim();
if (envTz && isValidIanaTimezone(envTz)) return envTz;
return 'UTC';
}
function isValidIanaTimezone(tz) {
const value = String(tz || '').trim();
if (!value || value.length > 64) return false;
try {
Intl.DateTimeFormat('en-US', { timeZone: value });
return true;
} catch (_) {
return false;
}
}
function parseEnvFile(content) {
const out = {};
if (!content) return out;
for (const line of String(content).split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eq = trimmed.indexOf('=');
if (eq <= 0) continue;
out[trimmed.slice(0, eq).trim()] = trimmed.slice(eq + 1).trim();
}
return out;
}
function envToBool(value, defaultValue) {
if (value === undefined || value === null || value === '') return defaultValue;
const v = String(value).trim().toUpperCase();
if (['Y', 'YES', '1', 'TRUE', 'ON'].includes(v)) return true;
if (['N', 'NO', '0', 'FALSE', 'OFF'].includes(v)) return false;
return defaultValue;
}
function boolToEnv(value) {
return value ? '1' : '0';
}
function isValidNTPServerEntry(entry) {
const s = String(entry || '').trim();
if (!s || s.length > 253) return false;
if (net.isIP(s)) return true;
return /^[a-zA-Z0-9]([a-zA-Z0-9.-]*[a-zA-Z0-9])?$/.test(s);
}
function normalizeSettings(raw = {}) {
const servers = String(raw.ntp_servers ?? DEFAULTS.ntp_servers)
.split(',')
.map((s) => s.trim())
.filter(Boolean);
const maxSkew = Number.parseInt(raw.max_skew_ms, 10);
let billingTimezone = String(raw.billing_timezone ?? DEFAULTS.billing_timezone).trim();
if (!billingTimezone) billingTimezone = DEFAULTS.billing_timezone;
return {
ntp_servers: servers.join(','),
max_skew_ms: Number.isFinite(maxSkew) && maxSkew > 0 ? maxSkew : DEFAULTS.max_skew_ms,
require_synced_clock: raw.require_synced_clock !== undefined
? !!raw.require_synced_clock
: DEFAULTS.require_synced_clock,
trust_os_ntp: raw.trust_os_ntp !== undefined
? !!raw.trust_os_ntp
: DEFAULTS.trust_os_ntp,
billing_timezone: billingTimezone,
};
}
function validateSettings(settings) {
const normalized = normalizeSettings(settings);
const servers = normalized.ntp_servers.split(',').map((s) => s.trim()).filter(Boolean);
if (!servers.length) {
throw new Error('invalid_ntp_servers');
}
for (const server of servers) {
if (!isValidNTPServerEntry(server)) {
throw new Error('invalid_ntp_servers');
}
}
if (normalized.max_skew_ms <= 0 || normalized.max_skew_ms > 600000) {
throw new Error('invalid_max_skew');
}
if (!isValidIanaTimezone(normalized.billing_timezone)) {
throw new Error('invalid_billing_timezone');
}
return normalized;
}
function settingsFromEnvContent(content) {
const env = parseEnvFile(content);
const fromEnv = env[ENV_KEYS.billing_timezone];
return normalizeSettings({
ntp_servers: env[ENV_KEYS.ntp_servers] || DEFAULTS.ntp_servers,
max_skew_ms: env[ENV_KEYS.max_skew_ms] || DEFAULTS.max_skew_ms,
require_synced_clock: envToBool(env[ENV_KEYS.require_synced_clock], DEFAULTS.require_synced_clock),
trust_os_ntp: envToBool(env[ENV_KEYS.trust_os_ntp], DEFAULTS.trust_os_ntp),
billing_timezone: fromEnv || detectOsTimezone(),
});
}
function getClockSettings() {
const content = fs.existsSync(ENV_PATH) ? fs.readFileSync(ENV_PATH, 'utf8') : '';
return settingsFromEnvContent(content);
}
function isDockerSplitDeployment() {
return config.isDocker === true
&& String(process.env.BETTERDESK_DOCKER_LAYOUT || '').trim().toLowerCase() === 'split';
}
async function getRuntimeClockSettings() {
const local = getClockSettings();
if (!isDockerSplitDeployment()) return local;
const response = await apiClient.get('/timesync/config');
const remote = response.data || {};
return normalizeSettings({
...remote,
// Prefer remote timezone when Go persists it; else keep console .env / OS detect.
billing_timezone: remote.billing_timezone || local.billing_timezone,
});
}
function writeClockSettingsToEnv(settings) {
const normalized = validateSettings(settings);
let content = fs.existsSync(ENV_PATH) ? fs.readFileSync(ENV_PATH, 'utf8') : '';
content = upsertEnvKey(content, ENV_KEYS.ntp_servers, normalized.ntp_servers);
content = upsertEnvKey(content, ENV_KEYS.max_skew_ms, String(normalized.max_skew_ms));
content = upsertEnvKey(content, ENV_KEYS.require_synced_clock, boolToEnv(normalized.require_synced_clock));
content = upsertEnvKey(content, ENV_KEYS.trust_os_ntp, boolToEnv(normalized.trust_os_ntp));
content = upsertEnvKey(content, ENV_KEYS.billing_timezone, normalized.billing_timezone);
fs.writeFileSync(ENV_PATH, content, { encoding: 'utf8', mode: 0o600 });
return normalized;
}
function restoreClockSettings(settings) {
return writeClockSettingsToEnv(settings);
}
async function saveClockSettings(settings, opts = {}) {
const normalized = validateSettings(settings);
if (isDockerSplitDeployment()) {
const response = await apiClient.put('/timesync/config', normalized);
const data = response.data || {};
if (data.error) throw new Error(data.error);
try {
writeClockSettingsToEnv(normalized);
} catch (_) { /* console .env may be read-only in some layouts */ }
return {
settings: normalizeSettings(data.config || normalized),
serviceConfig: null,
status: data.status || null,
restart: null,
dockerMode: true,
};
}
writeClockSettingsToEnv(normalized);
const serviceConfig = updateService.sanitizeGoServerServiceConfig();
const result = {
settings: normalized,
serviceConfig,
restart: null,
};
if (opts.restart !== false) {
result.restart = serverConnectionConfigService.restartServer();
}
return result;
}
module.exports = {
DEFAULTS,
COMMON_TIMEZONES,
ENV_PATH,
ENV_KEYS,
parseEnvFile,
envToBool,
boolToEnv,
isValidNTPServerEntry,
isValidIanaTimezone,
detectOsTimezone,
normalizeSettings,
validateSettings,
settingsFromEnvContent,
getClockSettings,
getRuntimeClockSettings,
isDockerSplitDeployment,
restoreClockSettings,
saveClockSettings,
};