Aplikasi billing & manajemen ISP (fiber optic) — PWA pelanggan, portal teknisi & kolektor, admin panel lengkap, multi-vendor OLT, RADIUS, WhatsApp/Telegram, pembayaran QRIS. Kredit & lisensi (ISC): - Base kode & repo: alijayanet/billing-rtrw oleh Alijaya Net — terima kasih banyak atas base kode dan repo yang bermanfaat. - Add-on: GoWA, LibreSpeed/OpenSpeedTest, Uptime Kuma, notification-forwarder. - Dikembangkan menjadi OPEN-ISP oleh oxpoe — https://github.com/oxpoe/open-isp
355 lines
11 KiB
JavaScript
355 lines
11 KiB
JavaScript
const fs = require('fs');
|
|
const path = require('path');
|
|
const crypto = require('crypto');
|
|
const { logger } = require('./logger');
|
|
|
|
let runtimeSessionSecret = null;
|
|
function getSecureSessionSecretFallback() {
|
|
if (!runtimeSessionSecret) {
|
|
runtimeSessionSecret = crypto.randomBytes(32).toString('hex');
|
|
}
|
|
return runtimeSessionSecret;
|
|
}
|
|
|
|
// Cache untuk settings dengan timestamp
|
|
let settingsCache = null;
|
|
let settingsCacheTime = 0;
|
|
const CACHE_DURATION = 2000; // 2 detik
|
|
|
|
// File system watcher untuk auto-reload settings
|
|
const settingsPath = path.join(__dirname, '../settings.json');
|
|
let watcher = null;
|
|
|
|
// Helper untuk baca settings.json secara dinamis
|
|
function getSettings() {
|
|
try {
|
|
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) || {};
|
|
|
|
// Secure fallback for session_secret
|
|
const defaultSecret = 'rahasia-portal-pelanggan-default-ganti-ini';
|
|
if (!settings.session_secret || settings.session_secret === defaultSecret) {
|
|
settings.session_secret = getSecureSessionSecretFallback();
|
|
}
|
|
|
|
const fallbackTz = 'Asia/Jakarta';
|
|
const tz = typeof settings.timezone === 'string' ? settings.timezone.trim() : '';
|
|
|
|
if (!tz) {
|
|
settings.timezone = fallbackTz;
|
|
return settings;
|
|
}
|
|
|
|
try {
|
|
new Intl.DateTimeFormat('en-US', { timeZone: tz }).format(new Date());
|
|
settings.timezone = tz;
|
|
} catch (e) {
|
|
settings.timezone = fallbackTz;
|
|
}
|
|
|
|
return settings;
|
|
} catch (error) {
|
|
logger.error(`[settings] Error reading settings.json: ${error.message}`);
|
|
// Kembalikan cache terakhir yang valid (hindari kehilangan setting karena file setengah tertulis)
|
|
return settingsCache || {};
|
|
}
|
|
}
|
|
|
|
// Helper untuk baca settings.json dengan cache
|
|
function getSettingsWithCache() {
|
|
const now = Date.now();
|
|
if (!settingsCache || (now - settingsCacheTime) > CACHE_DURATION) {
|
|
settingsCache = getSettings();
|
|
settingsCacheTime = now;
|
|
}
|
|
return settingsCache;
|
|
}
|
|
|
|
// Helper untuk mendapatkan nilai setting dengan fallback
|
|
function getSetting(key, defaultValue = null) {
|
|
const settings = getSettingsWithCache();
|
|
return settings[key] !== undefined ? settings[key] : defaultValue;
|
|
}
|
|
|
|
// Helper untuk mendapatkan multiple settings
|
|
function getSettingsByKeys(keys) {
|
|
const settings = getSettingsWithCache();
|
|
const result = {};
|
|
keys.forEach(key => {
|
|
result[key] = settings[key];
|
|
});
|
|
return result;
|
|
}
|
|
|
|
// File system watcher untuk auto-reload settings
|
|
function startSettingsWatcher() {
|
|
try {
|
|
// Hapus watcher lama jika ada
|
|
if (watcher) {
|
|
watcher.close();
|
|
}
|
|
|
|
// Buat watcher baru
|
|
watcher = fs.watch(settingsPath, (eventType, filename) => {
|
|
if (eventType !== 'change') return;
|
|
// Di Windows `filename` sering null; hanya abaikan jika jelas bukan settings.json
|
|
if (filename != null && filename !== 'settings.json') return;
|
|
|
|
settingsCache = null;
|
|
settingsCacheTime = 0;
|
|
|
|
try {
|
|
const s = getSettingsWithCache();
|
|
const port = s.server_port ?? 4555;
|
|
const host = s.server_host || 'localhost';
|
|
const gurl = s.genieacs_url || '(tidak diatur)';
|
|
const company = s.company_header || '(default)';
|
|
logger.info(`[settings] settings.json dimuat ulang — port ${port}, host ${host}, company: ${company}, GenieACS: ${gurl}`);
|
|
} catch (error) {
|
|
logger.error(`[settings] Gagal memuat ulang settings.json: ${error.message}`);
|
|
}
|
|
});
|
|
|
|
logger.info('[settings] Memantau perubahan settings.json');
|
|
} catch (error) {
|
|
logger.error(`[settings] Error starting settings watcher: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
// Mulai watcher saat modul dimuat
|
|
startSettingsWatcher();
|
|
|
|
// Menyimpan pengaturan ke settings.json
|
|
function saveSettings(newSettings) {
|
|
try {
|
|
const currentSettings = getSettings();
|
|
const updatedSettings = { ...currentSettings, ...newSettings };
|
|
// Cadangan rolling versi sebelumnya (mencegah kehilangan setting bila tertimpa salah)
|
|
try {
|
|
if (fs.existsSync(settingsPath)) {
|
|
fs.copyFileSync(settingsPath, settingsPath + '.prev');
|
|
}
|
|
} catch (e) { /* abaikan */ }
|
|
const tmpPath = settingsPath + '.tmp-' + process.pid;
|
|
fs.writeFileSync(tmpPath, JSON.stringify(updatedSettings, null, 2), 'utf-8');
|
|
fs.renameSync(tmpPath, settingsPath); // atomik: pembaca tidak pernah melihat file setengah jadi
|
|
settingsCache = updatedSettings;
|
|
settingsCacheTime = Date.now();
|
|
return true;
|
|
} catch (error) {
|
|
logger.error(`[settings] Error saving settings.json: ${error.message}`);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Helper untuk mendapatkan waktu sekarang dalam format lokal
|
|
* sesuai timezone yang diatur di settings.json
|
|
*/
|
|
function getNowLocal() {
|
|
const tz = getSetting('timezone', 'Asia/Jakarta');
|
|
const now = new Date();
|
|
const options = {
|
|
timeZone: tz,
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
second: '2-digit',
|
|
hourCycle: 'h23' // 💡 Paksa format jam 00-23 (mencegah bug '24' di tengah malam)
|
|
};
|
|
const formatter = new Intl.DateTimeFormat('en-US', options);
|
|
const parts = formatter.formatToParts(now);
|
|
const p = {};
|
|
parts.forEach(part => p[part.type] = part.value);
|
|
return `${p.year}-${p.month}-${p.day} ${p.hour}:${p.minute}:${p.second}`;
|
|
}
|
|
|
|
/**
|
|
* Helper untuk mendapatkan objek Date yang sudah disesuaikan dengan timezone di settings.
|
|
* Mengembalikan objek Date yang "angkanya" sudah sesuai dengan waktu lokal.
|
|
*/
|
|
function getCurrentDateInTimezone() {
|
|
const tz = getSetting('timezone', 'Asia/Jakarta');
|
|
const now = new Date();
|
|
|
|
// Ambil string format ISO lokal
|
|
const formatter = new Intl.DateTimeFormat('en-US', {
|
|
timeZone: tz,
|
|
year: 'numeric', month: '2-digit', day: '2-digit',
|
|
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
|
hourCycle: 'h23' // 💡 Paksa format jam 00-23 (mencegah bug '24' di tengah malam)
|
|
});
|
|
|
|
const parts = formatter.formatToParts(now);
|
|
const p = {};
|
|
parts.forEach(part => p[part.type] = part.value);
|
|
|
|
// Buat objek Date baru dengan nilai lokal tersebut
|
|
return new Date(`${p.year}-${p.month}-${p.day}T${p.hour}:${p.minute}:${p.second}`);
|
|
}
|
|
|
|
/**
|
|
* Mendapatkan info waktu sekarang (year, month, day, dll) dalam timezone yang diatur.
|
|
*/
|
|
function getCurrentTimeInfo() {
|
|
const tz = getSetting('timezone', 'Asia/Jakarta');
|
|
const now = new Date();
|
|
const formatter = new Intl.DateTimeFormat('en-US', {
|
|
timeZone: tz,
|
|
year: 'numeric', month: 'numeric', day: 'numeric',
|
|
hour: 'numeric', minute: 'numeric', second: 'numeric',
|
|
hourCycle: 'h23' // 💡 Paksa format jam 00-23 (mencegah bug '24' di tengah malam)
|
|
});
|
|
|
|
|
|
const parts = formatter.formatToParts(now);
|
|
const p = {};
|
|
parts.forEach(part => p[part.type] = part.value);
|
|
|
|
return {
|
|
year: parseInt(p.year),
|
|
month: parseInt(p.month),
|
|
day: parseInt(p.day),
|
|
hour: parseInt(p.hour),
|
|
minute: parseInt(p.minute),
|
|
second: parseInt(p.second)
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Mendapatkan string ISO-like tapi dalam waktu lokal (bukan UTC).
|
|
* Berguna untuk timestamp log/backup.
|
|
*/
|
|
function getNowLocalISO() {
|
|
const info = getCurrentTimeInfo();
|
|
const pad = (n) => String(n).padStart(2, '0');
|
|
return `${info.year}-${pad(info.month)}-${pad(info.day)}T${pad(info.hour)}:${pad(info.minute)}:${pad(info.second)}`;
|
|
}
|
|
|
|
/**
|
|
* Memparse string tanggal (YYYY-MM-DD HH:mm:ss) menjadi objek Date
|
|
* dengan asumsi string tersebut adalah waktu lokal sesuai setting timezone.
|
|
*/
|
|
function parseDateInTimezone(dateStr) {
|
|
if (!dateStr) return null;
|
|
const tz = getSetting('timezone', 'Asia/Jakarta');
|
|
|
|
const date = new Date(dateStr.replace(' ', 'T'));
|
|
if (isNaN(date.getTime())) return null;
|
|
|
|
const localDateStr = date.toLocaleString('en-US', { timeZone: tz, hourCycle: 'h23' });
|
|
const localDate = new Date(localDateStr);
|
|
const diff = localDate.getTime() - date.getTime();
|
|
|
|
return new Date(date.getTime() - diff);
|
|
}
|
|
|
|
function formatDateLocal(date) {
|
|
if (!date) return '-';
|
|
const tz = getSetting('timezone', 'Asia/Jakarta');
|
|
let d;
|
|
if (typeof date === 'string') {
|
|
d = parseDateInTimezone(date);
|
|
} else {
|
|
d = typeof date === 'number' ? new Date(date) : date;
|
|
}
|
|
if (!d || isNaN(d.getTime())) return '-';
|
|
return d.toLocaleString('id-ID', { timeZone: tz });
|
|
}
|
|
|
|
/**
|
|
* Helper untuk memformat objek Date menjadi string waktu lokal (hanya Jam:Menit)
|
|
*/
|
|
function formatTimeLocal(date) {
|
|
if (!date) return '-';
|
|
const tz = getSetting('timezone', 'Asia/Jakarta');
|
|
let d;
|
|
if (typeof date === 'string') {
|
|
d = parseDateInTimezone(date);
|
|
} else {
|
|
d = typeof date === 'number' ? new Date(date) : date;
|
|
}
|
|
if (!d || isNaN(d.getTime())) return '-';
|
|
return d.toLocaleTimeString('id-ID', { timeZone: tz, hour: '2-digit', minute: '2-digit' });
|
|
}
|
|
|
|
/**
|
|
* Memastikan semua setting wajib ada di settings.json dengan default value.
|
|
* Dijalankan saat startup aplikasi untuk migrasi/inisialisasi setting baru.
|
|
*/
|
|
function ensureDefaultSettings() {
|
|
try {
|
|
const currentSettings = getSettings();
|
|
let needsSave = false;
|
|
|
|
// Default settings yang wajib ada
|
|
const defaultSettings = {
|
|
// RADIUS Server settings (ditambahkan untuk update dari GitHub)
|
|
radius_enabled: '0',
|
|
radius_secret: 'secret123',
|
|
radius_auth_port: '1812',
|
|
radius_acct_port: '1813',
|
|
radius_isolir_action: 'pool',
|
|
radius_isolir_pool: 'isolir',
|
|
radius_limit_simultaneous: '1',
|
|
radius_default_rate_limit: '5M/10M',
|
|
radius_isolir_rate_limit: '512k/512k',
|
|
radius_isolir_ip_pool_enabled: '1',
|
|
radius_isolir_ip_pool_start: '10.10.99.2',
|
|
radius_isolir_ip_pool_end: '10.10.99.254',
|
|
radius_ip_pool_enabled: '1',
|
|
radius_ip_pool_start: '10.10.10.2',
|
|
radius_ip_pool_end: '10.10.10.254',
|
|
radius_framed_pool: 'pool-pppoe',
|
|
radius_send_group: '0',
|
|
webhook_notif_retention_enabled: '1',
|
|
webhook_notif_retention_days: '30',
|
|
finance_ppn_pct: '11',
|
|
finance_pph_pct: '22',
|
|
finance_uso_pct: '1.75',
|
|
finance_bhp_pct: '0.5',
|
|
finance_revenue_includes_ppn: '0'
|
|
};
|
|
|
|
// Cek dan tambahkan setting yang belum ada
|
|
for (const [key, defaultValue] of Object.entries(defaultSettings)) {
|
|
if (currentSettings[key] === undefined) {
|
|
currentSettings[key] = defaultValue;
|
|
needsSave = true;
|
|
logger.info(`[settings] Added missing setting: ${key} = ${defaultValue}`);
|
|
}
|
|
}
|
|
|
|
// Simpan jika ada perubahan
|
|
if (needsSave) {
|
|
fs.writeFileSync(settingsPath, JSON.stringify(currentSettings, null, 2), 'utf-8');
|
|
settingsCache = currentSettings;
|
|
settingsCacheTime = Date.now();
|
|
logger.info('[settings] settings.json updated with default RADIUS settings');
|
|
}
|
|
|
|
return needsSave;
|
|
} catch (error) {
|
|
logger.error(`[settings] Error ensuring default settings: ${error.message}`);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
getSettings,
|
|
getSettingsWithCache,
|
|
getSetting,
|
|
getSettingsByKeys,
|
|
saveSettings,
|
|
getNowLocal,
|
|
formatDateLocal,
|
|
formatTimeLocal,
|
|
getCurrentDateInTimezone,
|
|
getCurrentTimeInfo,
|
|
getNowLocalISO,
|
|
parseDateInTimezone,
|
|
startSettingsWatcher,
|
|
ensureDefaultSettings
|
|
};
|