import http from 'http' import matchRoute from 'my-way' import urlParse from 'url-parse' import { measure } from 'measurement-protocol' import got from './got' import fetchIcon from './fetch-icon' import serveBadge from './serve-badge' import serveDoc from './serve-doc' import sentry from './sentry' import { BadgenParams } from './types' export type PathArgs = NonNullable> export interface BadgeMaker { (pathArgs: PathArgs) : Promise; } export interface BadgenServeConfig { title: string; help?: string; examples: { [url: string]: string }; handlers: { [pattern: string]: BadgeMaker }; } export class BadgenError { public status: string // error badge param: status (required) public color: string // error badge param: color public code: number // status code for response constructor ({ status, color = 'grey', code = 500 }) { this.status = status this.color = color this.code = code } } export interface BadgenHandler extends http.RequestListener { meta: BadgenServeConfig; } export function createBadgenHandler (conf: BadgenServeConfig): BadgenHandler { async function badgenHandler (req: http.IncomingMessage, res: http.ServerResponse) { const url = req.url ?? '/' const { pathname, query } = urlParse(url, true) measurementLogInvocation(req.headers?.host ?? 'badgen.net', url) // Serve favicon if (pathname === '/favicon.ico') { return res.end() } const defaultLabel = pathname.split('/')[1] const defaultParams = { subject: defaultLabel, status: 'unknown', color: 'grey' } try { // Match handler let matchedArgs: ReturnType = null const matchedScheme = Object.keys(conf.handlers).find(scheme => { return matchedArgs = matchRoute(scheme, decodeURI(pathname)) }) if (!matchedScheme) { // Serve docs if (matchRoute('/:name', url)) { return serveDoc(conf)(req, res) } // handle PUT requests if (req.method === 'PUT') { const memoArgs = matchRoute('/memo/:name/:label/:status/:color?', url) if (memoArgs) { const memoApi = `https://badgen-store.amio.workers.dev/${url.substr(6)}` const putResult = await got.put(memoApi).text() return res.end(putResult) } } return serve404(req, res) } // Invoke badge handler const badgeParamsPromise = conf.handlers[matchedScheme](matchedArgs || {}) let iconPromise: Promise = Promise.resolve(undefined) if (typeof query.icon === 'string') { if (query.icon.startsWith('https://')) { iconPromise = fetchIcon(query.icon).catch(e => undefined) } else { iconPromise = Promise.resolve(query.icon) } } const [ icon, params = defaultParams ] = await Promise.all([ iconPromise, badgeParamsPromise ]) params.subject = simpleDecode(params.subject) params.status = simpleDecode(params.status) if (icon !== undefined) { query.icon = icon === '' ? params.subject : icon } if (query.style === undefined) { query.style = getBadgeStyle(req) } return serveBadge(req, res, { params, query: query as any }) } catch (error: any) { measurementLogError('error', error.code || error?.response?.statusCode , req.url || '/') if (error instanceof BadgenError) { console.error(`BGE${error.code} "${error.status}" ${req.url}`) return serveBadge(req, res, { code: error.code, sMaxAge: 5, params: { subject: defaultLabel, status: error.status, color: error.color } }) } // Handle timeout for `got` requests if (error.code === 'ETIMEDOUT') { console.error(`APIE504 ${req.url}`) return serveBadge(req, res, { code: 504, sMaxAge: 5, params: { subject: defaultLabel, status: 'timeout', color: 'grey' } }) } if (error.name === 'HTTPError') { return serveBadge(req, res, { code: 500, sMaxAge: 5, params: { subject: defaultLabel, status: error.response.statusCode, color: 'grey' } }) } // Handle requests errors from `got` if (error.statusCode) { const errorInfo = `${error.url} ${error.statusMessage}` console.error(`APIE${error.statusCode} ${url} ${errorInfo}`) return serveBadge(req, res, { code: 502, sMaxAge: 5, params: { subject: defaultLabel, status: error.statusCode, color: 'grey' } }) } sentry.configureScope((scope) => { scope.setTag('path', url) scope.setTag('service', defaultLabel) }) sentry.captureException(error) // uncatched error console.error(`UCE ${url}`, error.message, error) return serveBadge(req, res, { code: 500, sMaxAge: 5, params: { subject: 'badgen', status: 'error', color: 'grey' } }) } } badgenHandler.meta = conf return badgenHandler } const { TRACKING_GA, NOW_REGION } = process.env const tracker = TRACKING_GA && measure(TRACKING_GA).setCustomDimensions([NOW_REGION || 'unknown']) async function measurementLogInvocation (host: string, urlPath: string) { tracker && tracker.pageview({ host, path: urlPath}).send() } async function measurementLogError (category: string, action: string, label?: string, value?: number) { tracker && tracker.event(category, action, label, value).send() } function getBadgeStyle (req: http.IncomingMessage): string | undefined { const host = req.headers['x-forwarded-host']?.toString() ?? req.headers.host ?? '' return host.startsWith('flat') ? 'flat' : undefined } function simpleDecode (str: string): string { return String(str).replace(/%2F/g, '/') } function serve404 (req: http.IncomingMessage, res: http.ServerResponse) { const params = { subject: 'Badgen', status: '404', color: 'orange' } const { query } = urlParse(req.url || '/', true) if (query.style === undefined) { query.style = getBadgeStyle(req) } serveBadge(req, res, { code: 404, params, query }) }