Compare commits

..
16 Commits
Author SHA1 Message Date
oxpoe b715f48f8d chore: gitignore penanda restore .restore-pending 2026-10-13 00:15:52 +07:00
oxpoe 6abfeb4577 chore(release): siapkan v0.5.1 (versi, CHANGELOG, release notes) 2026-10-13 00:11:22 +07:00
oxpoe c6b39ae2bc chore(deps): upgrade node-telegram-bot-api 0.66 -> 2.1.0 (via adapter v1->v2), audit 0 vuln 2026-10-12 23:37:00 +07:00
oxpoe 3e913d8ceb chore(log): arahkan console.* (server) ke logger agar log seragam & tersimpan di logs/ 2026-10-12 23:31:45 +07:00
oxpoe ba0845da47 fix(backup): terapkan restore DB saat startup (tunda swap) - menimpa DB di bawah koneksi hidup merusak (WAL) 2026-10-12 23:24:24 +07:00
oxpoe 2aadb66f77 fix(backup): perbaiki scope restartNeeded (di luar try) agar restart setelah restore DB benar-benar jalan 2026-10-12 23:20:13 +07:00
oxpoe 994e3f8d61 fix(backup): buang -wal/-shm lama saat restore DB (backup self-contained) 2026-10-12 23:18:04 +07:00
oxpoe a77975ca44 fix(backup): restart otomatis setelah restore database (koneksi lama masih pegang file DB lama) 2026-10-12 23:13:35 +07:00
oxpoe 3eb4edfe5b fix(backup): VACUUM INTO agar backup konsisten saat DB mode WAL (sebelumnya salin file mentah bisa kehilangan -wal) 2026-10-12 23:12:35 +07:00
oxpoe ebbfffc116 fix(deps): xlsx -> build resmi SheetJS 0.20.3 (tutup high CVE) 2026-10-12 23:06:14 +07:00
oxpoe 4185af3971 fix(deps): hapus baileys (tak dipakai), update axios/multer, audit fix; fix trust proxy (XFF) 2026-10-12 22:45:29 +07:00
oxpoe a1e1570221 fix(log): putar log otomatis (10MB x 5) agar tak menumpuk 2026-10-12 22:37:46 +07:00
oxpoe dc5c1299d3 feat(telegram): perintah /cekterhubung <tag> (status perangkat) 2026-10-12 22:29:59 +07:00
oxpoe 008aa871f6 docs: CHANGELOG — catat perubahan pasca rilis v0.5.0 2026-10-12 22:27:56 +07:00
oxpoe 63994df1e4 feat: toggle fitur voucher (setting voucher_enabled) + gate route & sembunyikan link 2026-10-12 22:20:48 +07:00
oxpoe 8caae14cfd fix: footer versi & portal ikut company_header (brand-aware); hapus hack SKYFIBER 2026-10-09 11:32:20 +07:00
20 changed files with 327 additions and 2473 deletions

No files matched your search

