OPEN-ISP v0.5 (pickle-lime) — rilis open-source perdana
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
This commit is contained in:
commit
eaf05f2811
280 files changed
+123975
No files matched your search
@@ -0,0 +1,296 @@
|
||||
// Modul untuk menambahkan nomor pelanggan ke tag GenieACS
|
||||
const axios = require('axios');
|
||||
require('dotenv').config();
|
||||
const { logger } = require('./logger');
|
||||
|
||||
let sock = null;
|
||||
|
||||
// Fungsi untuk set instance sock
|
||||
function setSock(sockInstance) {
|
||||
sock = sockInstance;
|
||||
}
|
||||
|
||||
// Fungsi untuk menambahkan tag pelanggan ke perangkat GenieACS
|
||||
async function addCustomerTag(remoteJid, params) {
|
||||
try {
|
||||
// Ekstrak parameter
|
||||
if (params.length < 2) {
|
||||
await sock.sendMessage(remoteJid, {
|
||||
text: `❌ *Format Salah*\n\nFormat yang benar:\naddtag [device_id] [nomor_pelanggan]\n\nContoh:\naddtag 202BC1-BM632w-000000 081234567890`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const [deviceId, customerNumber] = params;
|
||||
|
||||
// Validasi nomor pelanggan
|
||||
if (!customerNumber || !/^\d{8,}$/.test(customerNumber)) {
|
||||
await sock.sendMessage(remoteJid, {
|
||||
text: `❌ *Nomor Pelanggan Tidak Valid*\n\nNomor pelanggan harus berupa angka minimal 8 digit.`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Dapatkan URL GenieACS
|
||||
const genieacsUrl = global.appSettings.genieacsUrl || process.env.GENIEACS_URL;
|
||||
if (!genieacsUrl) {
|
||||
await sock.sendMessage(remoteJid, {
|
||||
text: `❌ *Konfigurasi tidak lengkap*\n\nURL GenieACS tidak dikonfigurasi`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Kirim pesan bahwa proses sedang berlangsung
|
||||
await sock.sendMessage(remoteJid, {
|
||||
text: `⏳ *Proses penambahan tag*\n\nSedang menambahkan nomor ${customerNumber} ke perangkat ${deviceId}...`
|
||||
});
|
||||
|
||||
// Persiapkan URL untuk menambahkan tag
|
||||
const tagUrl = `${genieacsUrl}/devices/${deviceId}/tags/${customerNumber}`;
|
||||
|
||||
// Kirim request ke GenieACS untuk menambahkan tag
|
||||
try {
|
||||
const response = await axios.post(
|
||||
tagUrl,
|
||||
{},
|
||||
{
|
||||
auth: {
|
||||
username: global.appSettings.genieacsUsername || process.env.GENIEACS_USERNAME,
|
||||
password: global.appSettings.genieacsPassword || process.env.GENIEACS_PASSWORD
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
logger.info(`Tag response: ${response.status}`);
|
||||
|
||||
// Kirim pesan sukses
|
||||
let successMessage = `✅ *Tag berhasil ditambahkan*\n\n`;
|
||||
successMessage += `📱 *Nomor Pelanggan:* ${customerNumber}\n`;
|
||||
successMessage += `🖥️ *Device ID:* ${deviceId}\n\n`;
|
||||
successMessage += `Pelanggan sekarang dapat menggunakan WhatsApp dan Web Portal dengan nomor tersebut.`;
|
||||
|
||||
await sock.sendMessage(remoteJid, { text: successMessage });
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Error adding tag to GenieACS:', error);
|
||||
|
||||
let errorMessage = `❌ *Gagal menambahkan tag*\n\n`;
|
||||
if (error.response) {
|
||||
errorMessage += `Status: ${error.response.status}\n`;
|
||||
errorMessage += `Pesan: ${JSON.stringify(error.response.data)}\n`;
|
||||
} else {
|
||||
errorMessage += `Error: ${error.message}\n`;
|
||||
}
|
||||
|
||||
await sock.sendMessage(remoteJid, { text: errorMessage });
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Error in addCustomerTag:', error);
|
||||
|
||||
await sock.sendMessage(remoteJid, {
|
||||
text: `❌ *Error*\n\nTerjadi kesalahan saat menambahkan tag: ${error.message}`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Fungsi untuk mencari perangkat berdasarkan ID
|
||||
async function findDeviceById(deviceId) {
|
||||
try {
|
||||
// Dapatkan URL GenieACS
|
||||
const genieacsUrl = global.appSettings.genieacsUrl || process.env.GENIEACS_URL;
|
||||
if (!genieacsUrl) {
|
||||
logger.error('GenieACS URL not configured');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Buat query untuk mencari perangkat berdasarkan ID
|
||||
const queryObj = { "_id": deviceId };
|
||||
const queryJson = JSON.stringify(queryObj);
|
||||
const encodedQuery = encodeURIComponent(queryJson);
|
||||
|
||||
// Ambil perangkat dari GenieACS
|
||||
const response = await axios.get(`${genieacsUrl}/devices/?query=${encodedQuery}`, {
|
||||
auth: {
|
||||
username: global.appSettings.genieacsUsername || process.env.GENIEACS_USERNAME,
|
||||
password: global.appSettings.genieacsPassword || process.env.GENIEACS_PASSWORD
|
||||
},
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (response.data && response.data.length > 0) {
|
||||
return response.data[0];
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
logger.error(`Error finding device by ID: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Fungsi untuk menambahkan tag berdasarkan PPPoE Username
|
||||
async function addTagByPPPoE(remoteJid, params, sock) {
|
||||
try {
|
||||
// Ekstrak parameter
|
||||
if (params.length < 2) {
|
||||
await sock.sendMessage(remoteJid, {
|
||||
text: `❌ *Format Salah*\n\nFormat yang benar:\naddpppoe_tag [pppoe_username] [nomor_pelanggan]\n\nContoh:\naddpppoe_tag user123 081234567890`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const [pppoeUsername, customerNumber] = params;
|
||||
|
||||
// Validasi nomor pelanggan
|
||||
if (!customerNumber || !/^\d{8,}$/.test(customerNumber)) {
|
||||
await sock.sendMessage(remoteJid, {
|
||||
text: `❌ *Nomor Pelanggan Tidak Valid*\n\nNomor pelanggan harus berupa angka minimal 8 digit.`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Dapatkan URL GenieACS
|
||||
const genieacsUrl = global.appSettings.genieacsUrl || process.env.GENIEACS_URL;
|
||||
if (!genieacsUrl) {
|
||||
await sock.sendMessage(remoteJid, {
|
||||
text: `❌ *Konfigurasi tidak lengkap*\n\nURL GenieACS tidak dikonfigurasi`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Kirim pesan bahwa proses sedang berlangsung
|
||||
await sock.sendMessage(remoteJid, {
|
||||
text: `⏳ *Proses pencarian perangkat*\n\nSedang mencari perangkat dengan PPPoE Username ${pppoeUsername}...`
|
||||
});
|
||||
|
||||
// Cari perangkat berdasarkan PPPoE Username
|
||||
const device = await findDeviceByPPPoE(pppoeUsername);
|
||||
|
||||
if (!device) {
|
||||
await sock.sendMessage(remoteJid, {
|
||||
text: `❌ *Perangkat Tidak Ditemukan*\n\nTidak dapat menemukan perangkat dengan PPPoE Username ${pppoeUsername}`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Dapatkan device ID
|
||||
const deviceId = device._id;
|
||||
|
||||
// Kirim pesan bahwa proses sedang berlangsung
|
||||
await sock.sendMessage(remoteJid, {
|
||||
text: `⏳ *Proses penambahan tag*\n\nSedang menambahkan nomor ${customerNumber} ke perangkat ${deviceId}...`
|
||||
});
|
||||
|
||||
// Persiapkan URL untuk menambahkan tag
|
||||
const tagUrl = `${genieacsUrl}/devices/${deviceId}/tags/${customerNumber}`;
|
||||
|
||||
// Kirim request ke GenieACS untuk menambahkan tag
|
||||
try {
|
||||
const response = await axios.post(
|
||||
tagUrl,
|
||||
{},
|
||||
{
|
||||
auth: {
|
||||
username: global.appSettings.genieacsUsername || process.env.GENIEACS_USERNAME,
|
||||
password: global.appSettings.genieacsPassword || process.env.GENIEACS_PASSWORD
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
logger.info(`Tag response: ${response.status}`);
|
||||
|
||||
// Kirim pesan sukses
|
||||
let successMessage = `✅ *Tag berhasil ditambahkan*\n\n`;
|
||||
successMessage += `📱 *Nomor Pelanggan:* ${customerNumber}\n`;
|
||||
successMessage += `👤 *PPPoE Username:* ${pppoeUsername}\n`;
|
||||
successMessage += `🖥️ *Device ID:* ${deviceId}\n\n`;
|
||||
successMessage += `Pelanggan sekarang dapat menggunakan WhatsApp dan Web Portal dengan nomor tersebut.`;
|
||||
|
||||
await sock.sendMessage(remoteJid, { text: successMessage });
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Error adding tag to GenieACS:', error);
|
||||
|
||||
let errorMessage = `❌ *Gagal menambahkan tag*\n\n`;
|
||||
if (error.response) {
|
||||
errorMessage += `Status: ${error.response.status}\n`;
|
||||
errorMessage += `Pesan: ${JSON.stringify(error.response.data)}\n`;
|
||||
} else {
|
||||
errorMessage += `Error: ${error.message}\n`;
|
||||
}
|
||||
|
||||
await sock.sendMessage(remoteJid, { text: errorMessage });
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Error in addTagByPPPoE:', error);
|
||||
|
||||
await sock.sendMessage(remoteJid, {
|
||||
text: `❌ *Error*\n\nTerjadi kesalahan saat menambahkan tag: ${error.message}`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Fungsi untuk mencari perangkat berdasarkan PPPoE Username
|
||||
async function findDeviceByPPPoE(pppoeUsername) {
|
||||
try {
|
||||
// Dapatkan URL GenieACS
|
||||
const genieacsUrl = global.appSettings.genieacsUrl || process.env.GENIEACS_URL;
|
||||
if (!genieacsUrl) {
|
||||
logger.error('GenieACS URL not configured');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Parameter paths untuk PPPoE Username
|
||||
const pppUsernamePaths = [
|
||||
'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANPPPConnection.1.Username',
|
||||
'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANPPPConnection.1.Username._value',
|
||||
'VirtualParameters.pppoeUsername',
|
||||
'VirtualParameters.pppUsername'
|
||||
];
|
||||
|
||||
// Buat query untuk mencari perangkat berdasarkan PPPoE Username
|
||||
// Kita perlu membuat query yang mencari di semua kemungkinan path
|
||||
const queryObj = { $or: [] };
|
||||
|
||||
// Tambahkan semua kemungkinan path ke query
|
||||
for (const path of pppUsernamePaths) {
|
||||
const pathQuery = {};
|
||||
pathQuery[path] = pppoeUsername;
|
||||
queryObj.$or.push(pathQuery);
|
||||
}
|
||||
|
||||
const queryJson = JSON.stringify(queryObj);
|
||||
const encodedQuery = encodeURIComponent(queryJson);
|
||||
|
||||
// Ambil perangkat dari GenieACS
|
||||
const response = await axios.get(`${genieacsUrl}/devices/?query=${encodedQuery}`, {
|
||||
auth: {
|
||||
username: global.appSettings.genieacsUsername || process.env.GENIEACS_USERNAME,
|
||||
password: global.appSettings.genieacsPassword || process.env.GENIEACS_PASSWORD
|
||||
},
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (response.data && response.data.length > 0) {
|
||||
return response.data[0];
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
logger.error(`Error finding device by PPPoE Username: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
setSock,
|
||||
addCustomerTag,
|
||||
findDeviceById,
|
||||
addTagByPPPoE
|
||||
};
|
||||
+1076
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
+1195
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,50 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const SUPPORTED_LANGS = new Set(['id', 'en']);
|
||||
const FALLBACK_LANG = 'id';
|
||||
|
||||
function loadLocale(lang) {
|
||||
try {
|
||||
const filePath = path.join(__dirname, `../locales/${lang}.json`);
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
const dictionaries = {
|
||||
id: loadLocale('id'),
|
||||
en: loadLocale('en')
|
||||
};
|
||||
|
||||
function normalizeLang(input) {
|
||||
const lang = String(input || '').trim().toLowerCase();
|
||||
return SUPPORTED_LANGS.has(lang) ? lang : FALLBACK_LANG;
|
||||
}
|
||||
|
||||
function getNestedValue(obj, keyPath) {
|
||||
const keys = String(keyPath || '').split('.');
|
||||
let cur = obj;
|
||||
for (const k of keys) {
|
||||
if (!cur || typeof cur !== 'object' || !(k in cur)) return undefined;
|
||||
cur = cur[k];
|
||||
}
|
||||
return cur;
|
||||
}
|
||||
|
||||
function t(lang, key, fallback = '') {
|
||||
const useLang = normalizeLang(lang);
|
||||
const fromLang = getNestedValue(dictionaries[useLang], key);
|
||||
if (fromLang !== undefined) return fromLang;
|
||||
const fromFallback = getNestedValue(dictionaries[FALLBACK_LANG], key);
|
||||
if (fromFallback !== undefined) return fromFallback;
|
||||
return fallback || key;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
SUPPORTED_LANGS,
|
||||
FALLBACK_LANG,
|
||||
normalizeLang,
|
||||
t
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
// Modul logger untuk aplikasi
|
||||
const winston = require('winston');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
// Buat direktori logs jika belum ada
|
||||
const logsDir = path.join(process.cwd(), 'logs');
|
||||
if (!fs.existsSync(logsDir)) {
|
||||
fs.mkdirSync(logsDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Format untuk log
|
||||
const logFormat = winston.format.printf(({ level, message, timestamp }) => {
|
||||
return `${timestamp} ${level}: ${message}`;
|
||||
});
|
||||
|
||||
// Konfigurasi logger
|
||||
const logger = winston.createLogger({
|
||||
level: process.env.LOG_LEVEL || 'info',
|
||||
exitOnError: false, // Jangan exit saat uncaught exception (e.g. koneksi MikroTik gagal)
|
||||
format: winston.format.combine(
|
||||
winston.format.timestamp({
|
||||
format: 'YYYY-MM-DD HH:mm:ss'
|
||||
}),
|
||||
logFormat
|
||||
),
|
||||
transports: [
|
||||
// Log ke file
|
||||
new winston.transports.File({
|
||||
filename: path.join(logsDir, 'error.log'),
|
||||
level: 'error'
|
||||
}),
|
||||
new winston.transports.File({
|
||||
filename: path.join(logsDir, 'combined.log')
|
||||
}),
|
||||
// Log ke console
|
||||
new winston.transports.Console({
|
||||
format: winston.format.combine(
|
||||
winston.format.colorize(),
|
||||
winston.format.timestamp({
|
||||
format: 'YYYY-MM-DD HH:mm:ss'
|
||||
}),
|
||||
logFormat
|
||||
)
|
||||
})
|
||||
],
|
||||
exceptionHandlers: [
|
||||
new winston.transports.File({
|
||||
filename: path.join(logsDir, 'exceptions.log')
|
||||
}),
|
||||
new winston.transports.Console({
|
||||
format: winston.format.combine(
|
||||
winston.format.colorize(),
|
||||
winston.format.timestamp({
|
||||
format: 'YYYY-MM-DD HH:mm:ss'
|
||||
}),
|
||||
logFormat
|
||||
)
|
||||
})
|
||||
],
|
||||
rejectionHandlers: [
|
||||
new winston.transports.File({
|
||||
filename: path.join(logsDir, 'rejections.log')
|
||||
})
|
||||
]
|
||||
});
|
||||
|
||||
module.exports = { logger };
|
||||
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* Settings Audit Trail
|
||||
* Log semua perubahan settings untuk compliance & debugging
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { logger } = require('./logger');
|
||||
const { isSensitiveField, maskValue } = require('./settingsEncryption');
|
||||
|
||||
const AUDIT_LOG_DIR = path.join(__dirname, '../logs/settings-audit');
|
||||
const AUDIT_LOG_FILE = path.join(AUDIT_LOG_DIR, 'settings-changes.jsonl');
|
||||
|
||||
// Ensure audit log directory exists
|
||||
if (!fs.existsSync(AUDIT_LOG_DIR)) {
|
||||
fs.mkdirSync(AUDIT_LOG_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Log settings change
|
||||
*/
|
||||
function logSettingsChange(actor, changes, metadata = {}) {
|
||||
try {
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
// Mask sensitive values
|
||||
const maskedChanges = {};
|
||||
Object.keys(changes).forEach(field => {
|
||||
if (isSensitiveField(field)) {
|
||||
maskedChanges[field] = {
|
||||
oldValue: maskValue(changes[field].oldValue),
|
||||
newValue: maskValue(changes[field].newValue)
|
||||
};
|
||||
} else {
|
||||
maskedChanges[field] = changes[field];
|
||||
}
|
||||
});
|
||||
|
||||
const logEntry = {
|
||||
timestamp,
|
||||
actor: actor || 'system',
|
||||
changes: maskedChanges,
|
||||
ip: metadata.ip || 'unknown',
|
||||
userAgent: metadata.userAgent || 'unknown',
|
||||
metadata
|
||||
};
|
||||
|
||||
// Write to JSONL file
|
||||
fs.appendFileSync(AUDIT_LOG_FILE, JSON.stringify(logEntry) + '\n', 'utf-8');
|
||||
|
||||
logger.info(`[settings-audit] Settings changed by ${actor}`, {
|
||||
changes: Object.keys(maskedChanges),
|
||||
timestamp
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error(`[settings-audit] Error logging settings change: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get settings change history
|
||||
*/
|
||||
function getChangeHistory(limit = 100) {
|
||||
try {
|
||||
if (!fs.existsSync(AUDIT_LOG_FILE)) return [];
|
||||
|
||||
const lines = fs.readFileSync(AUDIT_LOG_FILE, 'utf-8').split('\n').filter(l => l.trim());
|
||||
const entries = lines.map(line => JSON.parse(line));
|
||||
|
||||
// Return latest entries
|
||||
return entries.slice(-limit).reverse();
|
||||
} catch (error) {
|
||||
logger.error(`[settings-audit] Error reading change history: ${error.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get changes by actor
|
||||
*/
|
||||
function getChangesByActor(actor, limit = 50) {
|
||||
try {
|
||||
const history = getChangeHistory(1000);
|
||||
return history.filter(entry => entry.actor === actor).slice(0, limit);
|
||||
} catch (error) {
|
||||
logger.error(`[settings-audit] Error filtering by actor: ${error.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get changes by field
|
||||
*/
|
||||
function getChangesByField(field, limit = 50) {
|
||||
try {
|
||||
const history = getChangeHistory(1000);
|
||||
return history.filter(entry => entry.changes[field]).slice(0, limit);
|
||||
} catch (error) {
|
||||
logger.error(`[settings-audit] Error filtering by field: ${error.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get changes in date range
|
||||
*/
|
||||
function getChangesByDateRange(startDate, endDate, limit = 100) {
|
||||
try {
|
||||
const history = getChangeHistory(1000);
|
||||
const start = new Date(startDate).getTime();
|
||||
const end = new Date(endDate).getTime();
|
||||
|
||||
return history.filter(entry => {
|
||||
const entryTime = new Date(entry.timestamp).getTime();
|
||||
return entryTime >= start && entryTime <= end;
|
||||
}).slice(0, limit);
|
||||
} catch (error) {
|
||||
logger.error(`[settings-audit] Error filtering by date range: ${error.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Export audit log
|
||||
*/
|
||||
function exportAuditLog(format = 'json') {
|
||||
try {
|
||||
const history = getChangeHistory(10000);
|
||||
|
||||
if (format === 'csv') {
|
||||
// Convert to CSV
|
||||
const headers = ['Timestamp', 'Actor', 'Field', 'Old Value', 'New Value', 'IP'];
|
||||
const rows = [];
|
||||
|
||||
history.forEach(entry => {
|
||||
Object.keys(entry.changes).forEach(field => {
|
||||
rows.push([
|
||||
entry.timestamp,
|
||||
entry.actor,
|
||||
field,
|
||||
entry.changes[field].oldValue,
|
||||
entry.changes[field].newValue,
|
||||
entry.ip
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
headers,
|
||||
rows,
|
||||
data: [headers, ...rows].map(row => row.join(',')).join('\n')
|
||||
};
|
||||
}
|
||||
|
||||
// Default JSON format
|
||||
return {
|
||||
format: 'json',
|
||||
count: history.length,
|
||||
data: history
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`[settings-audit] Error exporting audit log: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear old audit logs (retention policy)
|
||||
*/
|
||||
function clearOldLogs(daysToKeep = 90) {
|
||||
try {
|
||||
if (!fs.existsSync(AUDIT_LOG_FILE)) return;
|
||||
|
||||
const lines = fs.readFileSync(AUDIT_LOG_FILE, 'utf-8').split('\n').filter(l => l.trim());
|
||||
const cutoffDate = new Date();
|
||||
cutoffDate.setDate(cutoffDate.getDate() - daysToKeep);
|
||||
|
||||
const recentLines = lines.filter(line => {
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
return new Date(entry.timestamp) > cutoffDate;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
fs.writeFileSync(AUDIT_LOG_FILE, recentLines.join('\n') + '\n', 'utf-8');
|
||||
|
||||
logger.info(`[settings-audit] Cleared old logs, kept ${recentLines.length} entries`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error(`[settings-audit] Error clearing old logs: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get audit statistics
|
||||
*/
|
||||
function getAuditStats() {
|
||||
try {
|
||||
const history = getChangeHistory(10000);
|
||||
|
||||
const stats = {
|
||||
totalChanges: history.length,
|
||||
uniqueActors: new Set(history.map(e => e.actor)).size,
|
||||
changedFields: new Set(),
|
||||
changesByActor: {},
|
||||
changesByField: {},
|
||||
lastChange: history[0] || null
|
||||
};
|
||||
|
||||
history.forEach(entry => {
|
||||
// Count by actor
|
||||
stats.changesByActor[entry.actor] = (stats.changesByActor[entry.actor] || 0) + 1;
|
||||
|
||||
// Count by field
|
||||
Object.keys(entry.changes).forEach(field => {
|
||||
stats.changedFields.add(field);
|
||||
stats.changesByField[field] = (stats.changesByField[field] || 0) + 1;
|
||||
});
|
||||
});
|
||||
|
||||
stats.changedFields = Array.from(stats.changedFields);
|
||||
|
||||
return stats;
|
||||
} catch (error) {
|
||||
logger.error(`[settings-audit] Error getting audit stats: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
logSettingsChange,
|
||||
getChangeHistory,
|
||||
getChangesByActor,
|
||||
getChangesByField,
|
||||
getChangesByDateRange,
|
||||
exportAuditLog,
|
||||
clearOldLogs,
|
||||
getAuditStats,
|
||||
AUDIT_LOG_FILE
|
||||
};
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Settings Encryption & Decryption
|
||||
* Untuk encrypt/decrypt sensitive fields di settings.json
|
||||
*/
|
||||
const crypto = require('crypto');
|
||||
const { logger } = require('./logger');
|
||||
|
||||
// Master key untuk encryption (bisa dari environment variable)
|
||||
// PENTING: Ganti dengan key yang aman di production
|
||||
const MASTER_KEY = process.env.SETTINGS_MASTER_KEY || 'default-master-key-change-this-in-production';
|
||||
|
||||
function getMasterKeyForString(keyStr) {
|
||||
const hash = crypto.createHash('sha256');
|
||||
hash.update(keyStr || '');
|
||||
return hash.digest();
|
||||
}
|
||||
|
||||
// Normalize master key ke 32 bytes untuk AES-256
|
||||
function getMasterKey() {
|
||||
return getMasterKeyForString(MASTER_KEY);
|
||||
}
|
||||
|
||||
// List field yang harus di-encrypt
|
||||
const SENSITIVE_FIELDS = [
|
||||
'genieacs_password',
|
||||
'admin_password',
|
||||
'admin_api_key',
|
||||
'mikrotik_password',
|
||||
'telegram_bot_token',
|
||||
'session_secret'
|
||||
];
|
||||
|
||||
/**
|
||||
* Encrypt value menggunakan AES-256-GCM
|
||||
*/
|
||||
function encryptValue(value) {
|
||||
if (!value || typeof value !== 'string') return value;
|
||||
|
||||
try {
|
||||
const masterKey = getMasterKey();
|
||||
const iv = crypto.randomBytes(16);
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', masterKey, iv);
|
||||
|
||||
let encrypted = cipher.update(value, 'utf8', 'hex');
|
||||
encrypted += cipher.final('hex');
|
||||
|
||||
const authTag = cipher.getAuthTag();
|
||||
|
||||
// Format: iv:authTag:encrypted
|
||||
return `enc:${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`;
|
||||
} catch (error) {
|
||||
logger.error(`[encryption] Error encrypting value: ${error.message}`);
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function decryptWithKey(encryptedValue, key) {
|
||||
const parts = encryptedValue.split(':');
|
||||
if (parts.length !== 4) {
|
||||
throw new Error('Invalid encrypted value format');
|
||||
}
|
||||
|
||||
const [prefix, ivHex, authTagHex, encrypted] = parts;
|
||||
const iv = Buffer.from(ivHex, 'hex');
|
||||
const authTag = Buffer.from(authTagHex, 'hex');
|
||||
|
||||
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
|
||||
decipher.setAuthTag(authTag);
|
||||
|
||||
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
|
||||
decrypted += decipher.final('utf8');
|
||||
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt value
|
||||
*/
|
||||
function decryptValue(encryptedValue) {
|
||||
if (!encryptedValue || typeof encryptedValue !== 'string') return encryptedValue;
|
||||
if (!encryptedValue.startsWith('enc:')) return encryptedValue; // Belum di-encrypt
|
||||
|
||||
const primaryKey = getMasterKey();
|
||||
try {
|
||||
return decryptWithKey(encryptedValue, primaryKey);
|
||||
} catch (error) {
|
||||
// If decryption fails, and primaryKey is not the default fallback key, try the fallback key
|
||||
const defaultSecret = 'default-master-key-change-this-in-production';
|
||||
if (MASTER_KEY !== defaultSecret) {
|
||||
try {
|
||||
const fallbackKey = getMasterKeyForString(defaultSecret);
|
||||
const decrypted = decryptWithKey(encryptedValue, fallbackKey);
|
||||
logger.info('[encryption] Successfully decrypted value using default fallback key. It will be re-encrypted using the new key upon next save.');
|
||||
return decrypted;
|
||||
} catch (fallbackError) {
|
||||
// Both primary key and fallback key failed
|
||||
logger.error(`[encryption] Error decrypting value (both primary and fallback keys failed): ${fallbackError.message}`);
|
||||
}
|
||||
} else {
|
||||
logger.error(`[encryption] Error decrypting value: ${error.message}`);
|
||||
}
|
||||
return encryptedValue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt sensitive fields dalam settings object
|
||||
*/
|
||||
function encryptSettings(settings) {
|
||||
const encrypted = { ...settings };
|
||||
|
||||
SENSITIVE_FIELDS.forEach(field => {
|
||||
if (encrypted[field]) {
|
||||
encrypted[field] = encryptValue(encrypted[field]);
|
||||
}
|
||||
});
|
||||
|
||||
return encrypted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt sensitive fields dalam settings object
|
||||
*/
|
||||
function decryptSettings(settings) {
|
||||
const decrypted = { ...settings };
|
||||
|
||||
SENSITIVE_FIELDS.forEach(field => {
|
||||
if (decrypted[field]) {
|
||||
decrypted[field] = decryptValue(decrypted[field]);
|
||||
}
|
||||
});
|
||||
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mask sensitive values untuk display (show first 4 & last 4 chars)
|
||||
*/
|
||||
function maskValue(value) {
|
||||
if (!value || typeof value !== 'string') return value;
|
||||
if (value.length <= 8) return '****';
|
||||
|
||||
const first = value.substring(0, 4);
|
||||
const last = value.substring(value.length - 4);
|
||||
return `${first}****${last}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get masked settings untuk display di UI
|
||||
*/
|
||||
function getMaskedSettings(settings) {
|
||||
const masked = { ...settings };
|
||||
|
||||
SENSITIVE_FIELDS.forEach(field => {
|
||||
if (masked[field]) {
|
||||
masked[field] = maskValue(masked[field]);
|
||||
}
|
||||
});
|
||||
|
||||
return masked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check apakah field adalah sensitive
|
||||
*/
|
||||
function isSensitiveField(field) {
|
||||
return SENSITIVE_FIELDS.includes(field);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
encryptValue,
|
||||
decryptValue,
|
||||
encryptSettings,
|
||||
decryptSettings,
|
||||
maskValue,
|
||||
getMaskedSettings,
|
||||
isSensitiveField,
|
||||
SENSITIVE_FIELDS
|
||||
};
|
||||
@@ -0,0 +1,354 @@
|
||||
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
|
||||
};
|
||||
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* Settings Validation Schema
|
||||
* Validate setiap field sebelum di-save ke settings.json
|
||||
*/
|
||||
const { logger } = require('./logger');
|
||||
|
||||
// Validation rules untuk setiap field
|
||||
const VALIDATION_RULES = {
|
||||
// Server Configuration
|
||||
server_port: {
|
||||
type: 'number',
|
||||
min: 1024,
|
||||
max: 65535,
|
||||
required: true,
|
||||
description: 'Port server (1024-65535)'
|
||||
},
|
||||
server_host: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'Host server'
|
||||
},
|
||||
session_secret: {
|
||||
type: 'string',
|
||||
minLength: 32,
|
||||
required: true,
|
||||
description: 'Session secret (min 32 karakter)'
|
||||
},
|
||||
|
||||
// Company Information
|
||||
company_header: {
|
||||
type: 'string',
|
||||
maxLength: 100,
|
||||
required: true,
|
||||
description: 'Nama perusahaan'
|
||||
},
|
||||
public_base_url: {
|
||||
type: 'string',
|
||||
pattern: /^https?:\/\/.+/,
|
||||
description: 'URL publik aplikasi (http://... atau https://...)'
|
||||
},
|
||||
company_manager: {
|
||||
type: 'string',
|
||||
maxLength: 100,
|
||||
description: 'Nama manager'
|
||||
},
|
||||
company_phone: {
|
||||
type: 'string',
|
||||
pattern: /^[0-9\-\+\s]+$/,
|
||||
description: 'Nomor telepon perusahaan'
|
||||
},
|
||||
company_email: {
|
||||
type: 'string',
|
||||
pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
|
||||
description: 'Email perusahaan'
|
||||
},
|
||||
company_address: {
|
||||
type: 'string',
|
||||
maxLength: 500,
|
||||
description: 'Alamat perusahaan'
|
||||
},
|
||||
footer_info: {
|
||||
type: 'string',
|
||||
maxLength: 200,
|
||||
description: 'Footer info'
|
||||
},
|
||||
operational_hours: {
|
||||
type: 'string',
|
||||
maxLength: 200,
|
||||
description: 'Jam operasional'
|
||||
},
|
||||
|
||||
// Admin Credentials
|
||||
admin_username: {
|
||||
type: 'string',
|
||||
minLength: 3,
|
||||
maxLength: 50,
|
||||
required: true,
|
||||
description: 'Username admin'
|
||||
},
|
||||
admin_password: {
|
||||
type: 'string',
|
||||
minLength: 6,
|
||||
required: true,
|
||||
description: 'Password admin (min 6 karakter)'
|
||||
},
|
||||
admin_api_key: {
|
||||
type: 'string',
|
||||
minLength: 10,
|
||||
description: 'API key admin'
|
||||
},
|
||||
|
||||
// GenieACS Configuration
|
||||
genieacs_url: {
|
||||
type: 'string',
|
||||
pattern: /^https?:\/\/.+/,
|
||||
description: 'URL GenieACS (http://...)'
|
||||
},
|
||||
genieacs_username: {
|
||||
type: 'string',
|
||||
description: 'Username GenieACS'
|
||||
},
|
||||
genieacs_password: {
|
||||
type: 'string',
|
||||
description: 'Password GenieACS'
|
||||
},
|
||||
|
||||
// MikroTik Configuration
|
||||
mikrotik_host: {
|
||||
type: 'string',
|
||||
pattern: /^(\d{1,3}\.){3}\d{1,3}$|^[a-zA-Z0-9\-\.]+$/,
|
||||
description: 'Host MikroTik (IP atau hostname)'
|
||||
},
|
||||
mikrotik_user: {
|
||||
type: 'string',
|
||||
minLength: 1,
|
||||
description: 'Username MikroTik'
|
||||
},
|
||||
mikrotik_password: {
|
||||
type: 'string',
|
||||
minLength: 1,
|
||||
description: 'Password MikroTik'
|
||||
},
|
||||
mikrotik_port: {
|
||||
type: 'number',
|
||||
min: 1024,
|
||||
max: 65535,
|
||||
description: 'Port MikroTik'
|
||||
},
|
||||
isolir_day: {
|
||||
type: 'number',
|
||||
min: 1,
|
||||
max: 365,
|
||||
description: 'Hari isolir (1-365)'
|
||||
},
|
||||
|
||||
// WhatsApp Configuration
|
||||
whatsapp_enabled: {
|
||||
type: 'boolean',
|
||||
description: 'Enable WhatsApp'
|
||||
},
|
||||
whatsapp_auth_folder: {
|
||||
type: 'string',
|
||||
description: 'Folder auth WhatsApp'
|
||||
},
|
||||
whatsapp_broadcast_delay: {
|
||||
type: 'number',
|
||||
min: 10,
|
||||
max: 5000,
|
||||
description: 'Delay broadcast WhatsApp (ms)'
|
||||
},
|
||||
|
||||
// Telegram Configuration
|
||||
telegram_enabled: {
|
||||
type: 'boolean',
|
||||
description: 'Enable Telegram'
|
||||
},
|
||||
telegram_bot_token: {
|
||||
type: 'string',
|
||||
description: 'Telegram bot token'
|
||||
},
|
||||
telegram_admin_id: {
|
||||
type: 'string',
|
||||
pattern: /^\d+$/,
|
||||
description: 'Telegram admin ID (numeric)'
|
||||
},
|
||||
|
||||
// Tripay Configuration
|
||||
office_lat: {
|
||||
type: 'string',
|
||||
pattern: /^-?\d+(\.\d+)?$|^$/,
|
||||
description: 'Latitude kantor'
|
||||
},
|
||||
office_lng: {
|
||||
type: 'string',
|
||||
pattern: /^-?\d+(\.\d+)?$|^$/,
|
||||
description: 'Longitude kantor'
|
||||
},
|
||||
|
||||
// Other
|
||||
|
||||
auto_backup_enabled: {
|
||||
type: 'boolean',
|
||||
description: 'Enable auto backup'
|
||||
},
|
||||
login_otp_enabled: {
|
||||
type: 'boolean',
|
||||
description: 'Enable OTP login'
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate single value
|
||||
*/
|
||||
function validateValue(field, value, rule) {
|
||||
// Trim string values
|
||||
let trimmedValue = value;
|
||||
if (typeof value === 'string') {
|
||||
trimmedValue = value.trim();
|
||||
}
|
||||
|
||||
// Check type
|
||||
if (rule.type === 'number') {
|
||||
if (typeof trimmedValue !== 'number') return `${field} harus berupa angka`;
|
||||
if (rule.min !== undefined && trimmedValue < rule.min) return `${field} minimal ${rule.min}`;
|
||||
if (rule.max !== undefined && trimmedValue > rule.max) return `${field} maksimal ${rule.max}`;
|
||||
}
|
||||
|
||||
if (rule.type === 'string') {
|
||||
if (typeof trimmedValue !== 'string') return `${field} harus berupa string`;
|
||||
if (rule.minLength && trimmedValue.length < rule.minLength) return `${field} minimal ${rule.minLength} karakter`;
|
||||
if (rule.maxLength && trimmedValue.length > rule.maxLength) return `${field} maksimal ${rule.maxLength} karakter`;
|
||||
if (rule.pattern && !rule.pattern.test(trimmedValue)) return `${field} format tidak valid`;
|
||||
if (rule.enum && !rule.enum.includes(trimmedValue)) return `${field} harus salah satu dari: ${rule.enum.join(', ')}`;
|
||||
}
|
||||
|
||||
if (rule.type === 'boolean') {
|
||||
if (typeof trimmedValue !== 'boolean') return `${field} harus berupa boolean`;
|
||||
}
|
||||
|
||||
return null; // Valid
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate settings object
|
||||
*/
|
||||
function validateSettings(settings) {
|
||||
const errors = [];
|
||||
|
||||
Object.keys(settings).forEach(field => {
|
||||
const value = settings[field];
|
||||
const rule = VALIDATION_RULES[field];
|
||||
|
||||
// Skip jika tidak ada rule (field baru atau optional)
|
||||
if (!rule) return;
|
||||
|
||||
// Check required
|
||||
if (rule.required && (value === undefined || value === null || value === '')) {
|
||||
errors.push(`${field} wajib diisi`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip validation jika value kosong dan tidak required
|
||||
if (!rule.required && (value === undefined || value === null || value === '')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate value
|
||||
const error = validateValue(field, value, rule);
|
||||
if (error) errors.push(error);
|
||||
});
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get validation rule untuk field
|
||||
*/
|
||||
function getFieldRule(field) {
|
||||
return VALIDATION_RULES[field] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all validation rules
|
||||
*/
|
||||
function getAllRules() {
|
||||
return VALIDATION_RULES;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
validateValue,
|
||||
validateSettings,
|
||||
getFieldRule,
|
||||
getAllRules,
|
||||
VALIDATION_RULES
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
/* config/sqliteSessionStore.js
|
||||
Session store berbasis SQLite (better-sqlite3) agar sesi PERSISTEN
|
||||
lintas restart proses — penting untuk alur OTP (pending_login di session)
|
||||
dan agar pelanggan tidak terlempar ke halaman login saat server restart. */
|
||||
const session = require('express-session');
|
||||
|
||||
module.exports = function createSqliteSessionStore(db) {
|
||||
const Store = session.Store;
|
||||
|
||||
class SqliteSessionStore extends Store {
|
||||
constructor() {
|
||||
super();
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS sessions (
|
||||
sid TEXT PRIMARY KEY,
|
||||
data TEXT NOT NULL,
|
||||
expires_at INTEGER
|
||||
)`);
|
||||
this._get = db.prepare('SELECT data, expires_at FROM sessions WHERE sid = ?');
|
||||
this._set = db.prepare('INSERT OR REPLACE INTO sessions (sid, data, expires_at) VALUES (?, ?, ?)');
|
||||
this._del = db.prepare('DELETE FROM sessions WHERE sid = ?');
|
||||
this._clean = db.prepare('DELETE FROM sessions WHERE expires_at IS NOT NULL AND expires_at < ?');
|
||||
const timer = setInterval(() => { try { this._clean.run(Date.now()); } catch (e) {} }, 15 * 60 * 1000);
|
||||
if (timer.unref) timer.unref();
|
||||
}
|
||||
get(sid, cb) {
|
||||
try {
|
||||
const row = this._get.get(sid);
|
||||
if (!row) return cb(null, null);
|
||||
if (row.expires_at && row.expires_at < Date.now()) { this._del.run(sid); return cb(null, null); }
|
||||
return cb(null, JSON.parse(row.data));
|
||||
} catch (e) { return cb(e); }
|
||||
}
|
||||
set(sid, sess, cb) {
|
||||
try {
|
||||
let exp = null;
|
||||
const c = sess && sess.cookie;
|
||||
if (c && c.expires) exp = new Date(c.expires).getTime();
|
||||
else if (c && c.maxAge) exp = Date.now() + c.maxAge;
|
||||
this._set.run(sid, JSON.stringify(sess), exp);
|
||||
if (cb) cb(null);
|
||||
} catch (e) { if (cb) cb(e); }
|
||||
}
|
||||
destroy(sid, cb) { try { this._del.run(sid); if (cb) cb(null); } catch (e) { if (cb) cb(e); } }
|
||||
touch(sid, sess, cb) { return this.set(sid, sess, cb); }
|
||||
}
|
||||
|
||||
return new SqliteSessionStore();
|
||||
};
|
||||
Reference in new issue
Block a user