diff --git a/server/api-docs.html b/server/api-docs.html new file mode 100644 index 0000000..9f1d6ec --- /dev/null +++ b/server/api-docs.html @@ -0,0 +1,18 @@ + + + + + + Midtrans Middleware — API Docs + + + + + + + diff --git a/server/index.cjs b/server/index.cjs index 1084981..fe8d2b7 100644 --- a/server/index.cjs +++ b/server/index.cjs @@ -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,18 +597,23 @@ 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' }) } - + const filePath = path.join(LOG_DIR, filename) if (!fs.existsSync(filePath)) { @@ -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 diff --git a/server/logviewer.html b/server/logviewer.html new file mode 100644 index 0000000..248cac3 --- /dev/null +++ b/server/logviewer.html @@ -0,0 +1,246 @@ + + + + + +Midtrans Middleware — Log Viewer + + + +
+

LOG VIEWER

+ +
+ DEBUG + INFO + WARN + ERROR +
+ + + + +
+
+ Trace aktif untuk: + +
+
Memuat...
+ + + + + diff --git a/server/openapi.json b/server/openapi.json new file mode 100644 index 0000000..31f9d9d --- /dev/null +++ b/server/openapi.json @@ -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": {} } } + } + } + } + } +}