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
|
||||
*.log
|
||||
|
||||
# Persisted server state
|
||||
server/data/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ module.exports = {
|
|||
script : "server/index.cjs",
|
||||
|
||||
env: {
|
||||
NODE_ENV: "development",
|
||||
NODE_ENV: "production",
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
|
|||
211
server/index.cjs
211
server/index.cjs
|
|
@ -29,9 +29,53 @@ const TransactionLogger = {
|
|||
// EXPRESS APP SETUP
|
||||
// ============================================================================
|
||||
const app = express()
|
||||
app.use(cors())
|
||||
|
||||
// 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(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
|
||||
// ============================================================================
|
||||
|
|
@ -86,8 +130,83 @@ const ERP_NOTIFICATION_URLS = (() => {
|
|||
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
|
||||
const notifiedOrders = new Set()
|
||||
const notifiedOrders = watchedSet(PERSISTED_STATE.notifiedOrders)
|
||||
|
||||
// ============================================================================
|
||||
// LOGGING UTILITIES
|
||||
|
|
@ -107,6 +226,32 @@ if (!fs.existsSync(LOG_DIR)) {
|
|||
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)
|
||||
function getLogFilename() {
|
||||
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_BASE = process.env.PAYMENT_LINK_BASE || 'http://localhost:5174/pay'
|
||||
|
||||
// IN-MEMORY STORAGE
|
||||
const activeOrders = new Map()
|
||||
const orderMerchantId = new Map()
|
||||
const orderRetryCount = new Map()
|
||||
// PERSISTED STORAGE (SURVIVES RESTARTS — SEE watchedMap ABOVE)
|
||||
const activeOrders = watchedMap(PERSISTED_STATE.activeOrders)
|
||||
const orderMerchantId = watchedMap(PERSISTED_STATE.orderMerchantId)
|
||||
const orderRetryCount = watchedMap(PERSISTED_STATE.orderRetryCount)
|
||||
|
||||
// CHECK IF DEVELOPMENT ENVIRONMENT
|
||||
function isDevEnv() {
|
||||
|
|
@ -521,7 +666,7 @@ app.get('/api/payment-links/:token', (req, res) => {
|
|||
* POST /api/payment-links
|
||||
* Requires: X-API-KEY header
|
||||
*/
|
||||
app.post('/api/payment-links', async (req, res) => {
|
||||
app.post('/api/payment-links', rateLimit, async (req, res) => {
|
||||
try {
|
||||
if (!verifyExternalKey(req)) {
|
||||
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
|
||||
* POST /api/payments/charge
|
||||
*/
|
||||
app.post('/api/payments/charge', async (req, res) => {
|
||||
app.post('/api/payments/charge', rateLimit, async (req, res) => {
|
||||
try {
|
||||
const pt = req?.body?.payment_type
|
||||
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
|
||||
* 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 {
|
||||
const snap = new midtransClient.Snap({
|
||||
isProduction,
|
||||
|
|
@ -752,7 +897,7 @@ app.get('/api/payments/:orderId/status', async (req, res) => {
|
|||
* POST /createtransaksi
|
||||
* Requires: X-API-KEY header
|
||||
*/
|
||||
app.post('/createtransaksi', async (req, res) => {
|
||||
app.post('/createtransaksi', rateLimit, async (req, res) => {
|
||||
try {
|
||||
if (!verifyExternalKey(req)) {
|
||||
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)
|
||||
*/
|
||||
|
|
|
|||
Loading…
Reference in New Issue