Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b715f48f8d | ||
|
|
6abfeb4577 | ||
|
|
c6b39ae2bc | ||
|
|
3e913d8ceb | ||
|
|
ba0845da47 | ||
|
|
2aadb66f77 | ||
|
|
994e3f8d61 | ||
|
|
a77975ca44 | ||
|
|
3eb4edfe5b | ||
|
|
ebbfffc116 | ||
|
|
4185af3971 | ||
|
|
a1e1570221 | ||
|
|
dc5c1299d3 | ||
|
|
008aa871f6 | ||
|
|
63994df1e4 | ||
|
|
8caae14cfd |
No files matched your search
@@ -82,3 +82,6 @@ public/downloads/*.aab
|
|||||||
public/downloads/logo-*
|
public/downloads/logo-*
|
||||||
.npmrc
|
.npmrc
|
||||||
.gitconfig
|
.gitconfig
|
||||||
|
|
||||||
|
# penanda restore tertunda (runtime)
|
||||||
|
database/.restore-pending
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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', '') || '');
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
})
|
})
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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.
|
||||||
Generated
+51
-2437
File diff suppressed because it is too large.
Load diff
+5
-6
@@ -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
@@ -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) => {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -180,7 +180,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="iso-foot">
|
<div class="iso-foot">
|
||||||
© <%= new Date().getFullYear() %> • <%= settings.company_header || 'OPEN-ISP' %> • OPEN-ISP v0.5 (pickle-lime) · ISC · oxpoe — terima kasih atas dukungan Anda.
|
© <%= 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>
|
||||||
•
|
•
|
||||||
|
|||||||
+1
-1
@@ -352,7 +352,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="foot-bottom">
|
<div class="foot-bottom">
|
||||||
<span>© <span id="year"></span> <%= brand %>. Seluruh hak cipta dilindungi.</span>
|
<span>© <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
@@ -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
@@ -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>
|
||||||
|
|||||||
Reference in new issue
Block a user