Files
Microsoft-Rewards-Bot/api/report-error.js

148 lines
4.5 KiB
JavaScript

const { randomUUID } = require('crypto')
const MAX_BODY_SIZE = 10000
const MAX_TEXT = 900
const MAX_FIELD = 120
const MAX_STACK_LINES = 8
const AUTH_HEADER = 'x-error-report-token'
function isPlainObject(value) {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
}
function trimAndLimit(value, limit) {
if (typeof value !== 'string') {
return ''
}
const trimmed = value.trim()
return trimmed.length > limit ? `${trimmed.slice(0, limit)}` : trimmed
}
function formatMetadata(metadata) {
if (!isPlainObject(metadata)) {
return 'Not provided'
}
const entries = Object.entries(metadata).filter(([key, val]) => typeof key === 'string' && (typeof val === 'string' || typeof val === 'number' || typeof val === 'boolean'))
if (entries.length === 0) {
return 'Not provided'
}
const limited = entries.slice(0, 6)
const lines = limited.map(([key, val]) => {
const valueText = trimAndLimit(String(val), MAX_FIELD)
return `${trimAndLimit(key, MAX_FIELD)}: ${valueText}`
})
return lines.join('\n')
}
async function readJsonBody(req) {
if (req.body) {
return req.body
}
let data = ''
for await (const chunk of req) {
data += chunk
if (data.length > MAX_BODY_SIZE) {
throw new Error('Payload too large')
}
}
if (!data) {
return {}
}
return JSON.parse(data)
}
module.exports = async function handler(req, res) {
res.setHeader('Content-Type', 'application/json')
if (req.method !== 'POST') {
res.setHeader('Allow', 'POST')
res.status(405).json({ error: 'Method not allowed' })
return
}
const webhookUrl = process.env.DISCORD_WEBHOOK_URL
const authToken = process.env.ERROR_REPORT_TOKEN
if (!webhookUrl) {
res.status(500).json({ error: 'Webhook not configured' })
return
}
if (!authToken) {
res.status(500).json({ error: 'Reporting token not configured' })
return
}
const providedHeader = req.headers?.[AUTH_HEADER]
const providedToken = Array.isArray(providedHeader) ? providedHeader[0] : providedHeader
if (!providedToken || providedToken !== authToken) {
res.status(401).json({ error: 'Unauthorized' })
return
}
let body
try {
body = await readJsonBody(req)
} catch (error) {
res.status(400).json({ error: 'Invalid JSON body' })
return
}
const errorText = trimAndLimit(body.error, MAX_TEXT)
if (!errorText) {
res.status(400).json({ error: 'Field \'error\' is required' })
return
}
const summary = trimAndLimit(body.summary || body.message || '', 140)
const errorType = trimAndLimit(body.type || 'unspecified', 80)
const environment = trimAndLimit((body.environment && (body.environment.name || body.environment)) || process.env.VERCEL_ENV || process.env.NODE_ENV || 'unspecified', 80)
const metadata = formatMetadata(body.metadata)
const stackSnippet = Array.isArray(body.stack)
? body.stack.slice(0, MAX_STACK_LINES).join('\n')
: trimAndLimit(typeof body.stack === 'string' ? body.stack.split('\n').slice(0, MAX_STACK_LINES).join('\n') : '', MAX_TEXT)
const requestId = randomUUID()
const embed = {
title: 'Error Report',
description: summary || 'Automated error report received',
color: 0xef4444,
fields: [
{ name: 'Error', value: errorText, inline: false },
{ name: 'Type', value: errorType, inline: true },
{ name: 'Environment', value: environment, inline: true },
{ name: 'Request ID', value: requestId, inline: true }
],
footer: { text: 'Microsoft Rewards Bot' },
timestamp: new Date().toISOString()
}
if (metadata && metadata !== 'Not provided') {
embed.fields.push({ name: 'Metadata', value: metadata, inline: false })
}
if (stackSnippet) {
embed.fields.push({ name: 'Stack (truncated)', value: stackSnippet })
}
const payload = { embeds: [embed] }
try {
const response = await fetch(webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
})
if (!response.ok) {
res.status(502).json({ error: 'Failed to deliver report' })
return
}
res.status(200).json({ status: 'reported' })
} catch (error) {
res.status(502).json({ error: 'Failed to deliver report' })
}
}