+3
View File
@@ -82,3 +82,6 @@ public/downloads/*.aab
public/downloads/logo-* public/downloads/logo-*
.npmrc .npmrc
.gitconfig .gitconfig
# penanda restore tertunda (runtime)
database/.restore-pending
+29
View File
@@ -2,6 +2,35 @@
Format mengikuti [Keep a Changelog](https://keepachangelog.com/) dan [Semantic Versioning](https://semver.org/). Format mengikuti [Keep a Changelog](https://keepachangelog.com/) dan [Semantic Versioning](https://semver.org/).
## [0.5.1] — 2026-10-13 — "pickle-lime"
### Diubah
- **WhatsApp** kini **notifikasi keluar saja** (interaksi/bot perintah dimatikan); interaksi dipindah ke **Telegram**.
- **Telegram** jadi pusat monitoring admin: menu + `/sistem`, `/pendapatan`, `/tunggakan`, `/tiket`, `/offline`, `/aktif`, `/olt`, `/trafik`.
- Alert admin/monitoring (staff/GenieACS/cron) dikirim via **Telegram** (WA fallback).
- Footer & portal *brand-aware* (mengikuti `company_header`).
### Ditambahkan
- Toggle **`voucher_enabled`** (Pengaturan → Fitur Opsional) untuk menyalakan/mematikan fitur Beli Voucher.
- Notifikasi **tiket** menyertakan **Chat Pelanggan (wa.me)** + **Share Lokasi (Maps)**.
### Diperbaiki
- Spintax `{A|B|C}` diproses terpusat (teks/gambar/dokumen).
- `getTechById` (notifikasi tiket oleh admin yang sebelumnya gagal).
- Tabel `discount_logs` dibuat saat fresh-install (500 di `/admin/discounts`).
- `.devcontainer` + `.nvmrc` untuk Codespaces (Node 22).
- **Backup DB** memakai `VACUUM INTO` (konsisten walau mode WAL) dan **restore diterapkan saat startup** (sebelum koneksi DB dibuka); app restart otomatis untuk menerapkan.
- **Logger** memutar berkas otomatis (10MB × 5) dan `console.*` sisi server diarahkan ke logger (log seragam di `logs/`).
- `settings.json` permission di-set `600`.
### Keamanan
- **`npm audit`: 16 → 0** kerentanan.
- Hapus `@whiskeysockets/baileys` (tidak dipakai).
- Perbarui `axios`, `multer`, `brace-expansion`, `proxy-addr`.
- `xlsx` → build resmi **SheetJS 0.20.3**.
- `node-telegram-bot-api` 0.66 → **2.1.0** (via adapter v1→v2 internal).
- Perbaiki bug **`trust proxy`** (`X-Forwarded-For` terbalik → IP bisa dipalsukan).
## [0.5.0] — 2026-10-09 — "pickle-lime" ## [0.5.0] — 2026-10-09 — "pickle-lime"
Rilis open-source perdana. Rilis open-source perdana.
+1 -1
View File
@@ -9,7 +9,7 @@
<p align="center"> <p align="center">
<img src="https://img.shields.io/badge/lisensi-ISC-ADFF2F" alt="Lisensi: ISC"> <img src="https://img.shields.io/badge/lisensi-ISC-ADFF2F" alt="Lisensi: ISC">
<img src="https://img.shields.io/badge/versi-0.5_pickle--lime-ADFF2F" alt="Versi 0.5 (pickle-lime)"> <img src="https://img.shields.io/badge/versi-0.5.1_pickle--lime-ADFF2F" alt="Versi 0.5.1 (pickle-lime)">
<img src="https://img.shields.io/badge/Node.js-%E2%89%A520-339933" alt="Node.js >= 20"> <img src="https://img.shields.io/badge/Node.js-%E2%89%A520-339933" alt="Node.js >= 20">
<img src="https://img.shields.io/badge/docker-ready-2496ED?logo=docker&logoColor=white" alt="Docker"> <img src="https://img.shields.io/badge/docker-ready-2496ED?logo=docker&logoColor=white" alt="Docker">
<img src="https://img.shields.io/github/stars/oxpoe/open-isp?style=flat" alt="Stars"> <img src="https://img.shields.io/github/stars/oxpoe/open-isp?style=flat" alt="Stars">
+15 -3
View File
@@ -11,6 +11,18 @@ const _jimpMod = require('jimp');
const Jimp = _jimpMod.Jimp || _jimpMod; const Jimp = _jimpMod.Jimp || _jimpMod;
const qrisUtil = require('./utils/qrisUtil'); const qrisUtil = require('./utils/qrisUtil');
const { logger } = require('./config/logger'); const { logger } = require('./config/logger');
// Semua console.* (server) diarahkan ke logger agar log seragam & tersimpan di logs/.
// Dipasang SETELAH logger dibuat: winston sudah mengikat console asli -> tanpa rekursi.
const _logFmt = (x) => {
if (x instanceof Error) return x.stack || x.message;
if (x && typeof x === 'object') { try { return JSON.stringify(x); } catch (e) { return String(x); } }
return typeof x === 'undefined' ? 'undefined' : String(x);
};
for (const [m, lvl] of [['log', 'info'], ['info', 'info'], ['warn', 'warn'], ['error', 'error'], ['debug', 'debug']]) {
console[m] = (...args) => logger[lvl](args.map(_logFmt).join(' '));
}
const db = require('./config/database'); const db = require('./config/database');
const customerSvc = require('./services/customerService'); const customerSvc = require('./services/customerService');
const noticeSvc = require('./services/serviceNoticeService'); const noticeSvc = require('./services/serviceNoticeService');
@@ -59,7 +71,7 @@ app.use(compression());
const isProduction = process.env.NODE_ENV === 'production'; const isProduction = process.env.NODE_ENV === 'production';
const cookieSecure = getSetting('cookie_secure', isProduction); const cookieSecure = getSetting('cookie_secure', isProduction);
const trustProxySetting = getSetting('trust_proxy', true); const trustProxySetting = getSetting('trust_proxy', true);
app.set('trust proxy', trustProxySetting ? 1 : true); app.set('trust proxy', trustProxySetting ? 1 : false);
// Middleware dasar // Middleware dasar
app.use(express.json({ app.use(express.json({
@@ -165,7 +177,7 @@ app.use((req, res, next) => {
res.locals.uptimeUrl = getSetting('uptime_url', ''); res.locals.uptimeUrl = getSetting('uptime_url', '');
res.locals.heroVer = getSetting('landing_hero_version', ''); res.locals.heroVer = getSetting('landing_hero_version', '');
res.locals.projectUrl = String(getSetting('project_url', 'https://github.com/oxpoe/open-isp') || '').replace(/\/+$/, ''); res.locals.projectUrl = String(getSetting('project_url', 'https://github.com/oxpoe/open-isp') || '').replace(/\/+$/, '');
res.locals.appVersion = String(getSetting('app_version', '0.5') || '0.5'); res.locals.appVersion = String(getSetting('app_version', '0.5.1') || '0.5.1');
res.locals.appVersionName = String(getSetting('app_version_name', 'pickle-lime') || 'pickle-lime'); res.locals.appVersionName = String(getSetting('app_version_name', 'pickle-lime') || 'pickle-lime');
res.locals.pwaVersion = String(getSetting('pwa_version', '2.0') || '2.0'); res.locals.pwaVersion = String(getSetting('pwa_version', '2.0') || '2.0');
next(); next();
@@ -1649,7 +1661,7 @@ function buildPwaManifest(scope, startUrl, desc, orientation) {
app.get('/version.txt', (req, res) => { app.get('/version.txt', (req, res) => {
res.type('text/plain; charset=utf-8'); res.type('text/plain; charset=utf-8');
res.set('Cache-Control', 'no-store, no-cache, must-revalidate'); res.set('Cache-Control', 'no-store, no-cache, must-revalidate');
const billing = String(getSetting('app_version', '0.5') || '0.5'); const billing = String(getSetting('app_version', '0.5.1') || '0.5.1');
const nameV = String(getSetting('app_version_name', 'pickle-lime') || 'pickle-lime'); const nameV = String(getSetting('app_version_name', 'pickle-lime') || 'pickle-lime');
const pwaV = String(getSetting('pwa_version', '2.0') || '2.0'); const pwaV = String(getSetting('pwa_version', '2.0') || '2.0');
const stUrl = String(getSetting('speedtest_url', '') || ''); const stUrl = String(getSetting('speedtest_url', '') || '');
+20
View File
@@ -10,6 +10,26 @@ if (!fs.existsSync(dbDir)) fs.mkdirSync(dbDir, { recursive: true });
const dbPath = path.join(dbDir, 'billing.db'); const dbPath = path.join(dbDir, 'billing.db');
// Terapkan restore DB yang ditunda SEBELUM koneksi dibuka.
// (menimpa billing.db di bawah koneksi hidup bisa merusak DB karena WAL bercampur)
const _pendingRestore = path.join(dbDir, '.restore-pending');
if (fs.existsSync(_pendingRestore)) {
try {
const _bkName = fs.readFileSync(_pendingRestore, 'utf8').trim();
const _bkPath = path.join(__dirname, '..', 'backups', _bkName);
if (_bkName && fs.existsSync(_bkPath)) {
fs.copyFileSync(_bkPath, dbPath);
for (const _ext of ['-wal', '-shm']) { try { fs.unlinkSync(dbPath + _ext); } catch (e) {} }
console.log('[DB] Restore tertunda diterapkan dari ' + _bkName);
} else {
console.error('[DB] Restore tertunda dilewati, backup tidak ada: ' + _bkName);
}
} catch (e) {
console.error('[DB] Gagal menerapkan restore tertunda:', e.message);
}
try { fs.unlinkSync(_pendingRestore); } catch (e) {}
}
let db; let db;
try { try {
db = new Database(dbPath); db = new Database(dbPath);
+4 -4
View File
@@ -27,11 +27,11 @@ const logger = winston.createLogger({
transports: [ transports: [
// Log ke file // Log ke file
new winston.transports.File({ new winston.transports.File({
filename: path.join(logsDir, 'error.log'), filename: path.join(logsDir, 'error.log'), maxsize: 10485760, maxFiles: 5,
level: 'error' level: 'error'
}), }),
new winston.transports.File({ new winston.transports.File({
filename: path.join(logsDir, 'combined.log') filename: path.join(logsDir, 'combined.log'), maxsize: 10485760, maxFiles: 5
}), }),
// Log ke console // Log ke console
new winston.transports.Console({ new winston.transports.Console({
@@ -46,7 +46,7 @@ const logger = winston.createLogger({
], ],
exceptionHandlers: [ exceptionHandlers: [
new winston.transports.File({ new winston.transports.File({
filename: path.join(logsDir, 'exceptions.log') filename: path.join(logsDir, 'exceptions.log'), maxsize: 10485760, maxFiles: 5
}), }),
new winston.transports.Console({ new winston.transports.Console({
format: winston.format.combine( format: winston.format.combine(
@@ -60,7 +60,7 @@ const logger = winston.createLogger({
], ],
rejectionHandlers: [ rejectionHandlers: [
new winston.transports.File({ new winston.transports.File({
filename: path.join(logsDir, 'rejections.log') filename: path.join(logsDir, 'rejections.log'), maxsize: 10485760, maxFiles: 5
}) })
] ]
}); });
+31
View File
@@ -0,0 +1,31 @@
# OPEN-ISP v0.5.1 (pickle-lime) — Patch Keamanan & Stabilitas
Tanggal: 2026-10-13
Rilis patch yang menutup seluruh temuan kerentanan dependensi, sekaligus memperkuat **backup/restore** dan **logging**.
## Keamanan
- `npm audit`: **16 → 0** kerentanan.
- Menghapus `@whiskeysockets/baileys` (tidak dipakai).
- Memperbarui `axios`, `multer`, `brace-expansion`, `proxy-addr`.
- `xlsx` → build resmi **SheetJS 0.20.3** (API sama).
- `node-telegram-bot-api` **0.66 → 2.1.0** (memakai adapter v1→v2 internal; modul bot tidak dirombak).
- Memperbaiki bug **`trust proxy`**: `X-Forwarded-For` terbalik sehingga IP bisa dipalsukan (berdampak pada rate-limit/login).
- `cookie_secure` sebaiknya diaktifkan saat aplikasi dipublikasikan via **HTTPS**.
## Backup & Restore
- Backup database memakai **`VACUUM INTO`** → snapshot **konsisten** walau database berjalan di mode **WAL** (sebelumnya menyalin file mentah berisiko kehilangan transaksi terbaru).
- **Restore diterapkan saat startup** (sebelum koneksi DB dibuka) → menghindari kerusakan data karena menimpa file DB di bawah koneksi hidup. Aplikasi **restart otomatis** untuk menerapkan restore.
- Restore settings memakai pemuatan ulang otomatis (tanpa restart).
## Logging
- Berkas log **diputar otomatis** (10MB × 5) — tidak lagi menumpuk memenuhi disk.
- Seluruh `console.*` sisi server diarahkan ke **logger** → log **seragam** dan tersimpan di `logs/`.
## Lainnya
- `settings.json` di-set permission `600` (rahasia tidak world-readable).
- Versi dinaikkan ke **0.5.1**.
## Cara Update
- **Docker:** `docker compose pull` / `docker compose up -d --build`.
- **Manual:** `git pull` lalu `npm ci --omit=dev` dan restart aplikasi.
+51 -2437
View File
File diff suppressed because it is too large. Load diff
+5 -6
View File
@@ -1,6 +1,6 @@
{ {
"name": "open-isp", "name": "open-isp",
"version": "0.5.0", "version": "0.5.1",
"description": "OPEN-ISP — sistem billing & manajemen ISP open-source (PWA pelanggan, multi-OLT, RADIUS, WhatsApp, QRIS)", "description": "OPEN-ISP — sistem billing & manajemen ISP open-source (PWA pelanggan, multi-OLT, RADIUS, WhatsApp, QRIS)",
"main": "app-customer.js", "main": "app-customer.js",
"scripts": { "scripts": {
@@ -10,9 +10,8 @@
"fix-permissions": "sudo chown -R $USER:$USER ." "fix-permissions": "sudo chown -R $USER:$USER ."
}, },
"dependencies": { "dependencies": {
"@whiskeysockets/baileys": "^7.0.0-rc11",
"@zxing/library": "^0.21.3", "@zxing/library": "^0.21.3",
"axios": "^1.6.7", "axios": "^1.20.0",
"better-sqlite3": "^11.3.0", "better-sqlite3": "^11.3.0",
"compression": "^1.8.2", "compression": "^1.8.2",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
@@ -22,10 +21,10 @@
"express-session": "^1.18.1", "express-session": "^1.18.1",
"jimp": "^1.6.1", "jimp": "^1.6.1",
"jsqr": "^1.4.0", "jsqr": "^1.4.0",
"multer": "^2.1.1", "multer": "^2.4.0",
"net-snmp": "^3.26.1", "net-snmp": "^3.26.1",
"node-cron": "^4.2.1", "node-cron": "^4.2.1",
"node-telegram-bot-api": "^0.66.0", "node-telegram-bot-api": "^2.1.0",
"pdfkit": "^0.19.1", "pdfkit": "^0.19.1",
"pino": "^9.6.0", "pino": "^9.6.0",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
@@ -34,7 +33,7 @@
"sharp": "^0.35.5", "sharp": "^0.35.5",
"ssh2": "^1.15.0", "ssh2": "^1.15.0",
"winston": "^3.8.2", "winston": "^3.8.2",
"xlsx": "^0.18.5" "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz"
}, },
"devDependencies": { "devDependencies": {
"nodemon": "^3.1.14" "nodemon": "^3.1.14"
+14 -1
View File
@@ -4732,6 +4732,8 @@ router.post('/settings', requireAdminSession, express.urlencoded({ extended: tru
const newSettings = { ...req.body }; const newSettings = { ...req.body };
if (newSettings.whatsapp_enabled === 'true') newSettings.whatsapp_enabled = true; if (newSettings.whatsapp_enabled === 'true') newSettings.whatsapp_enabled = true;
else if (newSettings.whatsapp_enabled === 'false') newSettings.whatsapp_enabled = false; else if (newSettings.whatsapp_enabled === 'false') newSettings.whatsapp_enabled = false;
if (newSettings.voucher_enabled === 'true') newSettings.voucher_enabled = true;
else if (newSettings.voucher_enabled === 'false') newSettings.voucher_enabled = false;
if (newSettings.qris_static_enabled === 'true') newSettings.qris_static_enabled = true; if (newSettings.qris_static_enabled === 'true') newSettings.qris_static_enabled = true;
else if (newSettings.qris_static_enabled === 'false') newSettings.qris_static_enabled = false; else if (newSettings.qris_static_enabled === 'false') newSettings.qris_static_enabled = false;
@@ -4954,6 +4956,7 @@ router.post('/backup/create', requireAdminSession, express.urlencoded({ extended
}); });
router.post('/backup/restore', requireAdminSession, express.urlencoded({ extended: true }), (req, res) => { router.post('/backup/restore', requireAdminSession, express.urlencoded({ extended: true }), (req, res) => {
let restartNeeded = false;
try { try {
const { fileName, type } = req.body; const { fileName, type } = req.body;
let result; let result;
@@ -4968,7 +4971,12 @@ router.post('/backup/restore', requireAdminSession, express.urlencoded({ extende
} }
if (result.success) { if (result.success) {
req.session._msg = { type: 'success', text: `Restore berhasil: ${fileName}` }; if (type === 'database') {
restartNeeded = true;
req.session._msg = { type: 'success', text: `Restore database berhasil: ${fileName}. Aplikasi restart otomatis untuk menerapkan...` };
} else {
req.session._msg = { type: 'success', text: `Restore berhasil: ${fileName}` };
}
} else { } else {
req.session._msg = { type: 'error', text: `Gagal restore: ${result.error}` }; req.session._msg = { type: 'error', text: `Gagal restore: ${result.error}` };
} }
@@ -4976,9 +4984,12 @@ router.post('/backup/restore', requireAdminSession, express.urlencoded({ extende
req.session._msg = { type: 'error', text: `Gagal: ${e.message}` }; req.session._msg = { type: 'error', text: `Gagal: ${e.message}` };
} }
res.redirect('/admin/backup'); res.redirect('/admin/backup');
// DB di-swap: koneksi lama masih memakai file lama -> muat ulang proses (PM2/Docker restart otomatis)
if (restartNeeded) setTimeout(() => process.exit(0), 2000);
}); });
router.post('/backup/upload-restore', requireAdminSession, upload.single('backupFile'), (req, res) => { router.post('/backup/upload-restore', requireAdminSession, upload.single('backupFile'), (req, res) => {
let restartNeeded = false;
try { try {
const file = req.file; const file = req.file;
if (!file || !file.buffer || !file.originalname) { if (!file || !file.buffer || !file.originalname) {
@@ -5002,6 +5013,7 @@ router.post('/backup/upload-restore', requireAdminSession, upload.single('backup
const savePath = path.join(backupDir, savedFileName); const savePath = path.join(backupDir, savedFileName);
fs.writeFileSync(savePath, file.buffer); fs.writeFileSync(savePath, file.buffer);
result = backupSvc.restoreDatabase(savedFileName); result = backupSvc.restoreDatabase(savedFileName);
restartNeeded = true;
} else if (ext === '.json' || originalName.includes('settings')) { } else if (ext === '.json' || originalName.includes('settings')) {
savedFileName = `uploaded_settings_${timestamp}_${originalName}`; savedFileName = `uploaded_settings_${timestamp}_${originalName}`;
const savePath = path.join(backupDir, savedFileName); const savePath = path.join(backupDir, savedFileName);
@@ -5033,6 +5045,7 @@ router.post('/backup/upload-restore', requireAdminSession, upload.single('backup
req.session._msg = { type: 'error', text: 'Gagal upload & restore: ' + e.message }; req.session._msg = { type: 'error', text: 'Gagal upload & restore: ' + e.message };
} }
res.redirect('/admin/backup'); res.redirect('/admin/backup');
if (restartNeeded && result && result.success) setTimeout(() => process.exit(0), 2000);
}); });
router.post('/backup/delete', requireAdminSession, express.urlencoded({ extended: true }), (req, res) => { router.post('/backup/delete', requireAdminSession, express.urlencoded({ extended: true }), (req, res) => {
+6
View File
@@ -2,6 +2,7 @@ const express = require('express');
const router = express.Router(); const router = express.Router();
const customerDevice = require('../services/customerDeviceService'); const customerDevice = require('../services/customerDeviceService');
const { getSettingsWithCache, getNowLocal, getCurrentTimeInfo, getNowLocalISO, formatDateLocal, formatTimeLocal } = require('../config/settingsManager'); const { getSettingsWithCache, getNowLocal, getCurrentTimeInfo, getNowLocalISO, formatDateLocal, formatTimeLocal } = require('../config/settingsManager');
function voucherFeatureOn() { try { const s = getSettingsWithCache() || {}; const v = s.voucher_enabled; return !(v === false || v === 'false' || v === '0' || v === 0); } catch (e) { return true; } }
const noticeSvc = require('../services/serviceNoticeService'); const noticeSvc = require('../services/serviceNoticeService');
const ssidChangeCooldown = new Map(); // key: customer id / loginId -> timestamp terakhir ubah SSID const ssidChangeCooldown = new Map(); // key: customer id / loginId -> timestamp terakhir ubah SSID
const registrationService = require('../services/registrationService'); const registrationService = require('../services/registrationService');
@@ -890,6 +891,7 @@ router.get('/check-billing', async (req, res) => {
let _voucherLastGoodProfiles = null; let _voucherLastGoodProfiles = null;
router.get('/voucher', async (req, res) => { router.get('/voucher', async (req, res) => {
if (!voucherFeatureOn()) return res.redirect('/customer/login?err=' + encodeURIComponent('Fitur voucher sedang dinonaktifkan'));
const settings = getSettingsWithCache(); const settings = getSettingsWithCache();
const error = String(req.query.err || '').trim() || null; const error = String(req.query.err || '').trim() || null;
const info = String(req.query.info || '').trim() || null; const info = String(req.query.info || '').trim() || null;
@@ -1149,6 +1151,7 @@ router.get('/voucher', async (req, res) => {
}); });
router.get('/voucher/qris/:orderId', async (req, res) => { router.get('/voucher/qris/:orderId', async (req, res) => {
if (!voucherFeatureOn()) return res.redirect('/customer/login?err=' + encodeURIComponent('Fitur voucher sedang dinonaktifkan'));
const settings = getSettingsWithCache(); const settings = getSettingsWithCache();
const orderId = Number(req.params.orderId || 0); const orderId = Number(req.params.orderId || 0);
const secret = settings.session_secret || 'rahasia-portal-pelanggan-default-ganti-ini'; const secret = settings.session_secret || 'rahasia-portal-pelanggan-default-ganti-ini';
@@ -1196,6 +1199,7 @@ router.get('/voucher/qris/:orderId', async (req, res) => {
}); });
// API endpoint untuk cek status voucher order (untuk auto-polling di halaman QRIS) // API endpoint untuk cek status voucher order (untuk auto-polling di halaman QRIS)
router.get('/voucher/status/:orderId', async (req, res) => { router.get('/voucher/status/:orderId', async (req, res) => {
if (!voucherFeatureOn()) return res.json({ success: false, status: 'disabled', message: 'Fitur voucher dinonaktifkan' });
const settings = getSettingsWithCache(); const settings = getSettingsWithCache();
const orderId = Number(req.params.orderId || 0); const orderId = Number(req.params.orderId || 0);
const secret = settings.session_secret || 'rahasia-portal-pelanggan-default-ganti-ini'; const secret = settings.session_secret || 'rahasia-portal-pelanggan-default-ganti-ini';
@@ -1224,6 +1228,7 @@ router.get('/voucher/status/:orderId', async (req, res) => {
router.post('/public/voucher/create-payment', async (req, res) => { router.post('/public/voucher/create-payment', async (req, res) => {
if (!voucherFeatureOn()) return res.status(403).json({ success: false, message: 'Fitur voucher dinonaktifkan' });
const settings = getSettingsWithCache(); const settings = getSettingsWithCache();
const buyerPhone = normalizeBuyerPhone(req.body.buyer_phone); const buyerPhone = normalizeBuyerPhone(req.body.buyer_phone);
@@ -3352,6 +3357,7 @@ router.post('/payment/proof/:invoiceId', uploadProof.single('proof'), async (req
}); });
router.post('/voucher/proof/:orderId', uploadProof.single('proof'), async (req, res) => { router.post('/voucher/proof/:orderId', uploadProof.single('proof'), async (req, res) => {
if (!voucherFeatureOn()) return res.redirect('/customer/login?err=' + encodeURIComponent('Fitur voucher sedang dinonaktifkan'));
const settings = getSettingsWithCache(); const settings = getSettingsWithCache();
const secret = settings.session_secret || 'rahasia-portal-pelanggan-default-ganti-ini'; const secret = settings.session_secret || 'rahasia-portal-pelanggan-default-ganti-ini';
const token = String(req.body && req.body.token ? req.body.token : '').trim(); const token = String(req.body && req.body.token ? req.body.token : '').trim();
+18 -9
View File
@@ -3,6 +3,7 @@
* Melakukan backup otomatis database dan settings * Melakukan backup otomatis database dan settings
*/ */
const fs = require('fs'); const fs = require('fs');
const Database = require('better-sqlite3');
const path = require('path'); const path = require('path');
const { logger } = require('../config/logger'); const { logger } = require('../config/logger');
const { getSetting, getCurrentDateInTimezone, getNowLocalISO } = require('../config/settingsManager'); const { getSetting, getCurrentDateInTimezone, getNowLocalISO } = require('../config/settingsManager');
@@ -41,8 +42,15 @@ function backupDatabase() {
const backupFileName = `billing_db_${timestamp}.db`; const backupFileName = `billing_db_${timestamp}.db`;
const backupFilePath = path.join(backupDir, backupFileName); const backupFilePath = path.join(backupDir, backupFileName);
// Copy database file // VACUUM INTO: snapshot konsisten & aman di mode WAL
fs.copyFileSync(dbPath, backupFilePath); // (menyalin file mentah bisa kehilangan transaksi terbaru yang masih di -wal)
if (fs.existsSync(backupFilePath)) fs.unlinkSync(backupFilePath);
const srcDb = new Database(dbPath, { readonly: true });
try {
srcDb.prepare('VACUUM INTO ?').run(backupFilePath);
} finally {
srcDb.close();
}
// Compress backup (optional - bisa ditambahkan nanti) // Compress backup (optional - bisa ditambahkan nanti)
const stats = fs.statSync(backupFilePath); const stats = fs.statSync(backupFilePath);
@@ -132,18 +140,19 @@ function restoreDatabase(backupFileName) {
logger.warn('[Backup] Failed to create pre-restore backup'); logger.warn('[Backup] Failed to create pre-restore backup');
} }
// Restore database // Tunda restore ke saat startup: menimpa DB di bawah koneksi hidup bisa merusak (WAL bercampur).
fs.copyFileSync(backupFilePath, dbPath); // Penanda dibaca config/database.js saat boot, sebelum koneksi dibuka.
const pendingPath = path.join(projectRoot, 'database', '.restore-pending');
fs.writeFileSync(pendingPath, backupFileName, 'utf8');
const size = fs.statSync(backupFilePath).size;
const stats = fs.statSync(dbPath); logger.info(`[Backup] Restore dijadwalkan (diterapkan saat restart): ${backupFileName}`);
const sizeKB = Math.round(stats.size / 1024);
logger.info(`[Backup] Database restored from: ${backupFileName} (${sizeKB} KB)`);
return { return {
success: true, success: true,
fileName: backupFileName, fileName: backupFileName,
size: stats.size, size,
pendingRestart: true,
timestamp: getNowLocalISO(), timestamp: getNowLocalISO(),
preRestoreBackup: preRestoreBackup.fileName preRestoreBackup: preRestoreBackup.fileName
}; };
+22 -2
View File
@@ -1,4 +1,4 @@
const TelegramBot = require('node-telegram-bot-api'); const { CompatBot } = require('./telegramCompat');
const { getSetting, getNowLocal } = require('../config/settingsManager'); const { getSetting, getNowLocal } = require('../config/settingsManager');
const { logger } = require('../config/logger'); const { logger } = require('../config/logger');
const customerSvc = require('./customerService'); const customerSvc = require('./customerService');
@@ -38,7 +38,7 @@ function initTelegram() {
return; return;
} }
bot = new TelegramBot(token, { polling: true }); bot = new CompatBot(token);
// ── Wrapper aman: bila Telegram gagal parse Markdown (mis. underscore pada // ── Wrapper aman: bila Telegram gagal parse Markdown (mis. underscore pada
// username pelanggan), kirim ulang otomatis TANPA parse_mode. // username pelanggan), kirim ulang otomatis TANPA parse_mode.
@@ -823,6 +823,26 @@ function initTelegram() {
catch (e) { bot.sendMessage(msg.chat.id, 'Gagal: ' + e.message); } catch (e) { bot.sendMessage(msg.chat.id, 'Gagal: ' + e.message); }
}); });
bot.onText(/\/cekterhubung\s+(\S+)/i, async (msg, match) => {
if (!isAdmin(msg)) return;
const chatId = msg.chat.id;
const tag = match[1];
try {
let dev = null;
try { dev = await customerDevice.findDeviceByTag(tag); } catch (e) {}
if (!dev) { try { dev = await customerDevice.findDeviceWithTagVariants(tag); } catch (e) {} }
if (!dev) return bot.sendMessage(chatId, '❌ Perangkat/tag "' + tag + '" tidak ditemukan.');
const m = customerDevice.mapDeviceData(dev, tag, false) || {};
const st = String(m.status || '').toLowerCase() === 'online' ? '🟢 Online' : '🔴 Offline';
let t = '📶 *CEK TERHUBUNG*\n\n';
t += '🏷️ Tag: ' + tag + '\n';
t += '📡 Status: ' + st + '\n';
if (m.lastInform) t += '🕒 Last Inform: ' + m.lastInform + '\n';
if (m.ssid && m.ssid !== '-') t += '📶 SSID: ' + m.ssid + '\n';
bot.sendMessage(chatId, t, { parse_mode: 'Markdown' });
} catch (e) { bot.sendMessage(chatId, 'Gagal: ' + e.message); }
});
bot.onText(/\/ringkasan/i, async (msg) => { bot.onText(/\/ringkasan/i, async (msg) => {
if (!isAdmin(msg)) return; if (!isAdmin(msg)) return;
const chatId = msg.chat.id; const chatId = msg.chat.id;
+84
View File
@@ -0,0 +1,84 @@
/**
* Adapter node-telegram-bot-api v1 -> v2.
* v2 (>=2.0) tidak kompatibel dengan v1 (bot.onText, bot.sendMessage(chatId, text, opts), ...).
* Adapter ini menyediakan permukaan API v1 yang dipakai services/telegramBot.js,
* sehingga file itu tak perlu dirombak. Terisolasi di sini agar mudah dirawat.
*/
const { Bot } = require('node-telegram-bot-api');
class CompatBot {
constructor(token) {
this.token = token;
this._bot = new Bot(token);
this._msgHandlers = [];
this._textHandlers = [];
this._cbHandlers = [];
this._errHandlers = [];
// v2 = middleware (bukan EventEmitter). Satu middleware meneruskan ke semua handler v1.
this._bot.on('message', (ctx) => this._dispatchMessage(ctx));
this._bot.on('callback_query', (ctx) => this._dispatchCallback(ctx));
// v1 `{ polling: true }` autostart; ditunda agar handler selesai didaftarkan lebih dulu.
setImmediate(() => this.startPolling());
}
_run(handler, args) {
try {
const r = handler(...args);
if (r && typeof r.catch === 'function') r.catch((e) => this._emitError(e));
} catch (e) {
this._emitError(e);
}
}
_dispatchMessage(ctx) {
const msg = ctx.message;
if (!msg) return;
for (const h of this._msgHandlers) this._run(h, [msg]);
if (typeof msg.text === 'string') {
for (const { re, h } of this._textHandlers) {
const m = msg.text.match(re);
if (m) this._run(h, [msg, m]);
}
}
}
_dispatchCallback(ctx) {
const q = ctx.callbackQuery;
if (!q) return;
for (const h of this._cbHandlers) this._run(h, [q]);
}
_emitError(e) {
const err = e instanceof Error ? e : new Error(String(e));
if (this._errHandlers.length) {
for (const h of this._errHandlers) { try { h(err); } catch (_) {} }
} else {
console.error('Telegram error:', err.message);
}
}
on(kind, handler) {
if (kind === 'message') this._msgHandlers.push(handler);
else if (kind === 'callback_query') this._cbHandlers.push(handler);
else if (kind === 'polling_error' || kind === 'error') {
this._errHandlers.push(handler);
this._bot.catch((err) => this._emitError(err));
}
return this;
}
onText(re, handler) { this._textHandlers.push({ re, h: handler }); return this; }
sendMessage(chatId, text, opts) { return this._bot.api.sendMessage({ chat_id: chatId, text, ...(opts || {}) }); }
editMessageText(text, opts) { return this._bot.api.editMessageText({ text, ...(opts || {}) }); }
answerCallbackQuery(id, opts) { return this._bot.api.answerCallbackQuery({ callback_query_id: id, ...(opts || {}) }); }
getMe() { return this._bot.api.getMe(); }
deleteWebHook() { return this._bot.api.deleteWebhook({}); }
stopPolling() { try { this._bot.stop(); } catch (e) {} }
startPolling() { this._bot.startPolling().catch((e) => this._emitError(e)); }
isPolling() { return this._bot.isRunning(); }
}
module.exports = { CompatBot };
+1 -1
View File
@@ -133,7 +133,7 @@ document.addEventListener('DOMContentLoaded', () => {
OPEN-ISP BILLING SYSTEM OPEN-ISP BILLING SYSTEM
</a> </a>
<div style="color:#64748b;font-size:10px;margin-top:3px;line-height:1.5"> <div style="color:#64748b;font-size:10px;margin-top:3px;line-height:1.5">
v<%= (typeof appVersion !== 'undefined' ? appVersion : '0.5') %> (pickle-lime) · ISC · oxpoe v<%= (typeof appVersion !== 'undefined' ? appVersion : '0.5.1') %> (pickle-lime) · ISC · oxpoe
</div> </div>
</div> </div>
</nav> </nav>
+13
View File
@@ -432,6 +432,19 @@
</div> </div>
</div> </div>
<!-- Fitur Opsional -->
<div data-setcat="notif">
<h6 style="font-size:13px;color:var(--muted);margin-bottom:12px;text-transform:uppercase;letter-spacing:1px"><i class="bi bi-toggles"></i> Fitur Opsional</h6>
<div class="fg">
<label class="fl"><%= t('admin.set.voucher_enabled','Fitur Voucher (Beli Voucher Wi-Fi)') %></label>
<select class="fs" name="voucher_enabled">
<option value="true" <%= (settings.voucher_enabled === false || settings.voucher_enabled === 'false' || settings.voucher_enabled === '0' || settings.voucher_enabled === 0) ? '' : 'selected' %>><%= t('common.active','Aktif') %></option>
<option value="false" <%= (settings.voucher_enabled === false || settings.voucher_enabled === 'false' || settings.voucher_enabled === '0' || settings.voucher_enabled === 0) ? 'selected' : '' %>><%= t('common.inactive','Nonaktif') %></option>
</select>
<div style="font-size:10px; color:var(--muted); margin-top:4px">Nonaktifkan untuk menyembunyikan halaman Beli Voucher publik (mis. saat deploy).</div>
</div>
</div>
<!-- WhatsApp --> <!-- WhatsApp -->
<div data-setcat="notif"> <div data-setcat="notif">
<h6 style="font-size:13px;color:var(--muted);margin-bottom:12px;text-transform:uppercase;letter-spacing:1px"><i class="bi bi-whatsapp"></i> Integrasi WhatsApp</h6> <h6 style="font-size:13px;color:var(--muted);margin-bottom:12px;text-transform:uppercase;letter-spacing:1px"><i class="bi bi-whatsapp"></i> Integrasi WhatsApp</h6>
+1 -1
View File
@@ -180,7 +180,7 @@
</div> </div>
<div class="iso-foot"> <div class="iso-foot">
&copy; <%= new Date().getFullYear() %> • <%= settings.company_header || 'OPEN-ISP' %> • OPEN-ISP v0.5 (pickle-lime) · ISC · oxpoe — terima kasih atas dukungan Anda. &copy; <%= new Date().getFullYear() %> • <%= settings.company_header || 'OPEN-ISP' %> • <%= settings.company_header || 'OPEN-ISP' %> v<%= (typeof appVersion !== 'undefined' ? appVersion : '0.5.1') %> (<%= (typeof appVersionName !== 'undefined' ? appVersionName : 'pickle-lime') %>) · ISC · oxpoe — terima kasih atas dukungan Anda.
<div style="margin-top:6px"> <div style="margin-top:6px">
<a href="/admin" style="color:var(--muted)"><i class="bi bi-speedometer2"></i> <%= t('donate.admin_panel','Admin Panel') %></a> <a href="/admin" style="color:var(--muted)"><i class="bi bi-speedometer2"></i> <%= t('donate.admin_panel','Admin Panel') %></a>
&nbsp;•&nbsp; &nbsp;•&nbsp;
+1 -1
View File
@@ -352,7 +352,7 @@
</div> </div>
<div class="foot-bottom"> <div class="foot-bottom">
<span>&copy; <span id="year"></span> <%= brand %>. Seluruh hak cipta dilindungi.</span> <span>&copy; <span id="year"></span> <%= brand %>. Seluruh hak cipta dilindungi.</span>
<span class="mono">OPEN-ISP v<%= (typeof appVersion !== 'undefined' ? appVersion : '0.5') %> (<%= (typeof appVersionName !== 'undefined' ? appVersionName : 'pickle-lime') %>) · ISC · oxpoe</span> <span class="mono"><%= (typeof settings !== 'undefined' && settings && settings.company_header) ? settings.company_header : 'OPEN-ISP' %> v<%= (typeof appVersion !== 'undefined' ? appVersion : '0.5.1') %> (<%= (typeof appVersionName !== 'undefined' ? appVersionName : 'pickle-lime') %>) · ISC · oxpoe</span>
</div> </div>
</div> </div>
</footer> </footer>
+5 -4
View File
@@ -1,5 +1,6 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="<%= lang || 'id' %>" data-theme="dark"> <html lang="<%= lang || 'id' %>" data-theme="dark">
<% const voucherOn = !(settings && (settings.voucher_enabled === false || settings.voucher_enabled === 'false' || settings.voucher_enabled === '0' || settings.voucher_enabled === 0)); %>
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
@@ -41,7 +42,7 @@
<a href="#packages">Paket</a> <a href="#packages">Paket</a>
<a href="#features"><%= t('landing.nav.features','Keunggulan') %></a> <a href="#features"><%= t('landing.nav.features','Keunggulan') %></a>
<a href="/customer/check-billing"><%= t('landing.hero.check','Cek Tagihan') %></a> <a href="/customer/check-billing"><%= t('landing.hero.check','Cek Tagihan') %></a>
<a href="/customer/voucher"><%= t('login.voucher_nav','Voucher') %></a> <% if (voucherOn) { %><a href="/customer/voucher"><%= t('login.voucher_nav','Voucher') %></a><% } %>
<% if (typeof settings !== 'undefined' && settings && settings.status_url) { %><a href="<%= settings.status_url %>" target="_blank" rel="noopener">Status</a><% } %> <% if (typeof settings !== 'undefined' && settings && settings.status_url) { %><a href="<%= settings.status_url %>" target="_blank" rel="noopener">Status</a><% } %>
</nav> </nav>
<div class="header-actions"> <div class="header-actions">
@@ -56,7 +57,7 @@
<a href="#packages" onclick="toggleMenu()"><%= t('landing.nav.packages_full','Paket & Harga') %></a> <a href="#packages" onclick="toggleMenu()"><%= t('landing.nav.packages_full','Paket & Harga') %></a>
<a href="#features" onclick="toggleMenu()"><%= t('landing.nav.features','Keunggulan') %></a> <a href="#features" onclick="toggleMenu()"><%= t('landing.nav.features','Keunggulan') %></a>
<a href="/customer/check-billing" onclick="toggleMenu()"><i class="bi bi-receipt"></i> <%= t('landing.hero.check','Cek Tagihan') %></a> <a href="/customer/check-billing" onclick="toggleMenu()"><i class="bi bi-receipt"></i> <%= t('landing.hero.check','Cek Tagihan') %></a>
<a href="/customer/voucher" onclick="toggleMenu()"><i class="bi bi-ticket-perforated"></i> <%= t('login.voucher_short','Beli Voucher') %></a> <% if (voucherOn) { %><a href="/customer/voucher" onclick="toggleMenu()"><i class="bi bi-ticket-perforated"></i> <%= t('login.voucher_short','Beli Voucher') %></a><% } %>
<a href="/customer/register" onclick="toggleMenu()"><i class="bi bi-person-plus"></i> <%= t('login.register_new','Daftar Baru') %></a> <a href="/customer/register" onclick="toggleMenu()"><i class="bi bi-person-plus"></i> <%= t('login.register_new','Daftar Baru') %></a>
<% if (typeof settings !== 'undefined' && settings && settings.status_url) { %><a href="<%= settings.status_url %>" target="_blank" rel="noopener" onclick="toggleMenu()"><i class="bi bi-activity"></i> Status Server</a><% } %> <% if (typeof settings !== 'undefined' && settings && settings.status_url) { %><a href="<%= settings.status_url %>" target="_blank" rel="noopener" onclick="toggleMenu()"><i class="bi bi-activity"></i> Status Server</a><% } %>
</div> </div>
@@ -120,7 +121,7 @@
<div class="quick-tools"> <div class="quick-tools">
<a href="/customer/check-billing" class="qtool"><i class="bi bi-receipt"></i> <%= t('landing.hero.check','Cek Tagihan') %></a> <a href="/customer/check-billing" class="qtool"><i class="bi bi-receipt"></i> <%= t('landing.hero.check','Cek Tagihan') %></a>
<a href="/customer/voucher" class="qtool"><i class="bi bi-ticket-perforated"></i> <%= t('login.voucher_short','Beli Voucher') %></a> <% if (voucherOn) { %><a href="/customer/voucher" class="qtool"><i class="bi bi-ticket-perforated"></i> <%= t('login.voucher_short','Beli Voucher') %></a><% } %>
</div> </div>
<div class="auth-foot"> <div class="auth-foot">
@@ -209,7 +210,7 @@
<h4>Portal</h4> <h4>Portal</h4>
<div class="foot-links"> <div class="foot-links">
<a href="/customer/check-billing"><%= t('login.check_full','Cek & Bayar Tagihan') %></a> <a href="/customer/check-billing"><%= t('login.check_full','Cek & Bayar Tagihan') %></a>
<a href="/customer/voucher"><%= t('login.voucher','Beli Voucher Wi-Fi') %></a> <% if (voucherOn) { %><a href="/customer/voucher"><%= t('login.voucher','Beli Voucher Wi-Fi') %></a><% } %>
<a href="/customer/register"><%= t('login.new_install','Pasang Baru') %></a> <a href="/customer/register"><%= t('login.new_install','Pasang Baru') %></a>
<a href="/customer/dashboard"><%= t('login.dashboard','Dashboard Pelanggan') %></a> <a href="/customer/dashboard"><%= t('login.dashboard','Dashboard Pelanggan') %></a>
</div> </div>
+3 -3
View File
@@ -916,7 +916,7 @@
</div> </div>
<div> <div>
<h1 class="font-bold text-base tracking-tight" style="font-family:'Space Grotesk',sans-serif;color:var(--text);"> <h1 class="font-bold text-base tracking-tight" style="font-family:'Space Grotesk',sans-serif;color:var(--text);">
<% if (brandName === 'OPEN-ISP') { %>SKY<span style="color:var(--lime);">FIBER</span><% } else { %><%= brandName %><% } %> <%= brandName %>
</h1> </h1>
<p class="text-[10px]" style="color:var(--muted);margin-top:1px;"><%= t('pwa.title','Portal Pelanggan') %></p> <p class="text-[10px]" style="color:var(--muted);margin-top:1px;"><%= t('pwa.title','Portal Pelanggan') %></p>
</div> </div>
@@ -959,7 +959,7 @@
<img src="<%= brandLogo %>" alt="<%= brandName %>" style="width:100%;height:100%;object-fit:contain;padding:3px;" onerror="this.style.display='none';if(this.nextElementSibling)this.nextElementSibling.style.display=''"><i class="ph-bold ph-wifi-high" style="display:none;color:var(--lime);font-size:15px;"></i> <img src="<%= brandLogo %>" alt="<%= brandName %>" style="width:100%;height:100%;object-fit:contain;padding:3px;" onerror="this.style.display='none';if(this.nextElementSibling)this.nextElementSibling.style.display=''"><i class="ph-bold ph-wifi-high" style="display:none;color:var(--lime);font-size:15px;"></i>
</div> </div>
<span class="brand-word font-bold text-base tracking-tight" style="font-family:'Space Grotesk',sans-serif;color:var(--text);"> <span class="brand-word font-bold text-base tracking-tight" style="font-family:'Space Grotesk',sans-serif;color:var(--text);">
<% if (brandName === 'OPEN-ISP') { %>SKY<span style="color:var(--lime);">FIBER</span><% } else { %><%= brandName %><% } %> <%= brandName %>
</span> </span>
</div> </div>
<div class="hidden md:block desktop-title"> <div class="hidden md:block desktop-title">
@@ -1014,7 +1014,7 @@
<div class="cc-brand-mark" style="overflow:hidden;"> <div class="cc-brand-mark" style="overflow:hidden;">
<img src="<%= brandLogo %>" alt="<%= brandName %>" style="width:100%;height:100%;object-fit:contain;padding:2px;" onerror="this.style.display='none';if(this.nextElementSibling)this.nextElementSibling.style.display=''"><i class="ph-bold ph-wifi-high" style="display:none;font-size:11px;"></i> <img src="<%= brandLogo %>" alt="<%= brandName %>" style="width:100%;height:100%;object-fit:contain;padding:2px;" onerror="this.style.display='none';if(this.nextElementSibling)this.nextElementSibling.style.display=''"><i class="ph-bold ph-wifi-high" style="display:none;font-size:11px;"></i>
</div> </div>
<span style="color:#fff;"><% if (brandName === 'OPEN-ISP') { %>SKY<span>FIBER</span><% } else { %><%= brandName %><% } %></span> <span style="color:#fff;"><%= brandName %></span>
</div> </div>
<div class="cc-top-badges"> <div class="cc-top-badges">
<span class="cc-status"><span class="dot"></span><%= portal.statusText %></span> <span class="cc-status"><span class="dot"></span><%= portal.statusText %></span>