fix: harden payment-creation endpoints and persist server state
- ecosystem.config.cjs: NODE_ENV was hardcoded to "development" even for the production pm2 process, silently disabling every isDevEnv()-gated security check (API key bypass, admin auth bypass) in production. Now "production". - Restrict CORS via configurable CORS_ALLOWED_ORIGINS (falls back to allow-all with a warning when unset, so this doesn't break existing traffic on deploy). - Add an in-process, dependency-free rate limiter on /createtransaksi, /api/payment-links, /api/payments/charge, and /api/payments/snap/token to curb bot abuse / card-testing on endpoints the browser checkout must be able to call directly (an API-key gate would break that legitimate flow). - Auto-delete LOGS_*.log files older than LOG_RETENTION_DAYS (default 30). - Persist activeOrders, notifiedOrders, orderRetryCount, and orderMerchantId to server/data/state.json via Proxy-wrapped Map/Set (schedulePersist on every mutation), so idempotency and retry-suffix tracking survive restarts/crashes instead of resetting to empty every deploy. - Remove processPaymentCompletion, dead code with no call sites. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
04c9ec233e
commit
441e6261a2
|
|
@ -1,6 +1,9 @@
|
||||||
# Logs
|
# Logs
|
||||||
logs
|
logs
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
|
# Persisted server state
|
||||||
|
server/data/
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ module.exports = {
|
||||||
script : "server/index.cjs",
|
script : "server/index.cjs",
|
||||||
|
|
||||||
env: {
|
env: {
|
||||||
NODE_ENV: "development",
|
NODE_ENV: "production",
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
209
server/index.cjs
209
server/index.cjs
|
|
@ -29,9 +29,53 @@ const TransactionLogger = {
|
||||||
// EXPRESS APP SETUP
|
// EXPRESS APP SETUP
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
const app = express()
|
const app = express()
|
||||||
|
|
||||||
|
// CORS — restrict to known frontend origins via CORS_ALLOWED_ORIGINS (comma-separated).
|
||||||
|
// Falls back to allowing all origins (with a warning) when unset, so this ships without
|
||||||
|
// breaking existing traffic until the env var is explicitly configured.
|
||||||
|
const CORS_ALLOWED_ORIGINS = parseList(process.env.CORS_ALLOWED_ORIGINS)
|
||||||
|
if (CORS_ALLOWED_ORIGINS.length > 0) {
|
||||||
|
app.use(cors({ origin: CORS_ALLOWED_ORIGINS, credentials: true }))
|
||||||
|
} else {
|
||||||
|
console.warn('[CORS] CORS_ALLOWED_ORIGINS not set — allowing all origins. Set it in production to restrict access.')
|
||||||
app.use(cors())
|
app.use(cors())
|
||||||
|
}
|
||||||
app.use(express.json())
|
app.use(express.json())
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SIMPLE IN-PROCESS RATE LIMITER (NO EXTERNAL DEPENDENCY)
|
||||||
|
// Protects payment-creation endpoints from bot abuse / card-testing without
|
||||||
|
// requiring an API key on endpoints the customer-facing checkout calls directly.
|
||||||
|
// ============================================================================
|
||||||
|
const RATE_LIMIT_WINDOW_MS = parseInt(process.env.RATE_LIMIT_WINDOW_MS || '60000', 10)
|
||||||
|
const RATE_LIMIT_MAX = parseInt(process.env.RATE_LIMIT_MAX || '20', 10)
|
||||||
|
const rateLimitBuckets = new Map() // key (ip) -> { count, resetAt }
|
||||||
|
|
||||||
|
function rateLimit(req, res, next) {
|
||||||
|
const key = String(req.headers['x-forwarded-for'] || req.socket.remoteAddress || 'unknown').split(',')[0].trim()
|
||||||
|
const now = Date.now()
|
||||||
|
let bucket = rateLimitBuckets.get(key)
|
||||||
|
if (!bucket || now > bucket.resetAt) {
|
||||||
|
bucket = { count: 0, resetAt: now + RATE_LIMIT_WINDOW_MS }
|
||||||
|
rateLimitBuckets.set(key, bucket)
|
||||||
|
}
|
||||||
|
bucket.count += 1
|
||||||
|
if (bucket.count > RATE_LIMIT_MAX) {
|
||||||
|
logWarn('rate_limit.blocked', { key, path: req.path, count: bucket.count })
|
||||||
|
res.set('Retry-After', String(Math.ceil((bucket.resetAt - now) / 1000)))
|
||||||
|
return res.status(429).json({ error: 'RATE_LIMITED', message: 'Too many requests, please try again later.' })
|
||||||
|
}
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
|
||||||
|
// PERIODIC CLEANUP SO INACTIVE IP ENTRIES DON'T GROW THE MAP FOREVER
|
||||||
|
setInterval(() => {
|
||||||
|
const now = Date.now()
|
||||||
|
for (const [key, bucket] of rateLimitBuckets) {
|
||||||
|
if (now > bucket.resetAt) rateLimitBuckets.delete(key)
|
||||||
|
}
|
||||||
|
}, RATE_LIMIT_WINDOW_MS).unref()
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// MIDTRANS CONFIGURATION
|
// MIDTRANS CONFIGURATION
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -86,8 +130,83 @@ const ERP_NOTIFICATION_URLS = (() => {
|
||||||
return ERP_NOTIFICATION_URL ? [ERP_NOTIFICATION_URL] : []
|
return ERP_NOTIFICATION_URL ? [ERP_NOTIFICATION_URL] : []
|
||||||
})()
|
})()
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// PERSISTENT STATE (SURVIVES RESTARTS) — wraps Map/Set in Proxies that
|
||||||
|
// auto-persist to disk on mutation, so activeOrders/notifiedOrders/
|
||||||
|
// orderRetryCount/orderMerchantId call sites elsewhere don't need to change.
|
||||||
|
// ============================================================================
|
||||||
|
const STATE_FILE = path.join(__dirname, 'data', 'state.json')
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(path.dirname(STATE_FILE), { recursive: true })
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
let persistTimer = null
|
||||||
|
function schedulePersist() {
|
||||||
|
if (persistTimer) return
|
||||||
|
persistTimer = setTimeout(() => {
|
||||||
|
persistTimer = null
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(STATE_FILE, JSON.stringify({
|
||||||
|
activeOrders: [...activeOrders.entries()],
|
||||||
|
notifiedOrders: [...notifiedOrders],
|
||||||
|
orderRetryCount: [...orderRetryCount.entries()],
|
||||||
|
orderMerchantId: [...orderMerchantId.entries()],
|
||||||
|
}), 'utf8')
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[state-persist] Failed to write state file:', e.message)
|
||||||
|
}
|
||||||
|
}, 250)
|
||||||
|
persistTimer.unref()
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadPersistedState() {
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(STATE_FILE)) return {}
|
||||||
|
return JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'))
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[state-persist] Failed to read state file:', e.message)
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function watchedMap(initialEntries) {
|
||||||
|
const map = new Map(initialEntries || [])
|
||||||
|
return new Proxy(map, {
|
||||||
|
get(target, prop) {
|
||||||
|
const value = target[prop]
|
||||||
|
if (prop === 'set' || prop === 'delete') {
|
||||||
|
return (...args) => {
|
||||||
|
const result = value.apply(target, args)
|
||||||
|
schedulePersist()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return typeof value === 'function' ? value.bind(target) : value
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function watchedSet(initialValues) {
|
||||||
|
const set = new Set(initialValues || [])
|
||||||
|
return new Proxy(set, {
|
||||||
|
get(target, prop) {
|
||||||
|
const value = target[prop]
|
||||||
|
if (prop === 'add' || prop === 'delete') {
|
||||||
|
return (...args) => {
|
||||||
|
const result = value.apply(target, args)
|
||||||
|
schedulePersist()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return typeof value === 'function' ? value.bind(target) : value
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const PERSISTED_STATE = loadPersistedState()
|
||||||
|
|
||||||
// IN-MEMORY TRACKING UNTUK PREVENT DUPLICATE NOTIFICATIONS
|
// IN-MEMORY TRACKING UNTUK PREVENT DUPLICATE NOTIFICATIONS
|
||||||
const notifiedOrders = new Set()
|
const notifiedOrders = watchedSet(PERSISTED_STATE.notifiedOrders)
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// LOGGING UTILITIES
|
// LOGGING UTILITIES
|
||||||
|
|
@ -107,6 +226,32 @@ if (!fs.existsSync(LOG_DIR)) {
|
||||||
fs.mkdirSync(LOG_DIR, { recursive: true })
|
fs.mkdirSync(LOG_DIR, { recursive: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LOG RETENTION — DELETE LOGS_*.log FILES OLDER THAN LOG_RETENTION_DAYS
|
||||||
|
const LOG_RETENTION_DAYS = parseInt(process.env.LOG_RETENTION_DAYS || '30', 10)
|
||||||
|
|
||||||
|
function cleanupOldLogs() {
|
||||||
|
try {
|
||||||
|
const maxAgeMs = LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000
|
||||||
|
const now = Date.now()
|
||||||
|
const files = fs.readdirSync(LOG_DIR).filter((f) => f.startsWith('LOGS_') && f.endsWith('.log'))
|
||||||
|
let deleted = 0
|
||||||
|
for (const f of files) {
|
||||||
|
const filePath = path.join(LOG_DIR, f)
|
||||||
|
const stats = fs.statSync(filePath)
|
||||||
|
if (now - stats.mtimeMs > maxAgeMs) {
|
||||||
|
fs.unlinkSync(filePath)
|
||||||
|
deleted += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (deleted > 0) console.log(`[log-cleanup] Deleted ${deleted} log file(s) older than ${LOG_RETENTION_DAYS} days`)
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[log-cleanup] Failed:', e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanupOldLogs()
|
||||||
|
setInterval(cleanupOldLogs, 24 * 60 * 60 * 1000).unref()
|
||||||
|
|
||||||
// GET LOG FILENAME (LOGS_DDMMYYYY.LOG)
|
// GET LOG FILENAME (LOGS_DDMMYYYY.LOG)
|
||||||
function getLogFilename() {
|
function getLogFilename() {
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
|
|
@ -236,10 +381,10 @@ const PAYMENT_LINK_SECRET = process.env.PAYMENT_LINK_SECRET || ''
|
||||||
const PAYMENT_LINK_TTL_MINUTES = parseInt(process.env.PAYMENT_LINK_TTL_MINUTES || '1440', 10)
|
const PAYMENT_LINK_TTL_MINUTES = parseInt(process.env.PAYMENT_LINK_TTL_MINUTES || '1440', 10)
|
||||||
const PAYMENT_LINK_BASE = process.env.PAYMENT_LINK_BASE || 'http://localhost:5174/pay'
|
const PAYMENT_LINK_BASE = process.env.PAYMENT_LINK_BASE || 'http://localhost:5174/pay'
|
||||||
|
|
||||||
// IN-MEMORY STORAGE
|
// PERSISTED STORAGE (SURVIVES RESTARTS — SEE watchedMap ABOVE)
|
||||||
const activeOrders = new Map()
|
const activeOrders = watchedMap(PERSISTED_STATE.activeOrders)
|
||||||
const orderMerchantId = new Map()
|
const orderMerchantId = watchedMap(PERSISTED_STATE.orderMerchantId)
|
||||||
const orderRetryCount = new Map()
|
const orderRetryCount = watchedMap(PERSISTED_STATE.orderRetryCount)
|
||||||
|
|
||||||
// CHECK IF DEVELOPMENT ENVIRONMENT
|
// CHECK IF DEVELOPMENT ENVIRONMENT
|
||||||
function isDevEnv() {
|
function isDevEnv() {
|
||||||
|
|
@ -521,7 +666,7 @@ app.get('/api/payment-links/:token', (req, res) => {
|
||||||
* POST /api/payment-links
|
* POST /api/payment-links
|
||||||
* Requires: X-API-KEY header
|
* Requires: X-API-KEY header
|
||||||
*/
|
*/
|
||||||
app.post('/api/payment-links', async (req, res) => {
|
app.post('/api/payment-links', rateLimit, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
if (!verifyExternalKey(req)) {
|
if (!verifyExternalKey(req)) {
|
||||||
logWarn('payment-links.create.unauthorized', { id: req.id })
|
logWarn('payment-links.create.unauthorized', { id: req.id })
|
||||||
|
|
@ -619,7 +764,7 @@ app.post('/api/payment-links', async (req, res) => {
|
||||||
* Create payment transaction via Midtrans Core API
|
* Create payment transaction via Midtrans Core API
|
||||||
* POST /api/payments/charge
|
* POST /api/payments/charge
|
||||||
*/
|
*/
|
||||||
app.post('/api/payments/charge', async (req, res) => {
|
app.post('/api/payments/charge', rateLimit, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const pt = req?.body?.payment_type
|
const pt = req?.body?.payment_type
|
||||||
logInfo('charge.request', { id: req.id, payment_type: pt })
|
logInfo('charge.request', { id: req.id, payment_type: pt })
|
||||||
|
|
@ -681,7 +826,7 @@ app.post('/api/payments/charge', async (req, res) => {
|
||||||
* Generate Snap token for hosted payment interface
|
* Generate Snap token for hosted payment interface
|
||||||
* POST /api/payments/snap/token
|
* POST /api/payments/snap/token
|
||||||
*/
|
*/
|
||||||
app.post('/api/payments/snap/token', async (req, res) => {
|
app.post('/api/payments/snap/token', rateLimit, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const snap = new midtransClient.Snap({
|
const snap = new midtransClient.Snap({
|
||||||
isProduction,
|
isProduction,
|
||||||
|
|
@ -752,7 +897,7 @@ app.get('/api/payments/:orderId/status', async (req, res) => {
|
||||||
* POST /createtransaksi
|
* POST /createtransaksi
|
||||||
* Requires: X-API-KEY header
|
* Requires: X-API-KEY header
|
||||||
*/
|
*/
|
||||||
app.post('/createtransaksi', async (req, res) => {
|
app.post('/createtransaksi', rateLimit, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
if (!verifyExternalKey(req)) {
|
if (!verifyExternalKey(req)) {
|
||||||
logWarn('createtransaksi.unauthorized', { id: req.id })
|
logWarn('createtransaksi.unauthorized', { id: req.id })
|
||||||
|
|
@ -961,52 +1106,6 @@ function updateLedger(orderId, data) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Process payment completion and trigger ERP notifications
|
|
||||||
* Unified handler for both CORE and SNAP modes
|
|
||||||
*/
|
|
||||||
async function processPaymentCompletion(orderId, internalStatus, mode, body) {
|
|
||||||
try {
|
|
||||||
logInfo(`[${mode}] payment.process`, {
|
|
||||||
order_id: orderId,
|
|
||||||
internal_status: internalStatus,
|
|
||||||
transaction_status: body?.transaction_status
|
|
||||||
})
|
|
||||||
if (internalStatus === 'completed') {
|
|
||||||
const grossAmount = body?.gross_amount
|
|
||||||
const nominal = String(grossAmount || '')
|
|
||||||
|
|
||||||
if (!notifiedOrders.has(orderId)) {
|
|
||||||
activeOrders.delete(orderId)
|
|
||||||
|
|
||||||
const mercantId = resolveMercantId(orderId)
|
|
||||||
const ok = await notifyERP({ orderId, nominal, mercantId })
|
|
||||||
|
|
||||||
if (ok) {
|
|
||||||
notifiedOrders.add(orderId)
|
|
||||||
logInfo(`[${mode}] erp.notify.success`, { order_id: orderId })
|
|
||||||
} else {
|
|
||||||
logWarn(`[${mode}] erp.notify.failed`, { order_id: orderId })
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
logInfo(`[${mode}] erp.notify.skip`, { order_id: orderId, reason: 'already_notified' })
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
logInfo(`[${mode}] payment.non_success`, {
|
|
||||||
order_id: orderId,
|
|
||||||
internal_status: internalStatus,
|
|
||||||
transaction_status: body?.transaction_status
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (e) {
|
|
||||||
logError(`[${mode}] payment.process.error`, {
|
|
||||||
order_id: orderId,
|
|
||||||
message: e?.message
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compute Midtrans webhook signature (SHA-512)
|
* Compute Midtrans webhook signature (SHA-512)
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue