diff --git a/server/index.cjs b/server/index.cjs index 34cdcc4..3e11baf 100644 --- a/server/index.cjs +++ b/server/index.cjs @@ -338,11 +338,52 @@ app.get('/api/config', (_req, res) => { res.json(payload) }) +// ============================================================================ +// LOG ACCESS AUTHENTICATION (HTTP BASIC AUTH — browser-native login prompt) +// ============================================================================ +const LOG_BASIC_AUTH_USER = process.env.LOG_BASIC_AUTH_USER || '' +const LOG_BASIC_AUTH_PASS = process.env.LOG_BASIC_AUTH_PASS || '' + +function timingSafeEqualStr(a, b) { + const bufA = Buffer.from(String(a)) + const bufB = Buffer.from(String(b)) + if (bufA.length !== bufB.length) return false + return crypto.timingSafeEqual(bufA, bufB) +} + +function requireLogBasicAuth(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.' }) + } + + const header = req.headers['authorization'] || '' + const [scheme, encoded] = header.split(' ') + let user = '', pass = '' + if (scheme === 'Basic' && encoded) { + try { + const decoded = Buffer.from(encoded, 'base64').toString('utf8') + const idx = decoded.indexOf(':') + user = idx >= 0 ? decoded.slice(0, idx) : decoded + pass = idx >= 0 ? decoded.slice(idx + 1) : '' + } catch {} + } + + 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' }) + } + 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', (req, res) => { +app.get('/api/logs', requireLogBasicAuth, (req, res) => { if (!LOG_EXPOSE_API) { return res.status(403).json({ error: 'FORBIDDEN', message: 'Log API disabled. Set LOG_EXPOSE_API=true to enable.' }) } @@ -365,7 +406,7 @@ app.get('/api/logs', (req, res) => { * List all log files in logs directory * GET /api/logs/files */ -app.get('/api/logs/files', (req, res) => { +app.get('/api/logs/files', requireLogBasicAuth, (req, res) => { if (!LOG_EXPOSE_API) { return res.status(403).json({ error: 'FORBIDDEN', message: 'Log API disabled. Set LOG_EXPOSE_API=true to enable.' }) } @@ -393,7 +434,7 @@ app.get('/api/logs/files', (req, res) => { * Read specific log file * GET /api/logs/files/:filename */ -app.get('/api/logs/files/:filename', (req, res) => { +app.get('/api/logs/files/:filename', requireLogBasicAuth, (req, res) => { if (!LOG_EXPOSE_API) { return res.status(403).json({ error: 'FORBIDDEN', message: 'Log API disabled. Set LOG_EXPOSE_API=true to enable.' }) } @@ -504,10 +545,16 @@ app.post('/api/payment-links', async (req, res) => { } // Midtrans order_id only allows alphanumeric characters and - _ ~ . (no ':') - const midtransOrderId = order_id.replace(/[^A-Za-z0-9\-_~.]/g, '.') + const sanitizedOrderId = order_id.replace(/[^A-Za-z0-9\-_~.]/g, '.') // ERP convention: "mercant_id:item_id" — take the segment before ':' as mercant_id for ERP callback matching const mercantId = order_id.includes(':') ? order_id.split(':')[0] : order_id + // Append retry suffix when a previous link for this order expired — Midtrans never allows + // reusing an order_id, even an expired one. Keyed by sanitizedOrderId so this stays aligned + // with getBaseOrderId(), which strips the retry suffix from what the webhook sends back. + const retryCount = orderRetryCount.get(sanitizedOrderId) || 0 + const midtransOrderId = retryCount > 0 ? `${sanitizedOrderId}.r${retryCount}` : sanitizedOrderId + const customer = { name: customerIn.name, phone: customerIn.phone, @@ -521,12 +568,12 @@ app.post('/api/payment-links', async (req, res) => { expire_at = now + ttlMin * 60 * 1000 } - if (notifiedOrders.has(order_id)) { + if (notifiedOrders.has(sanitizedOrderId)) { logWarn('payment-links.create.completed', { order_id }) return res.status(409).json({ error: 'ORDER_COMPLETED', message: 'Order already completed' }) } - const existing = activeOrders.get(order_id) + const existing = activeOrders.get(sanitizedOrderId) if (existing && existing > now) { logWarn('payment-links.create.active_exists', { order_id }) return res.status(409).json({ error: 'ORDER_ACTIVE', message: 'Active payment link exists' }) @@ -552,9 +599,9 @@ app.post('/api/payment-links', async (req, res) => { const token = createPaymentLinkToken({ order_id: midtransOrderId, nominal, expire_at, customer, allowed_methods }) const url = `${PAYMENT_LINK_BASE}/${token}` - activeOrders.set(order_id, expire_at) - orderMerchantId.set(order_id, mercantId) - if (midtransOrderId !== order_id) orderMerchantId.set(midtransOrderId, mercantId) + activeOrders.set(sanitizedOrderId, expire_at) + orderMerchantId.set(sanitizedOrderId, mercantId) + if (midtransOrderId !== sanitizedOrderId) orderMerchantId.set(midtransOrderId, mercantId) logInfo('payment-links.create.issued', { order_id, midtrans_order_id: midtransOrderId, expire_at }) res.json({ status: '200', messages: 'SUCCESS', data: { url, order_id, midtrans_order_id: midtransOrderId, expire_at } }) @@ -717,20 +764,28 @@ app.post('/createtransaksi', async (req, res) => { const items = Array.isArray(req?.body?.item) ? req.body.item : [] const primaryItemId = items?.[0]?.item_id - // Midtrans order_id only allows alphanumeric characters and - _ ~ . (no ':') const order_id = String( (primaryItemId && mercantId) ? `${mercantId}.${primaryItemId}` : (primaryItemId || mercantId || req?.body?.order_id || req?.body?.item_id || '') ) - // Compute Midtrans-specific order_id — append retry suffix when the previous attempt expired - const retryCount = orderRetryCount.get(order_id) || 0 - const midtransOrderId = retryCount > 0 ? `${order_id}.r${retryCount}` : order_id + // Midtrans order_id only allows alphanumeric characters and - _ ~ . (no ':') + // order_id may still contain ':' here if it came straight from req.body.order_id/item_id + // instead of the mercant_id+item[] branch, so sanitize before it's ever sent to Midtrans. + const sanitizedOrderId = order_id.replace(/[^A-Za-z0-9\-_~.]/g, '.') - if (mercantId) { + // Compute Midtrans-specific order_id — append retry suffix when the previous attempt expired. + // Keyed by sanitizedOrderId (not the raw order_id) so this stays aligned with getBaseOrderId(), + // which strips the retry suffix from the Midtrans-form id the webhook sends back. + const retryCount = orderRetryCount.get(sanitizedOrderId) || 0 + const midtransOrderId = retryCount > 0 ? `${sanitizedOrderId}.r${retryCount}` : sanitizedOrderId + + // ERP convention: "mercant_id:item_id" — fall back to the segment before ':' when mercant_id wasn't sent explicitly + const resolvedMercantId = mercantId || (order_id.includes(':') ? order_id.split(':')[0] : '') + if (resolvedMercantId) { try { - orderMerchantId.set(order_id, mercantId) - if (midtransOrderId !== order_id) orderMerchantId.set(midtransOrderId, mercantId) + orderMerchantId.set(sanitizedOrderId, resolvedMercantId) + if (midtransOrderId !== sanitizedOrderId) orderMerchantId.set(midtransOrderId, resolvedMercantId) } catch {} } @@ -750,12 +805,12 @@ app.post('/createtransaksi', async (req, res) => { const ttlMin = PAYMENT_LINK_TTL_MINUTES > 0 ? PAYMENT_LINK_TTL_MINUTES : 1440 const expire_at = now + ttlMin * 60 * 1000 - if (notifiedOrders.has(order_id)) { + if (notifiedOrders.has(sanitizedOrderId)) { logWarn('createtransaksi.completed', { order_id }) return res.status(409).json({ error: 'ORDER_COMPLETED', message: 'Order already completed' }) } - const existing = activeOrders.get(order_id) + const existing = activeOrders.get(sanitizedOrderId) if (existing && existing > now) { logWarn('createtransaksi.active_exists', { order_id }) return res.status(409).json({ error: 'ORDER_ACTIVE', message: 'Active payment link exists' }) @@ -783,7 +838,7 @@ app.post('/createtransaksi', async (req, res) => { const token = createPaymentLinkToken({ order_id: midtransOrderId, nominal, expire_at, customer, allowed_methods }) const url = `${PAYMENT_LINK_BASE}/${token}` - activeOrders.set(order_id, expire_at) + activeOrders.set(sanitizedOrderId, expire_at) logInfo('createtransaksi.issued', { order_id, midtrans_order_id: midtransOrderId, retry_count: retryCount, expire_at }) res.json({ status: '200', messages: 'SUCCESS', data: { url } })