diff --git a/.github/workflows/render.yaml b/.github/workflows/render.yaml index 4213321..2070ffe 100644 --- a/.github/workflows/render.yaml +++ b/.github/workflows/render.yaml @@ -3,6 +3,10 @@ name: "Render" on: push: branches-ignore: [master] + paths: + - ".github/workflows/render.yaml" + - "src/**" + - "package*.json" workflow_call: #inputs: # version: diff --git a/package-lock.json b/package-lock.json index 0e03789..c72e773 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "@influxdata/influxdb-client": "^1.35.0", "@octokit/rest": "^22.0.0", "@sentry/node": "^10.22.0", - "axios": "^1.12.2", + "axios": "^1.13.1", "badge-maker": "^5.0.2", "camelcase": "^8.0.0", "chroma-js": "^3.1.2", @@ -1246,9 +1246,9 @@ } }, "node_modules/@types/node": { - "version": "24.9.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.1.tgz", - "integrity": "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==", + "version": "24.9.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.2.tgz", + "integrity": "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA==", "license": "MIT", "dependencies": { "undici-types": "~7.16.0" @@ -1427,9 +1427,9 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz", - "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==", + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.1.tgz", + "integrity": "sha512-hU4EGxxt+j7TQijx1oYdAjw4xuIp1wRQSsbMFwSthCWeBQur1eF+qJ5iQ5sN3Tw8YRzQNKb8jszgBdMDVqwJcw==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", diff --git a/package.json b/package.json index 00b2f76..eed971f 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "@influxdata/influxdb-client": "^1.35.0", "@octokit/rest": "^22.0.0", "@sentry/node": "^10.22.0", - "axios": "^1.12.2", + "axios": "^1.13.1", "badge-maker": "^5.0.2", "camelcase": "^8.0.0", "chroma-js": "^3.1.2", diff --git a/src/api.js b/src/api.js index cf80642..9d11b9d 100644 --- a/src/api.js +++ b/src/api.js @@ -30,8 +30,8 @@ if (process.env.INFLUX_URL && process.env.INFLUX_TOKEN) { export class GHCRApi { /** * GHCR API - * @param {String} packageOwner - * @param {String} packageName + * @param {string} packageOwner + * @param {string} packageName */ constructor(packageOwner, packageName) { if (!packageOwner || !packageName) throw new Error('Invalid Arguments') @@ -51,7 +51,7 @@ export class GHCRApi { /** * Get Image Tags - * @return {Promise} + * @return {Promise} */ async getImageTags() { const url = `${this.packageOwner}/${this.packageName}/tags/list` @@ -67,7 +67,7 @@ export class GHCRApi { /** * Get Image Size - * @return {Promise} + * @return {Promise} */ async getImageSize(tag = 'latest') { const key = `ghcr/size/${this.packageOwner}/${this.packageName}/${tag}` @@ -110,7 +110,7 @@ export class GHCRApi { /** * Get Image Manifest - * @return {Promise} + * @return {Promise} */ async getManifest(tag = 'latest') { const url = `${this.packageOwner}/${this.packageName}/manifests/${tag}` @@ -127,7 +127,7 @@ export class GHCRApi { /** * Get VirusTotal Stats for a Release Asset * @param {import('express').Request} req - * @return {Promise} + * @return {Promise} */ export async function getVTReleaseStats(req) { const tag = req.params.tag || 'latest' @@ -166,8 +166,8 @@ export async function getVTReleaseStats(req) { /** * Get VT Stats for a File ID/Hash - * @param {String} hash - * @return {Promise} + * @param {string} hash + * @return {Promise} */ export async function getVTStats(hash) { const key = `/vt/id/${hash}` @@ -271,8 +271,8 @@ async function cacheError(key, errorMessage, EX = 60 * 10) { throw new Error(errorMessage) } -export async function incrBadge() { - await client.incr('badges_total') +export async function incrKey(key) { + await client.incr(key) } export async function sendInflux() { @@ -285,17 +285,17 @@ export async function sendInflux() { const measurementName = 'node_badges' - const badgesTotal = await cacheGet('badges_total', 0) - // debug('badgesTotal:', badgesTotal, typeof badgesTotal) - writeApi.writePoint(new Point(measurementName).intField('badges_total', badgesTotal)) - - const appUptime = Math.floor(process.uptime()) - // debug('appUptime:', appUptime, typeof appUptime) - writeApi.writePoint(new Point(measurementName).intField('app_uptime', appUptime)) + const write = (name, data) => { + const point = new Point(measurementName).intField(name, data) + debug('writePoint:', point) + writeApi.writePoint(point) + } - const redisKeys = await client.dbSize() - // debug('redisKeys:', redisKeys, typeof redisKeys) - writeApi.writePoint(new Point(measurementName).intField('redis_keys', redisKeys)) + write('badges_total', await cacheGet('badges_total', 0)) + write('badges_404', await cacheGet('badges_404', 0)) + write('badges_error', await cacheGet('badges_error', 0)) + write('app_uptime', Math.floor(process.uptime())) + write('redis_keys', await client.dbSize()) await writeApi.close() } diff --git a/src/app.js b/src/app.js index 9caa1b5..118fdc0 100644 --- a/src/app.js +++ b/src/app.js @@ -19,7 +19,7 @@ import { getVTReleaseStats, getVTStats, GHCRApi, - incrBadge, + incrKey, sendInflux, } from './api.js' @@ -63,6 +63,7 @@ process.on('SIGTERM', () => { await schedule.gracefulShutdown() // NOTE: Determine if we should sendInflux on close // await sendInflux() + process.exit(0) }) }) @@ -91,15 +92,16 @@ app.get('/', async (req, res) => { }) // app.get('/test{/:extra}', async (req, res) => { -// res.sendStatus(200) +// throw new Error('ralf brok ei t') +// // res.sendStatus(200) // -// const total = await cacheGet('badges_total') -// debug('badges_total:', total) -// -// if (req.params.extra) { -// debug('req.params.extra:', req.params.extra) -// sendInflux().catch(console.error) -// } +// // const total = await cacheGet('badges_total') +// // debug('badges_total:', total) +// // +// // if (req.params.extra) { +// // debug('req.params.extra:', req.params.extra) +// // sendInflux().catch(console.error) +// // } // }) app.get('/colors{/:index}', async (req, res) => { @@ -141,28 +143,25 @@ app.all('/vt/:type/:hash', async (req, res, next) => { next() }) -app.get( - '/vt/:type/:hash', - errorBadgeHandler(async (req, res) => { - debug(req.originalUrl) - // debug('req.params.type:', req.params.type) - if (!['id', 'sha'].includes(req.params.type)) return res.sendStatus(404) - if (!process.env.VT_API_KEY) throw new Error('Missing VT API Key') - - const hash = req.params.hash.includes(':') - ? req.params.hash.split(':')[1] - : req.params.hash - // debug('hash:', hash) - - const stats = await getVTStats(hash) - // debug('stats:', stats) - const message = `${stats.malicious}/${stats.suspicious}/${stats.undetected}` - // debug('message:', message) - const color = getRangedColor(req, stats.malicious + stats.suspicious) - const options = { label: hash.slice(0, 6), icon: 'virustotal', color } - getBadge(message, req.query, options, res) - }) -) +app.get('/vt/:type/:hash', async (req, res) => { + debug(req.originalUrl) + // debug('req.params.type:', req.params.type) + if (!['id', 'sha'].includes(req.params.type)) return res.sendStatus(404) + if (!process.env.VT_API_KEY) throw new Error('Missing VT API Key') + + const hash = req.params.hash.includes(':') + ? req.params.hash.split(':')[1] + : req.params.hash + // debug('hash:', hash) + + const stats = await getVTStats(hash) + // debug('stats:', stats) + const message = `${stats.malicious}/${stats.suspicious}/${stats.undetected}` + // debug('message:', message) + const color = getRangedColor(req, stats.malicious + stats.suspicious) + const options = { label: hash.slice(0, 6), icon: 'virustotal', color } + getBadge(message, req.query, options, res) +}) app.all('/vt/:owner/:repo/:asset{/:tag}', async (req, res, next) => { if (req.method === 'PURGE') { @@ -174,20 +173,17 @@ app.all('/vt/:owner/:repo/:asset{/:tag}', async (req, res, next) => { next() }) -app.get( - '/vt/:owner/:repo/:asset{/:tag}', - errorBadgeHandler(async (req, res) => { - debug(req.originalUrl) - if (!process.env.VT_API_KEY) throw new Error('Missing VT API Key') - const stats = await getVTReleaseStats(req) - // debug('stats:', stats) - const message = `${stats.malicious}/${stats.suspicious}/${stats.undetected}` - // debug('message:', message) - const color = getRangedColor(req, stats.malicious + stats.suspicious) - const options = { label: req.params.asset, icon: 'virustotal', color } - getBadge(message, req.query, options, res) - }) -) +app.get('/vt/:owner/:repo/:asset{/:tag}', async (req, res) => { + debug(req.originalUrl) + if (!process.env.VT_API_KEY) throw new Error('Missing VT API Key') + const stats = await getVTReleaseStats(req) + // debug('stats:', stats) + const message = `${stats.malicious}/${stats.suspicious}/${stats.undetected}` + // debug('message:', message) + const color = getRangedColor(req, stats.malicious + stats.suspicious) + const options = { label: req.params.asset, icon: 'virustotal', color } + getBadge(message, req.query, options, res) +}) app.all('/ghcr/tags/:owner/:package{/:latest}', async (req, res, next) => { if (req.method === 'PURGE') { @@ -198,43 +194,40 @@ app.all('/ghcr/tags/:owner/:package{/:latest}', async (req, res, next) => { next() }) -app.get( - '/ghcr/tags/:owner/:package{/:latest}', - errorBadgeHandler(async (req, res) => { - debug(req.originalUrl) - if (req.params.latest && req.params.latest !== 'latest') { - return res.sendStatus(404) - } - const count = Number.parseInt(req.query.n) || 3 - // debug('count:', count) - - const api = new GHCRApi(req.params.owner, req.params.package) - let tags = await api.getImageTags() - tags = tags.filter((tag) => tag !== 'latest') - tags = tags.toReversed() - - if (req.query.semver !== undefined) { - tags = tags.filter((str) => semver.valid(str)) - } - - tags = tags.slice(0, count) - tags = tags.toSorted((a, b) => a.localeCompare(b, undefined, { numeric: true })) - - if (req.params.latest) { - const message = tags.at(-1) - // debug('latest - message:', message) - return getBadge(message, req.query, { label: 'latest', lucide: 'tag' }, res) - } - - if (req.query.reversed !== undefined) { - tags.reverse() - } - - const message = tags.join(` ${req.query.sep || '|'} `) - // debug('tags - message:', message) - getBadge(message, req.query, { label: 'tags', lucide: 'tags' }, res) - }) -) +app.get('/ghcr/tags/:owner/:package{/:latest}', async (req, res) => { + debug(req.originalUrl) + if (req.params.latest && req.params.latest !== 'latest') { + return res.sendStatus(404) + } + const count = Number.parseInt(req.query.n) || 3 + // debug('count:', count) + + const api = new GHCRApi(req.params.owner, req.params.package) + let tags = await api.getImageTags() + tags = tags.filter((tag) => tag !== 'latest') + tags = tags.toReversed() + + if (req.query.semver !== undefined) { + tags = tags.filter((str) => semver.valid(str)) + } + + tags = tags.slice(0, count) + tags = tags.toSorted((a, b) => a.localeCompare(b, undefined, { numeric: true })) + + if (req.params.latest) { + const message = tags.at(-1) + // debug('latest - message:', message) + return getBadge(message, req.query, { label: 'latest', lucide: 'tag' }, res) + } + + if (req.query.reversed !== undefined) { + tags.reverse() + } + + const message = tags.join(` ${req.query.sep || '|'} `) + // debug('tags - message:', message) + getBadge(message, req.query, { label: 'tags', lucide: 'tags' }, res) +}) app.all('/ghcr/size/:owner/:package{/:tag}', async (req, res, next) => { if (req.method === 'PURGE') { @@ -246,37 +239,31 @@ app.all('/ghcr/size/:owner/:package{/:tag}', async (req, res, next) => { next() }) -app.get( - '/ghcr/size/:owner/:package{/:tag}', - errorBadgeHandler(async (req, res) => { - debug(req.originalUrl) +app.get('/ghcr/size/:owner/:package{/:tag}', async (req, res) => { + debug(req.originalUrl) - const api = new GHCRApi(req.params.owner, req.params.package) - const tag = req.params.tag || 'latest' - const total = await api.getImageSize(tag) - // debug('getImageSize - total:', total) + const api = new GHCRApi(req.params.owner, req.params.package) + const tag = req.params.tag || 'latest' + const total = await api.getImageSize(tag) + // debug('getImageSize - total:', total) - const message = formatSize(total) - // debug('message:', message) - getBadge(message, req.query, { label: 'size', lucide: 'container' }, res) - }) -) - -app.get( - '/static/:message{/:label}', - errorBadgeHandler(async (req, res) => { - debug(req.originalUrl) - // debug(`message/label: ${req.params.message} / ${req.params.label}`) - // NOTE: This endpoint uses custom logic to make a "static" badge - // This needs to be fixed, the icon does not show up like shields - const query = structuredClone(req.query) - if (!req.params.label && !query.label && !query.labelColor) { - query.labelColor = query.color || 'brightgreen' - } - // debug('query:', query) - getBadge(req.params.message, query, { label: req.params.label }, res) - }) -) + const message = formatSize(total) + // debug('message:', message) + getBadge(message, req.query, { label: 'size', lucide: 'container' }, res) +}) + +app.get('/static/:message{/:label}', async (req, res) => { + debug(req.originalUrl) + // debug(`message/label: ${req.params.message} / ${req.params.label}`) + // NOTE: This endpoint uses custom logic to make a "static" badge + // This needs to be fixed, the icon does not show up like shields + const query = structuredClone(req.query) + if (!req.params.label && !query.label && !query.labelColor) { + query.labelColor = query.color || 'brightgreen' + } + // debug('query:', query) + getBadge(req.params.message, query, { label: req.params.label }, res) +}) app.all('/:type/:url/:path', async (req, res, next) => { if (!['yaml', 'json'].includes(req.params.type)) return next() @@ -287,32 +274,26 @@ app.all('/:type/:url/:path', async (req, res, next) => { next() }) -app.get( - '/:type/:url/:path', - errorBadgeHandler(async (req, res) => { - debug(req.originalUrl) - // debug('req.params.type:', req.params.type) - if (!['yaml', 'json'].includes(req.params.type)) return res.sendStatus(404) +app.get('/:type/:url/:path', async (req, res) => { + debug(req.originalUrl) + // debug('req.params.type:', req.params.type) + if (!['yaml', 'json'].includes(req.params.type)) return res.sendStatus(404) - const message = await getJSONPath(req) - // debug('message:', message) - getBadge(message, req.query, { label: 'result', lucide: 'code-xml' }, res) - }) -) - -app.get( - '/uptime', - errorBadgeHandler(async (req, res) => { - debug(req.originalUrl) - const message = getUptime() - // debug('message:', message) - getBadge(message, req.query, { label: 'uptime', lucide: 'clock-arrow-up' }, res) - }) -) + const message = await getJSONPath(req) + // debug('message:', message) + getBadge(message, req.query, { label: 'result', lucide: 'code-xml' }, res) +}) + +app.get('/uptime', async (req, res) => { + debug(req.originalUrl) + const message = getUptime() + // debug('message:', message) + getBadge(message, req.query, { label: 'uptime', lucide: 'clock-arrow-up' }, res) +}) -// Handler 404 +// Handler - 404 app.use((req, res) => { - debug('404:', req.originalUrl) + debug('404 - originalUrl:', req.originalUrl) const data = { message: '404 - URL Not Found', color: 'red', @@ -322,28 +303,28 @@ app.use((req, res) => { // noinspection JSCheckFunctionSignatures const badge = makeBadge(data) sendBadge(res, badge, 404) + incrKey('badges_404').catch(console.error) }) +// Handler - Error +app.use(errorHandler) + +// Handler - Sentry Error - NOTE: This only catches errorHandler errors currently... if (Sentry) Sentry.setupExpressErrorHandler(app) -// TODO: Move to express error handler... -function errorBadgeHandler(handler) { - return async (req, res) => { - try { - await handler(req, res) - } catch (error) { - console.error(error) - debug('errorBadgeHandler:', error.message) - const data = { - message: error.message || 'Unknown Error', - color: 'red', - style: req.query.style || 'flat', - } - debug('data:', data) - const badge = makeBadge(data) - if (res) sendBadge(res, badge) - } +function errorHandler(err, req, res) { + // console.log('errorHandler:', err) + debug('errorHandler - originalUrl:', req.originalUrl) + debug('err.message:', err.message) + const data = { + message: err.message || 'Unknown Error', + color: 'red', + style: req.query.style || 'flat', } + debug('data:', data) + const badge = makeBadge(data) + sendBadge(res, badge) + incrKey('badges_error').catch(console.error) } /** @@ -358,8 +339,8 @@ function setHeaders(res) { /** * Send Badge * @param {express.Response} res - * @param {String} badge - * @param {Number} [status] + * @param {string} badge + * @param {number} [status] */ function sendBadge(res, badge, status = 200) { setHeaders(res) @@ -368,11 +349,11 @@ function sendBadge(res, badge, status = 200) { /** * Get Badge - * @param {String} message Badge Message - * @param {Object} [query] req.query Object - * @param {Object} [options] Badge Options + * @param {string} message Badge Message + * @param {object} [query] req.query Object + * @param {object} [options] Badge Options * @param {express.Response} [res] To sendBadge - * @return {String} + * @return {string} */ function getBadge(message, query = {}, options = {}, res = null) { const opts = { color: '', label: '', icon: '', lucide: '', ...options } @@ -392,16 +373,16 @@ function getBadge(message, query = {}, options = {}, res = null) { // debug('data:', data) const badge = makeBadge(data) if (res) sendBadge(res, badge) - incrBadge().catch(console.error) + incrKey('badges_total').catch(console.error) return badge } /** * Get Logo String - * @param {Object} query - * @param {Object} opts - * @param {String} [color] - * @return {String} + * @param {object} query + * @param {object} opts + * @param {string} [color] + * @return {string} */ function getLogo(query, opts, color = '#fff') { // debug('query.icon:', query.icon) @@ -439,7 +420,7 @@ function getLogo(query, opts, color = '#fff') { /** * Purge Key Response * @param {express.Response} res - * @param {String} key + * @param {string} key * @return {Promise} */ async function purgeKey(res, key) { @@ -451,8 +432,8 @@ async function purgeKey(res, key) { /** * Get Size String - * @param {Number} bytes - * @return {String} + * @param {number} bytes + * @return {string} */ function formatSize(bytes) { if (bytes === 0) return '0 B' @@ -463,7 +444,7 @@ function formatSize(bytes) { /** * Get Uptime String - * @return {String} + * @return {string} */ function getUptime() { const seconds = process.uptime() @@ -479,9 +460,9 @@ function getUptime() { /** * Get Ranged Color w/ Options * @param {express.Request} req - * @param {Number} index - * @param {Object} [options] - * @return {String} + * @param {number} index + * @param {object} [options] + * @return {string} */ function getRangedColor(req, index, options = {}) { const opts = { total: 8, start: '#44cc11', end: '#e05d44', ...options } diff --git a/src/github.js b/src/github.js index 00ead7a..6ef233a 100644 --- a/src/github.js +++ b/src/github.js @@ -6,7 +6,7 @@ export const debug = createDebug('app:api') export class GitHubApi { /** * GitHub Api - * @param {String} [token] + * @param {string} [token] */ constructor(token) { const data = {} @@ -17,7 +17,7 @@ export class GitHubApi { // /** // * Get Release // * @param {string} release_id - // * @return {Promise} + // * @return {Promise} // */ // async getRelease(release_id) { // debug('getRelease:', release_id) @@ -31,10 +31,10 @@ export class GitHubApi { /** * Get Release by Tag - * @param {String} owner - * @param {String} repo - * @param {String} tag - * @return {Promise} + * @param {string} owner + * @param {string} repo + * @param {string} tag + * @return {Promise} */ async getReleaseByTag(owner, repo, tag) { debug('getReleaseByTag:', tag) @@ -49,9 +49,9 @@ export class GitHubApi { /** * Get Latest Release - * @param {String} owner - * @param {String} repo - * @return {Promise} + * @param {string} owner + * @param {string} repo + * @return {Promise} */ async getLatestRelease(owner, repo) { debug('getLatestRelease:', owner, repo) diff --git a/src/virustotal.js b/src/virustotal.js index 6871259..5f424d1 100644 --- a/src/virustotal.js +++ b/src/virustotal.js @@ -6,7 +6,7 @@ const debug = createDebug('app:api') export class VTApi { /** * GitHub Api - * @param {String} token + * @param {string} token */ constructor(token) { this.client = axios.create({ @@ -17,8 +17,8 @@ export class VTApi { /** * Get Release - * @param {String} id - * @return {Promise} + * @param {string} id + * @return {Promise} */ async getReport(id) { try { @@ -32,8 +32,8 @@ export class VTApi { /** * Get Release - * @param {String} id - * @return {Promise} + * @param {string} id + * @return {Promise} */ async getAnalysis(id) { try {