feat: add internal admin tooling — log viewer and Scalar API docs
- server/logviewer.html: readable log viewer at GET /api/logs/view — level filter chips, free-text search, and click-to-trace (clicking any field value re-filters to every line sharing it, e.g. an order_id or request id across its whole lifecycle). GET /api/logs/files/:filename now redirects browser navigation here instead of returning raw JSON (Accept: application/json still gets the JSON body). - server/openapi.json + server/api-docs.html: OpenAPI 3.0 spec and a Scalar reference page served at GET /openapi.json and GET /docs. Scalar is loaded from a version-pinned CDN URL with a Subresource Integrity hash rather than installed as a dependency. - Generalize requireLogBasicAuth -> requireAdminAuth since the same HTTP Basic Auth credentials now gate /api/logs*, /openapi.json, and /docs alike. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
441e6261a2
commit
c9149eec12
|
|
@ -0,0 +1,18 @@
|
|||
<!doctype html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Midtrans Middleware — API Docs</title>
|
||||
<style>
|
||||
body { margin: 0; background: #0f1115; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<script id="api-reference" data-url="/openapi.json" data-configuration='{"theme":"purple","darkMode":true}'></script>
|
||||
<script
|
||||
src="https://cdn.jsdelivr.net/npm/@scalar/api-reference@1.25.68/dist/browser/standalone.js"
|
||||
integrity="sha384-v9vXqqwFlfQ80J0rCh/ErF90VG7ymwSmE1r+K7cw8Vroy4kX1N/0PWsdXY/vvcm5"
|
||||
crossorigin="anonymous"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -226,6 +226,24 @@ if (!fs.existsSync(LOG_DIR)) {
|
|||
fs.mkdirSync(LOG_DIR, { recursive: true })
|
||||
}
|
||||
|
||||
// LOG VIEWER PAGE (STATIC, LOADED ONCE — FETCHES /api/logs/* CLIENT-SIDE)
|
||||
let LOG_VIEWER_HTML = ''
|
||||
try {
|
||||
LOG_VIEWER_HTML = fs.readFileSync(path.join(__dirname, 'logviewer.html'), 'utf8')
|
||||
} catch (e) {
|
||||
console.warn('[log-viewer] Failed to load logviewer.html:', e.message)
|
||||
}
|
||||
|
||||
// API DOCS (SCALAR) — STATIC, LOADED ONCE
|
||||
let API_DOCS_HTML = ''
|
||||
let OPENAPI_JSON = ''
|
||||
try {
|
||||
API_DOCS_HTML = fs.readFileSync(path.join(__dirname, 'api-docs.html'), 'utf8')
|
||||
OPENAPI_JSON = fs.readFileSync(path.join(__dirname, 'openapi.json'), 'utf8')
|
||||
} catch (e) {
|
||||
console.warn('[api-docs] Failed to load api-docs.html/openapi.json:', e.message)
|
||||
}
|
||||
|
||||
// LOG RETENTION — DELETE LOGS_*.log FILES OLDER THAN LOG_RETENTION_DAYS
|
||||
const LOG_RETENTION_DAYS = parseInt(process.env.LOG_RETENTION_DAYS || '30', 10)
|
||||
|
||||
|
|
@ -496,11 +514,11 @@ function timingSafeEqualStr(a, b) {
|
|||
return crypto.timingSafeEqual(bufA, bufB)
|
||||
}
|
||||
|
||||
function requireLogBasicAuth(req, res, next) {
|
||||
function requireAdminAuth(req, res, next) {
|
||||
if (!LOG_BASIC_AUTH_USER || !LOG_BASIC_AUTH_PASS) {
|
||||
if (isDevEnv()) return next()
|
||||
logWarn('logs.auth.not_configured', { id: req.id })
|
||||
return res.status(503).json({ error: 'LOG_AUTH_NOT_CONFIGURED', message: 'Set LOG_BASIC_AUTH_USER and LOG_BASIC_AUTH_PASS to enable log access.' })
|
||||
logWarn('admin.auth.not_configured', { id: req.id })
|
||||
return res.status(503).json({ error: 'ADMIN_AUTH_NOT_CONFIGURED', message: 'Set LOG_BASIC_AUTH_USER and LOG_BASIC_AUTH_PASS to enable access.' })
|
||||
}
|
||||
|
||||
const header = req.headers['authorization'] || ''
|
||||
|
|
@ -517,9 +535,9 @@ function requireLogBasicAuth(req, res, next) {
|
|||
|
||||
const ok = user && pass && timingSafeEqualStr(user, LOG_BASIC_AUTH_USER) && timingSafeEqualStr(pass, LOG_BASIC_AUTH_PASS)
|
||||
if (!ok) {
|
||||
if (scheme === 'Basic') logWarn('logs.auth.failed', { id: req.id, user })
|
||||
res.set('WWW-Authenticate', 'Basic realm="Midtrans Middleware Logs"')
|
||||
return res.status(401).json({ error: 'UNAUTHORIZED', message: 'Login required to access logs' })
|
||||
if (scheme === 'Basic') logWarn('admin.auth.failed', { id: req.id, user })
|
||||
res.set('WWW-Authenticate', 'Basic realm="Midtrans Middleware Admin"')
|
||||
return res.status(401).json({ error: 'UNAUTHORIZED', message: 'Login required' })
|
||||
}
|
||||
next()
|
||||
}
|
||||
|
|
@ -528,7 +546,7 @@ function requireLogBasicAuth(req, res, next) {
|
|||
* Get recent logs from memory (dev/debug only)
|
||||
* GET /api/logs?limit=100&level=debug|info|warn|error&q=keyword
|
||||
*/
|
||||
app.get('/api/logs', requireLogBasicAuth, (req, res) => {
|
||||
app.get('/api/logs', requireAdminAuth, (req, res) => {
|
||||
if (!LOG_EXPOSE_API) {
|
||||
return res.status(403).json({ error: 'FORBIDDEN', message: 'Log API disabled. Set LOG_EXPOSE_API=true to enable.' })
|
||||
}
|
||||
|
|
@ -551,7 +569,7 @@ app.get('/api/logs', requireLogBasicAuth, (req, res) => {
|
|||
* List all log files in logs directory
|
||||
* GET /api/logs/files
|
||||
*/
|
||||
app.get('/api/logs/files', requireLogBasicAuth, (req, res) => {
|
||||
app.get('/api/logs/files', requireAdminAuth, (req, res) => {
|
||||
if (!LOG_EXPOSE_API) {
|
||||
return res.status(403).json({ error: 'FORBIDDEN', message: 'Log API disabled. Set LOG_EXPOSE_API=true to enable.' })
|
||||
}
|
||||
|
|
@ -579,13 +597,18 @@ app.get('/api/logs/files', requireLogBasicAuth, (req, res) => {
|
|||
* Read specific log file
|
||||
* GET /api/logs/files/:filename
|
||||
*/
|
||||
app.get('/api/logs/files/:filename', requireLogBasicAuth, (req, res) => {
|
||||
app.get('/api/logs/files/:filename', requireAdminAuth, (req, res) => {
|
||||
if (!LOG_EXPOSE_API) {
|
||||
return res.status(403).json({ error: 'FORBIDDEN', message: 'Log API disabled. Set LOG_EXPOSE_API=true to enable.' })
|
||||
}
|
||||
try {
|
||||
const { filename } = req.params
|
||||
|
||||
// BROWSER NAVIGATION (NOT fetch()/API CLIENTS) GETS THE READABLE VIEWER INSTEAD OF RAW JSON
|
||||
if (req.accepts(['json', 'html']) === 'html') {
|
||||
return res.redirect(`/api/logs/view?file=${encodeURIComponent(filename)}`)
|
||||
}
|
||||
|
||||
// SECURITY: VALIDATE FILENAME TO PREVENT DIRECTORY TRAVERSAL
|
||||
if (!filename.match(/^LOGS_\d{8}\.log$/)) {
|
||||
return res.status(400).json({ error: 'INVALID_FILENAME', message: 'Invalid log filename format' })
|
||||
|
|
@ -611,6 +634,43 @@ app.get('/api/logs/files/:filename', requireLogBasicAuth, (req, res) => {
|
|||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Readable log viewer (search, level filter, click-to-trace) — fetches
|
||||
* /api/logs/files and /api/logs/files/:filename client-side.
|
||||
* GET /api/logs/view?file=LOGS_DDMMYYYY.log
|
||||
*/
|
||||
app.get('/api/logs/view', requireAdminAuth, (req, res) => {
|
||||
if (!LOG_EXPOSE_API) {
|
||||
return res.status(403).json({ error: 'FORBIDDEN', message: 'Log API disabled. Set LOG_EXPOSE_API=true to enable.' })
|
||||
}
|
||||
if (!LOG_VIEWER_HTML) {
|
||||
return res.status(500).json({ error: 'VIEWER_UNAVAILABLE', message: 'logviewer.html failed to load' })
|
||||
}
|
||||
res.type('html').send(LOG_VIEWER_HTML)
|
||||
})
|
||||
|
||||
/**
|
||||
* OpenAPI spec backing the /docs page.
|
||||
* GET /openapi.json
|
||||
*/
|
||||
app.get('/openapi.json', requireAdminAuth, (req, res) => {
|
||||
if (!OPENAPI_JSON) {
|
||||
return res.status(500).json({ error: 'SPEC_UNAVAILABLE', message: 'openapi.json failed to load' })
|
||||
}
|
||||
res.type('json').send(OPENAPI_JSON)
|
||||
})
|
||||
|
||||
/**
|
||||
* Interactive API reference (Scalar), gated by the same credentials as /api/logs*.
|
||||
* GET /docs
|
||||
*/
|
||||
app.get('/docs', requireAdminAuth, (req, res) => {
|
||||
if (!API_DOCS_HTML) {
|
||||
return res.status(500).json({ error: 'DOCS_UNAVAILABLE', message: 'api-docs.html failed to load' })
|
||||
}
|
||||
res.type('html').send(API_DOCS_HTML)
|
||||
})
|
||||
|
||||
/**
|
||||
* Update payment toggles at runtime (dev only)
|
||||
* POST /api/config
|
||||
|
|
|
|||
|
|
@ -0,0 +1,246 @@
|
|||
<!doctype html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Midtrans Middleware — Log Viewer</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0a0b0e; --panel: #1c1f28; --border: #3a4050; --text: #f5f7fa; --dim: #aab2c5;
|
||||
--accent: #6ab5ff; --debug: #9aa3b8; --debug-fg: #0a0b0e; --info: #3b82f6; --info-fg: #ffffff;
|
||||
--warn: #f5a524; --warn-fg: #1a1200; --error: #ef4444; --error-fg: #ffffff;
|
||||
--pill-bg: #2b3040; --pill-border: #454c60; --pill-hover: #394158;
|
||||
}
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root { --bg: #eef0f4; --panel: #ffffff; --border: #c7ccd6; --text: #10131a; --dim: #454b58;
|
||||
--pill-bg: #e6e9f0; --pill-border: #c2c8d4; --pill-hover: #d7dbe6;
|
||||
--debug-fg: #ffffff; --info-fg: #ffffff; --warn-fg: #1a1200; --error-fg: #ffffff; }
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: var(--bg); color: var(--text); font-family: -apple-system, Segoe UI, Roboto, sans-serif; }
|
||||
header { position: sticky; top: 0; z-index: 5; background: var(--panel); border-bottom: 1px solid var(--border);
|
||||
padding: 10px 14px; display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
|
||||
header h1 { font-size: 14px; margin: 0 12px 0 0; color: var(--dim); font-weight: 700; white-space: nowrap; letter-spacing: .04em; }
|
||||
select, input[type=text], button { background: var(--pill-bg); color: var(--text); border: 1px solid var(--pill-border);
|
||||
border-radius: 6px; padding: 6px 10px; font-size: 13px; font-family: inherit; }
|
||||
input[type=text] { min-width: 220px; flex: 1 1 220px; }
|
||||
input[type=text]::placeholder { color: var(--dim); }
|
||||
button { cursor: pointer; font-weight: 600; }
|
||||
button:hover { background: var(--pill-hover); }
|
||||
.levels { display: flex; gap: 5px; }
|
||||
.lvl-chip { padding: 5px 10px; border-radius: 6px; border: 1.5px solid var(--border); cursor: pointer; font-size: 12px;
|
||||
font-weight: 500; opacity: .4; user-select: none; background: transparent; }
|
||||
.lvl-chip.on { opacity: 1; }
|
||||
.lvl-chip[data-lvl=DEBUG].on { color: var(--debug-fg); background: var(--debug); border-color: var(--debug); }
|
||||
.lvl-chip[data-lvl=INFO].on { color: var(--info-fg); background: var(--info); border-color: var(--info); }
|
||||
.lvl-chip[data-lvl=WARN].on { color: var(--warn-fg); background: var(--warn); border-color: var(--warn); }
|
||||
.lvl-chip[data-lvl=ERROR].on { color: var(--error-fg); background: var(--error); border-color: var(--error); }
|
||||
.lvl-chip[data-lvl=DEBUG]:not(.on) { color: var(--debug); }
|
||||
.lvl-chip[data-lvl=INFO]:not(.on) { color: var(--info); }
|
||||
.lvl-chip[data-lvl=WARN]:not(.on) { color: var(--warn); }
|
||||
.lvl-chip[data-lvl=ERROR]:not(.on) { color: var(--error); }
|
||||
#stats { color: var(--dim); font-size: 12px; white-space: nowrap; font-weight: 600; }
|
||||
#trace-bar { display: none; align-items: center; gap: 8px; padding: 8px 14px; background: var(--pill-bg);
|
||||
border-bottom: 1px solid var(--border); font-size: 12px; }
|
||||
#trace-bar.on { display: flex; }
|
||||
#trace-bar b { color: var(--accent); }
|
||||
main { padding: 8px 14px 40px; max-width: 100%; }
|
||||
.row { display: flex; gap: 10px; padding: 6px 8px; border-radius: 6px; font-size: 12.5px; font-family: ui-monospace, SFMono-Regular, Consolas, Menlo, monospace;
|
||||
align-items: flex-start; border-left: 4px solid transparent; }
|
||||
.row:hover { background: var(--panel); }
|
||||
.row.ERROR { border-left-color: var(--error); background: color-mix(in srgb, var(--error) 10%, transparent); }
|
||||
.row.WARN { border-left-color: var(--warn); background: color-mix(in srgb, var(--warn) 8%, transparent); }
|
||||
.row.INFO { border-left-color: transparent; }
|
||||
.row.DEBUG { opacity: .7; }
|
||||
.ts { color: var(--dim); white-space: nowrap; flex: 0 0 auto; font-weight: 600; }
|
||||
.badge { flex: 0 0 auto; font-weight: 600; padding: 2px 7px; border-radius: 4px; font-size: 11px; letter-spacing: .03em; }
|
||||
.badge.DEBUG { color: var(--debug-fg); background: var(--debug); }
|
||||
.badge.INFO { color: var(--info-fg); background: var(--info); }
|
||||
.badge.WARN { color: var(--warn-fg); background: var(--warn); }
|
||||
.badge.ERROR { color: var(--error-fg); background: var(--error); }
|
||||
.msg { flex: 0 0 auto; font-weight: 700; white-space: nowrap; color: var(--text); }
|
||||
.meta { flex: 1 1 auto; color: var(--dim); display: flex; flex-wrap: wrap; gap: 5px; min-width: 0; }
|
||||
.pill { background: var(--pill-bg); border: 1px solid var(--pill-border); border-radius: 4px; padding: 1px 7px; cursor: pointer; white-space: nowrap; color: var(--text); }
|
||||
.pill:hover { background: var(--pill-hover); border-color: var(--accent); }
|
||||
.pill.hit { outline: 2px solid var(--accent); border-color: var(--accent); color: var(--accent); font-weight: 700; }
|
||||
#empty { color: var(--dim); text-align: center; padding: 40px; font-size: 13px; }
|
||||
#jump { position: fixed; right: 20px; bottom: 20px; border-radius: 20px; padding: 8px 14px; box-shadow: 0 2px 10px rgba(0,0,0,.3);
|
||||
background: var(--accent); color: #06121f; border: none; font-weight: 800; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>LOG VIEWER</h1>
|
||||
<select id="fileSelect"></select>
|
||||
<div class="levels" id="levels">
|
||||
<span class="lvl-chip on" data-lvl="DEBUG">DEBUG</span>
|
||||
<span class="lvl-chip on" data-lvl="INFO">INFO</span>
|
||||
<span class="lvl-chip on" data-lvl="WARN">WARN</span>
|
||||
<span class="lvl-chip on" data-lvl="ERROR">ERROR</span>
|
||||
</div>
|
||||
<input type="text" id="search" placeholder="Cari teks, order_id, request id...">
|
||||
<label style="font-size:12px;color:var(--dim);display:flex;align-items:center;gap:4px;">
|
||||
<input type="checkbox" id="autoRefresh"> auto-refresh 5s
|
||||
</label>
|
||||
<button id="refreshBtn">Refresh</button>
|
||||
<span id="stats"></span>
|
||||
</header>
|
||||
<div id="trace-bar">
|
||||
Trace aktif untuk: <b id="traceVal"></b>
|
||||
<button id="clearTrace">Hapus filter</button>
|
||||
</div>
|
||||
<main id="rows"><div id="empty">Memuat...</div></main>
|
||||
<button id="jump" style="display:none;">↓ Terbaru</button>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var state = { file: null, entries: [], levels: new Set(['DEBUG','INFO','WARN','ERROR']), search: '' };
|
||||
var params = new URLSearchParams(location.search);
|
||||
state.file = params.get('file') || '';
|
||||
|
||||
function parseLine(raw) {
|
||||
var m = raw.match(/^\[([^\]]+)\]\s\[(\w+)\s*\]\s(.*)$/);
|
||||
if (!m) return { ts: '', level: 'INFO', msg: raw, metaRaw: '', meta: null, raw: raw };
|
||||
var ts = m[1], level = m[2].trim(), rest = m[3];
|
||||
var sepIdx = rest.indexOf(' | ');
|
||||
var msg = sepIdx === -1 ? rest : rest.slice(0, sepIdx);
|
||||
var metaRaw = sepIdx === -1 ? '' : rest.slice(sepIdx + 3);
|
||||
var meta = null;
|
||||
if (metaRaw) { try { meta = JSON.parse(metaRaw); } catch (e) { meta = null; } }
|
||||
return { ts: ts, level: level, msg: msg, metaRaw: metaRaw, meta: meta, raw: raw };
|
||||
}
|
||||
|
||||
function fmtTime(ts) {
|
||||
if (!ts) return '';
|
||||
var i = ts.indexOf('T');
|
||||
return i === -1 ? ts : ts.slice(i + 1, ts.length - 6);
|
||||
}
|
||||
|
||||
async function loadFileList() {
|
||||
var res = await fetch('/api/logs/files', { headers: { Accept: 'application/json' } });
|
||||
if (!res.ok) return;
|
||||
var data = await res.json();
|
||||
var sel = document.getElementById('fileSelect');
|
||||
sel.innerHTML = '';
|
||||
data.files.forEach(function (f) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = f.filename; opt.textContent = f.filename + ' (' + (f.size/1024).toFixed(1) + ' KB)';
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
if (!state.file && data.files.length) state.file = data.files[0].filename;
|
||||
sel.value = state.file;
|
||||
}
|
||||
|
||||
async function loadFile() {
|
||||
document.getElementById('rows').innerHTML = '<div id="empty">Memuat...</div>';
|
||||
var res = await fetch('/api/logs/files/' + encodeURIComponent(state.file), { headers: { Accept: 'application/json' } });
|
||||
if (!res.ok) {
|
||||
document.getElementById('rows').innerHTML = '<div id="empty">Gagal memuat file (' + res.status + ')</div>';
|
||||
return;
|
||||
}
|
||||
var data = await res.json();
|
||||
state.entries = data.content.map(parseLine);
|
||||
var url = new URL(location.href); url.searchParams.set('file', state.file);
|
||||
history.replaceState(null, '', url);
|
||||
render();
|
||||
}
|
||||
|
||||
function matches(e) {
|
||||
if (!state.levels.has(e.level)) return false;
|
||||
if (!state.search) return true;
|
||||
return e.raw.toLowerCase().indexOf(state.search.toLowerCase()) !== -1;
|
||||
}
|
||||
|
||||
function metaHtml(e) {
|
||||
if (!e.meta || typeof e.meta !== 'object') {
|
||||
return e.metaRaw ? '<span class="pill">' + escapeHtml(e.metaRaw) + '</span>' : '';
|
||||
}
|
||||
return Object.keys(e.meta).map(function (k) {
|
||||
var v = e.meta[k];
|
||||
var vs = typeof v === 'object' ? JSON.stringify(v) : String(v);
|
||||
var isHit = state.search && vs.toLowerCase() === state.search.toLowerCase();
|
||||
return '<span class="pill' + (isHit ? ' hit' : '') + '" data-val="' + escapeAttr(vs) + '">' +
|
||||
escapeHtml(k) + ': ' + escapeHtml(vs) + '</span>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function escapeHtml(s) { return s.replace(/[&<>]/g, function (c) { return { '&': '&', '<': '<', '>': '>' }[c]; }); }
|
||||
function escapeAttr(s) { return escapeHtml(s).replace(/"/g, '"'); }
|
||||
|
||||
function render() {
|
||||
var filtered = state.entries.filter(matches);
|
||||
var container = document.getElementById('rows');
|
||||
if (!filtered.length) {
|
||||
container.innerHTML = '<div id="empty">Tidak ada log yang cocok dengan filter.</div>';
|
||||
} else {
|
||||
var html = filtered.map(function (e) {
|
||||
return '<div class="row ' + e.level + '">' +
|
||||
'<span class="ts" title="' + escapeAttr(e.ts) + '">' + escapeHtml(fmtTime(e.ts)) + '</span>' +
|
||||
'<span class="badge ' + e.level + '">' + e.level + '</span>' +
|
||||
'<span class="msg">' + escapeHtml(e.msg) + '</span>' +
|
||||
'<span class="meta">' + metaHtml(e) + '</span>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
container.innerHTML = html;
|
||||
}
|
||||
var counts = { DEBUG: 0, INFO: 0, WARN: 0, ERROR: 0 };
|
||||
state.entries.forEach(function (e) { if (counts[e.level] !== undefined) counts[e.level]++; });
|
||||
document.getElementById('stats').textContent =
|
||||
filtered.length + ' / ' + state.entries.length + ' baris — ' +
|
||||
'E:' + counts.ERROR + ' W:' + counts.WARN + ' I:' + counts.INFO + ' D:' + counts.DEBUG;
|
||||
}
|
||||
|
||||
document.getElementById('rows').addEventListener('click', function (ev) {
|
||||
var pill = ev.target.closest('.pill');
|
||||
if (!pill || !pill.dataset.val) return;
|
||||
setSearch(pill.dataset.val);
|
||||
});
|
||||
|
||||
function setSearch(v) {
|
||||
state.search = v;
|
||||
document.getElementById('search').value = v;
|
||||
document.getElementById('trace-bar').classList.toggle('on', !!v);
|
||||
document.getElementById('traceVal').textContent = v;
|
||||
render();
|
||||
}
|
||||
|
||||
document.getElementById('clearTrace').addEventListener('click', function () { setSearch(''); });
|
||||
document.getElementById('search').addEventListener('input', function (ev) { setSearch(ev.target.value); });
|
||||
|
||||
document.getElementById('levels').addEventListener('click', function (ev) {
|
||||
var chip = ev.target.closest('.lvl-chip');
|
||||
if (!chip) return;
|
||||
var lvl = chip.dataset.lvl;
|
||||
if (state.levels.has(lvl)) { state.levels.delete(lvl); chip.classList.remove('on'); }
|
||||
else { state.levels.add(lvl); chip.classList.add('on'); }
|
||||
render();
|
||||
});
|
||||
|
||||
document.getElementById('fileSelect').addEventListener('change', function (ev) {
|
||||
state.file = ev.target.value;
|
||||
loadFile();
|
||||
});
|
||||
|
||||
document.getElementById('refreshBtn').addEventListener('click', loadFile);
|
||||
|
||||
var timer = null;
|
||||
document.getElementById('autoRefresh').addEventListener('change', function (ev) {
|
||||
if (timer) { clearInterval(timer); timer = null; }
|
||||
if (ev.target.checked) timer = setInterval(loadFile, 5000);
|
||||
});
|
||||
|
||||
var jumpBtn = document.getElementById('jump');
|
||||
window.addEventListener('scroll', function () {
|
||||
jumpBtn.style.display = (document.body.scrollHeight - window.scrollY - window.innerHeight > 400) ? 'block' : 'none';
|
||||
});
|
||||
jumpBtn.addEventListener('click', function () { window.scrollTo(0, document.body.scrollHeight); });
|
||||
|
||||
(async function init() {
|
||||
await loadFileList();
|
||||
await loadFile();
|
||||
window.scrollTo(0, document.body.scrollHeight);
|
||||
})();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,285 @@
|
|||
{
|
||||
"openapi": "3.0.3",
|
||||
"info": {
|
||||
"title": "Midtrans Middleware API",
|
||||
"version": "1.0.0",
|
||||
"description": "Express backend integrating Midtrans (Core API + Snap) with an ERP system. Supports two payment-creation flows: a pre-registered shareable payment link (`/createtransaksi`, `POST /api/payment-links`) for ERP-initiated orders, and a direct checkout flow (`/api/payments/charge`, `/api/payments/snap/token`) called straight from the browser."
|
||||
},
|
||||
"servers": [
|
||||
{ "url": "http://localhost:8000", "description": "Local" },
|
||||
{ "url": "https://be-midtrans-cifo.winteraccess.id", "description": "Production" }
|
||||
],
|
||||
"components": {
|
||||
"securitySchemes": {
|
||||
"ApiKeyAuth": {
|
||||
"type": "apiKey",
|
||||
"in": "header",
|
||||
"name": "X-API-KEY",
|
||||
"description": "Required in production (EXTERNAL_API_KEY). Bypassed automatically when NODE_ENV is not 'production' and the key is unset."
|
||||
},
|
||||
"BasicAuth": {
|
||||
"type": "http",
|
||||
"scheme": "basic",
|
||||
"description": "Required in production (LOG_BASIC_AUTH_USER/PASS). Bypassed automatically when NODE_ENV is not 'production' and the credentials are unset."
|
||||
}
|
||||
},
|
||||
"schemas": {
|
||||
"Customer": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": { "type": "string" },
|
||||
"phone": { "type": "string" },
|
||||
"email": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"ErrorResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"error": { "type": "string" },
|
||||
"message": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"paths": {
|
||||
"/api/health": {
|
||||
"get": {
|
||||
"summary": "Health check",
|
||||
"tags": ["Health & Config"],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": { "application/json": { "example": { "ok": true, "env": { "isProduction": true, "hasServerKey": true, "hasClientKey": true } } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/config": {
|
||||
"get": {
|
||||
"summary": "Get runtime payment-method toggles and Midtrans client key",
|
||||
"tags": ["Health & Config"],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": { "application/json": { "example": { "paymentToggles": { "bank_transfer": true, "credit_card": true, "gopay": true, "cstore": true }, "midtransEnv": "production", "clientKey": "Mid-client-xxx" } } }
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"summary": "Update payment-method toggles at runtime (non-production only)",
|
||||
"tags": ["Health & Config"],
|
||||
"requestBody": {
|
||||
"content": { "application/json": { "example": { "paymentToggles": { "bank_transfer": false } } } }
|
||||
},
|
||||
"responses": {
|
||||
"200": { "description": "Updated" },
|
||||
"403": { "description": "Disabled in production", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/payment-links": {
|
||||
"post": {
|
||||
"summary": "Create a shareable Midtrans Snap payment link from a direct payload",
|
||||
"description": "order_id is used as-is for tracking, and sanitized (any character outside [A-Za-z0-9-_~.] -> '.') for the value actually sent to Midtrans. mercant_id used for the ERP callback is derived from the segment before ':' in the original order_id. Rate-limited.",
|
||||
"tags": ["Payment Links"],
|
||||
"security": [{ "ApiKeyAuth": [] }],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"order_id": "ERPSKRIP-2608030000000627:TKG-260803000063",
|
||||
"nominal": 179000,
|
||||
"customer": { "name": "Yusnika Nur Faidah", "phone": "0881022144656", "email": "yusnika_nur_faidah@example.com" },
|
||||
"expire_at": 1785852063058
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Link created",
|
||||
"content": { "application/json": { "example": { "status": "200", "messages": "SUCCESS", "data": { "url": "http://localhost:5173/pay/eyJ2Ijox...", "order_id": "ERPSKRIP-2608030000000627:TKG-260803000063", "midtrans_order_id": "ERPSKRIP-2608030000000627.TKG-260803000063", "expire_at": 1785852063058 } } } }
|
||||
},
|
||||
"400": { "description": "Missing/invalid order_id or nominal" },
|
||||
"401": { "description": "Invalid X-API-KEY" },
|
||||
"409": { "description": "Order already completed or has an active link/pending Midtrans transaction" },
|
||||
"429": { "description": "Rate limited" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/payment-links/{token}": {
|
||||
"get": {
|
||||
"summary": "Resolve a payment link token",
|
||||
"tags": ["Payment Links"],
|
||||
"parameters": [{ "name": "token", "in": "path", "required": true, "schema": { "type": "string" } }],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": { "application/json": { "example": { "order_id": "ERPSKRIP-2608030000000627.TKG-260803000063", "nominal": 179000, "customer": { "name": "Yusnika Nur Faidah", "phone": "0881022144656", "email": "yusnika_nur_faidah@example.com" }, "expire_at": 1785852063058, "allowed_methods": null } } }
|
||||
},
|
||||
"400": { "description": "Invalid token" },
|
||||
"410": { "description": "Token expired" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/createtransaksi": {
|
||||
"post": {
|
||||
"summary": "Create a shareable Midtrans Snap payment link (ERP-facing, mercant_id/item shape)",
|
||||
"description": "order_id is derived server-side as `mercant_id.item[0].item_id` (dot-joined). expire_at is NOT client-supplied — computed server-side from PAYMENT_LINK_TTL_MINUTES. Rate-limited.",
|
||||
"tags": ["Payment Links"],
|
||||
"security": [{ "ApiKeyAuth": [] }],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"mercant_id": "ERPSKRIP-2608030000000627",
|
||||
"nominal": 179000,
|
||||
"nama": "Yusnika Nur Faidah",
|
||||
"no_telepon": "0881022144656",
|
||||
"email": "yusnika_nur_faidah@example.com",
|
||||
"item": [{ "item_id": "TKG-260803000063" }],
|
||||
"allowed_methods": ["bank_transfer", "gopay"]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": { "description": "Link created", "content": { "application/json": { "example": { "status": "200", "messages": "SUCCESS", "data": { "url": "http://localhost:5173/pay/eyJ2Ijox..." } } } } },
|
||||
"400": { "description": "Missing order_id/nominal" },
|
||||
"401": { "description": "Invalid X-API-KEY" },
|
||||
"409": { "description": "Order already completed or has an active link/pending Midtrans transaction" },
|
||||
"429": { "description": "Rate limited" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/payments/charge": {
|
||||
"post": {
|
||||
"summary": "Create a payment transaction via Midtrans Core API (bank transfer, card, GoPay/QRIS, cstore)",
|
||||
"description": "Called directly from the browser checkout (no pre-registration required). Rate-limited to mitigate abuse/card-testing. Blocks re-charge if the order already has a pending Midtrans transaction, and honors ENABLE_* payment-method toggles.",
|
||||
"tags": ["Payment Operations"],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": { "payment_type": "bank_transfer", "transaction_details": { "order_id": "order-123", "gross_amount": 150000 }, "bank_transfer": { "bank": "bca" } }
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": { "description": "Raw Midtrans Core API charge response (passthrough)" },
|
||||
"400": { "description": "Charge failed / payment type disabled" },
|
||||
"409": { "description": "Order already has a pending Midtrans transaction" },
|
||||
"429": { "description": "Rate limited" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/payments/snap/token": {
|
||||
"post": {
|
||||
"summary": "Generate a Midtrans Snap token for the hosted payment popup",
|
||||
"description": "Called directly from the browser (PayPage / CheckoutPage) with a raw Midtrans Snap createTransaction payload. Rate-limited.",
|
||||
"tags": ["Payment Operations"],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": { "transaction_details": { "order_id": "order-123", "gross_amount": 150000 }, "customer_details": { "first_name": "John", "email": "john@example.com", "phone": "081234567890" }, "item_details": [{ "id": "order-123", "name": "Payment", "price": 150000, "quantity": 1 }] }
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": { "description": "Snap token", "content": { "application/json": { "example": { "token": { "token": "snap-token-xxx", "redirect_url": "https://app.midtrans.com/snap/v#/xxx" } } } } },
|
||||
"400": { "description": "Snap token creation failed" },
|
||||
"429": { "description": "Rate limited" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/payments/{orderId}/status": {
|
||||
"get": {
|
||||
"summary": "Check Midtrans transaction status",
|
||||
"description": "Passthrough of Midtrans core.transaction.status(). Also fires the ERP-notify fallback (fire-and-forget) if the status is already successful and hasn't been notified yet.",
|
||||
"tags": ["Payment Operations"],
|
||||
"parameters": [{ "name": "orderId", "in": "path", "required": true, "schema": { "type": "string" } }],
|
||||
"responses": {
|
||||
"200": { "description": "Raw Midtrans status response (passthrough)" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/payments/notification": {
|
||||
"post": {
|
||||
"summary": "Midtrans webhook (unified for Core and Snap)",
|
||||
"description": "Verifies signature_key (SHA512(order_id+status_code+gross_amount+MIDTRANS_SERVER_KEY)), maps status, and notifies the ERP with { mercant_id, status_code, nominal, signature }. Returns non-2xx on ERP-notify failure so Midtrans retries.",
|
||||
"tags": ["Webhook"],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": { "order_id": "ERPSKRIP-2608030000000637.TKG-260801001361", "transaction_status": "settlement", "status_code": "200", "gross_amount": "159000.00", "signature_key": "..." }
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": { "description": "Processed (or already processed)" },
|
||||
"400": { "description": "Invalid signature" },
|
||||
"500": { "description": "ERP notification failed — Midtrans will retry" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/logs": {
|
||||
"get": {
|
||||
"summary": "Get recent in-memory log entries",
|
||||
"tags": ["Logs (internal)"],
|
||||
"security": [{ "BasicAuth": [] }],
|
||||
"parameters": [
|
||||
{ "name": "limit", "in": "query", "schema": { "type": "integer", "default": 100, "minimum": 1, "maximum": 1000 } },
|
||||
{ "name": "level", "in": "query", "schema": { "type": "string", "enum": ["debug", "info", "warn", "error"] } },
|
||||
{ "name": "q", "in": "query", "schema": { "type": "string" }, "description": "Keyword search" }
|
||||
],
|
||||
"responses": {
|
||||
"200": { "description": "OK" },
|
||||
"401": { "description": "Basic Auth required" },
|
||||
"403": { "description": "LOG_EXPOSE_API disabled" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/logs/files": {
|
||||
"get": {
|
||||
"summary": "List available daily log files",
|
||||
"tags": ["Logs (internal)"],
|
||||
"security": [{ "BasicAuth": [] }],
|
||||
"responses": {
|
||||
"200": { "description": "OK", "content": { "application/json": { "example": { "count": 2, "files": [{ "filename": "LOGS_03082026.log", "size": 39013, "modified": "2026-08-03T22:30:00.000Z", "path": "/api/logs/files/LOGS_03082026.log" }] } } } },
|
||||
"401": { "description": "Basic Auth required" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/logs/files/{filename}": {
|
||||
"get": {
|
||||
"summary": "Read a specific log file as JSON lines (or redirects to the HTML viewer for browser navigation)",
|
||||
"description": "Browser requests (Accept: text/html) are redirected to GET /api/logs/view?file=... instead of returning raw JSON.",
|
||||
"tags": ["Logs (internal)"],
|
||||
"security": [{ "BasicAuth": [] }],
|
||||
"parameters": [{ "name": "filename", "in": "path", "required": true, "schema": { "type": "string", "pattern": "^LOGS_\\d{8}\\.log$" } }],
|
||||
"responses": {
|
||||
"200": { "description": "OK", "content": { "application/json": { "example": { "filename": "LOGS_03082026.log", "lines": 5777, "content": ["[2026-08-03T21:35:54.746+07:00] [INFO ] webhook.notifying_erp | {\"order_id\":\"...\"}"] } } } },
|
||||
"302": { "description": "Redirect to the HTML viewer (browser navigation)" },
|
||||
"400": { "description": "Invalid filename" },
|
||||
"401": { "description": "Basic Auth required" },
|
||||
"404": { "description": "File not found" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/logs/view": {
|
||||
"get": {
|
||||
"summary": "Readable log viewer — search, level filter, click-to-trace",
|
||||
"tags": ["Logs (internal)"],
|
||||
"security": [{ "BasicAuth": [] }],
|
||||
"parameters": [{ "name": "file", "in": "query", "schema": { "type": "string" }, "description": "Defaults to the most recent log file" }],
|
||||
"responses": {
|
||||
"200": { "description": "HTML page", "content": { "text/html": {} } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue