From b22ea33d90cd41ce93c7e9c23bb67f995ef7041a Mon Sep 17 00:00:00 2001 From: Amio Jin Date: Sun, 30 Jul 2023 13:55:21 +0800 Subject: [PATCH] feat: new /memo badge built on Vercel KV (#637) --- api-/memo.ts | 49 -------- libs/badge-list2.ts | 2 + libs/create-badgen-handler-next.ts | 64 +++++----- libs/serve-badge-next.ts | 7 +- libs/serve-doc-next.ts | 187 +++++++++++++++++++++++++++++ next.config.js | 29 ++--- package-lock.json | 34 ++++++ package.json | 1 + pages/api/memo.ts | 110 +++++++++++++++++ pages/api/winget.ts | 4 +- pages/api/xo.ts | 4 +- vercel.json | 4 - 12 files changed, 386 insertions(+), 109 deletions(-) delete mode 100644 api-/memo.ts create mode 100644 libs/serve-doc-next.ts create mode 100644 pages/api/memo.ts diff --git a/api-/memo.ts b/api-/memo.ts deleted file mode 100644 index d4c45a4..0000000 --- a/api-/memo.ts +++ /dev/null @@ -1,49 +0,0 @@ -import got from '../libs/got' -import { createBadgenHandler, PathArgs } from '../libs/create-badgen-handler' - -const help = ` -A badge with memories - -## Usage - -Update a badge with a \`PUT\` request: - - curl -X PUT https://badgen.net/memo/a-public-writable-badge/coverage/75%25/orange - -Then you have it: - - https://badgen.net/memo/a-public-writable-badge - -![](https://badgen.net/badge/coverage/75%25/orange) - -## Limits - -Up to 1 write per second per badge. - -## Caveat - -Since everyone can write to any badge, it's recommended to add a uuid suffix to badge name: - - https://badgen.net/memo/my-coverage-badge-df3ff1af-4703-435a-b4ea-20a38e711c7d - -For uuid, you may grab one at https://uuid.now.sh -` - -export default createBadgenHandler({ - title: 'Memo', - help, - examples: { - '/memo/deployed': 'memoized badge for deploy status', - }, - handlers: { - '/memo/:name': handler - } -}) - -async function handler ({ name }: PathArgs) { - const endpoint = `https://badgen-store.amio.workers.dev/${name}` - const data = await got(endpoint).json() - data.subject = data.subject || data.label - - return data -} diff --git a/libs/badge-list2.ts b/libs/badge-list2.ts index 619a91f..76f64d7 100644 --- a/libs/badge-list2.ts +++ b/libs/badge-list2.ts @@ -2,6 +2,7 @@ import staticBadge from '../pages/api/static' import github from '../pages/api/github' import gitlab from '../pages/api/gitlab' import https from '../pages/api/https' +import memo from '../pages/api/memo' import amo from '../pages/api/amo' import npm from '../pages/api/npm' import pub from '../pages/api/pub' @@ -29,6 +30,7 @@ export default { github: github.meta, gitlab: gitlab.meta, https: https.meta, + memo: memo.meta, amo: amo.meta, npm: npm.meta, pub: pub.meta, diff --git a/libs/create-badgen-handler-next.ts b/libs/create-badgen-handler-next.ts index 1fdc291..6d6621d 100644 --- a/libs/create-badgen-handler-next.ts +++ b/libs/create-badgen-handler-next.ts @@ -3,7 +3,7 @@ import http from 'http' import matchRoute from 'my-way' import { serveBadgeNext } from './serve-badge-next' -import serveDoc from './serve-doc' +import serveDoc from './serve-doc-next' import sentry from './sentry' import type { NextApiRequest, NextApiResponse } from 'next' @@ -11,13 +11,14 @@ import type { BadgenParams } from './types' import { HTTPError } from 'got' export type PathArgs = NonNullable> -export type BadgenResult = Promise +export type BadgenResponse = BadgenParams | string +export type BadgenHandler = (pathArgs: PathArgs, req: NextApiRequest, res: NextApiResponse) => Promise export interface BadgenServeConfig { title: string; help?: string; examples: { [url: string]: string }; - handlers: { [pattern: string]: (pathArgs: PathArgs) => BadgenResult }; + handlers: { [pattern: string]: BadgenHandler }; } export function createBadgenHandler (badgenServerConfig: BadgenServeConfig) { @@ -30,27 +31,32 @@ export function createBadgenHandler (badgenServerConfig: BadgenServeConfig) { return res.end() } - // Match badge handlers + if (matchRoute('/:name', pathname)) { + return serveDoc(badgenServerConfig)(req, res) + } + + // Find matched badgen handler let matchedArgs: PathArgs | null = null const matchedScheme = Object.keys(handlers).find(scheme => { return matchedArgs = matchRoute(scheme, decodeURI(pathname)) }) - // Invoke badge handler - if (matchedArgs !== null && matchedScheme !== undefined) { - return await handlers[matchedScheme](matchedArgs).then(params => { - return serveBadgeNext(req, res, { params }) - }).catch(error => { - const meta = { matchedArgs, matchedScheme } - return onBadgeHandlerError(meta, error, req, res) - }) + if (matchedArgs === null || matchedScheme === undefined) { + res.status(404).end() + return } - if (matchRoute('/:name', pathname)) { - return serveDoc(badgenServerConfig)(req, res) + // Invoke matched badgen handler + const badgenHandler = handlers[matchedScheme] + const badgenResponse = await badgenHandler(matchedArgs, req, res) + .catch(error => parseBadgenHandlerError(error, req, res)) + + if (typeof badgenResponse === 'string') { + res.end(badgenResponse) + return } - res.status(404).end() + serveBadgeNext(req, res, { params: badgenResponse }) } nextHandler.meta = { title, examples, help, handlers } @@ -58,31 +64,31 @@ export function createBadgenHandler (badgenServerConfig: BadgenServeConfig) { return nextHandler } -function onBadgeHandlerError (meta: any, err: Error | HTTPError, req: NextApiRequest, res: NextApiResponse) { - sentry.captureException(err) +function parseBadgenHandlerError (error: Error | HTTPError, req: NextApiRequest, res: NextApiResponse): BadgenResponse { + sentry.captureException(error) - console.error('BADGE_HANDLER_ERROR', err.message, req.url) + console.error('BADGE_HANDLER_ERROR', req.url, error.stack || error.message) - // Send user friendly response + const badgeName = req.url?.split('/')[1] + + // Send user friendly badge response const errorBadgeParams = { - subject: 'error', + subject: badgeName || 'error', status: '500', color: 'red', } - if (err instanceof HTTPError) { - errorBadgeParams.status = err.response.statusCode.toString() + if (error instanceof HTTPError) { + errorBadgeParams.status = error.response.statusCode.toString() } - if (err instanceof BadgenError) { - errorBadgeParams.status = err.status + if (error instanceof BadgenError) { + errorBadgeParams.status = error.status } - res.setHeader('Error-Message', err.message) - return serveBadgeNext(req, res, { - code: 200, - params: errorBadgeParams, - }) + res.setHeader('Error-Message', error.message) + + return errorBadgeParams } function getBadgeStyle (req: http.IncomingMessage): string | undefined { diff --git a/libs/serve-badge-next.ts b/libs/serve-badge-next.ts index b0eddd7..bae5572 100644 --- a/libs/serve-badge-next.ts +++ b/libs/serve-badge-next.ts @@ -34,8 +34,11 @@ export function serveBadgeNext (req: NextApiRequest, res: NextApiResponse, optio const badgeSVGString = badgen(badgeParams) // Minimum s-maxage is set to 300s(5m) - const cacheMaxAge = cache ? Math.max(parseInt(String(cache)), 300) : sMaxAge - res.setHeader('Cache-Control', `public, max-age=86400, s-maxage=${cacheMaxAge}, stale-while-revalidate=86400`) + if (res.getHeader('cache-control') === undefined) { + const cacheMaxAge = cache ? Math.max(parseInt(String(cache)), 300) : sMaxAge + res.setHeader('cache-control', `public, max-age=86400, s-maxage=${cacheMaxAge}, stale-while-revalidate=86400`) + } + res.setHeader('Content-Type', 'image/svg+xml;charset=utf-8') res.statusCode = code res.send(badgeSVGString) diff --git a/libs/serve-doc-next.ts b/libs/serve-doc-next.ts new file mode 100644 index 0000000..9659713 --- /dev/null +++ b/libs/serve-doc-next.ts @@ -0,0 +1,187 @@ +import http from 'http' +import matchRoute from 'my-way' +import { serveMarked } from 'serve-marked' +import serve404 from './serve-404' +import { BadgenServeConfig } from './create-badgen-handler-next' + +const { GA_MEASUREMENT_ID = 'G-PD7EFJDYFV' } = process.env + +export default function serveDoc (conf: BadgenServeConfig): http.RequestListener { + return (req, res) => { + const helpMarkdown = generateHelpMarkdown(conf) + + if (helpMarkdown) { + res.setHeader('Cache-Control', 'public, max-age=86400, s-maxage=604800, stale-while-revalidate=86400') + + return serveMarked(helpMarkdown, { + title: `${conf.title} badge | Badgen`, + inlineCSS, + beforeHeadEnd: ` + + + + + `, + beforeBodyEnd: helpFooter, + })(req, res) + } + + serve404(req, res) + } +} + +function generateHelpMarkdown ({ title, help, examples, handlers }: BadgenServeConfig): string { + const mainTitle = `# ${title} Badge` + + const customHelp = help || '' + + const exampleTitle = `## Examples` + + const routes = Object.keys(handlers) + const categorizedExamples = Object.entries(examples).reduce((accu, [url, desc]) => { + const scheme = routes.find(route => matchRoute(route, url)) + if (scheme) { + accu[scheme] ? accu[scheme].push({ url, desc }) : accu[scheme] = [{ url, desc }] + } + return accu + }, {}) + + const examplesSection = Object.entries(categorizedExamples).reduce((accu, [header, list]) => { + const hash = hashify(header) + const h4 = `

${header.replace(/

` + const ul = (list as Array).reduce((acc, { url, desc }) => { + return `${acc}\n- ![${url}](${url}) [${url}](${url}) ${desc}` + }, '') + return `${accu}\n\n${h4}\n\n${ul}` + }, '') + + return [mainTitle, customHelp, exampleTitle, examplesSection].join('\n\n') +} + +// turn `/github/:topic/:owner/:repo/:ref?` +// into `github-topic-commits-last-commit-owner-repo-ref` +function hashify (str: string) { + // return str.replace(/[^\w]/g, '') + return str.split(/[^\w]+/).filter(Boolean).join('-') +} + +const inlineCSS = ` + html, body { scroll-behavior: smooth } + .markdown-body { max-width: 960px; min-height: calc(100vh - 348px) } + .markdown-body h1 { margin-bottom: 42px } + li > img { vertical-align: middle; margin: 0.2em 0; font-size: 12px; float: right } + li > img + a { font-family: monospace; font-size: 0.9em } + li > img + a + i { color: #AAA } + h4 a code { color: #333; font-size: 1rem } + h4 a:hover { text-decoration: none !important } + h4 { padding: 4px 0 } +` + +const helpFooter = ` +
+ + +
+` diff --git a/next.config.js b/next.config.js index c4e364a..aec639c 100644 --- a/next.config.js +++ b/next.config.js @@ -24,27 +24,12 @@ const nextConfig = { }, async rewrites() { - const liveBadgeRedirects = badgeList.live.map(badge => { - return { - source: `/${badge.id}/:path*`, - destination: `/api/${badge.id}/:path*`, - } - }) - const staticBadgeRedirects = [{ - source: `/badge/:path*`, - destination: `/api/badge/:path*`, - }] - - const badgeRedirects = [ - { source: '/badge/:path*', destination: '/api/static' }, - { source: '/badge', destination: '/api/static' }, - ] - const badgeApis = [ '/static', '/github', '/gitlab', '/https', + '/memo', // registry '/amo', '/npm', @@ -76,12 +61,14 @@ const nextConfig = { '/david', ] - badgeApis.forEach(badge => { - badgeRedirects.push({ source: `${badge}/:path*`, destination: `/api${badge}` }) // badges - badgeRedirects.push({ source: badge, destination: `/api${badge}` }) // doc pages - }) + let badgeRedirects = [ + { source: '/badge/:path*', destination: '/api/static' }, + { source: '/badge', destination: '/api/static' }, + ] - // const badgeRedirects = liveBadgeRedirects.concat(staticBadgeRedirects) + badgeRedirects = badgeRedirects + .concat(badgeApis.map(badge => ({ source: `${badge}/:path*`, destination: `/api${badge}` }))) // badges + .concat(badgeApis.map(badge => ({ source: badge, destination: `/api${badge}` }))) // doc pages return badgeRedirects }, diff --git a/package-lock.json b/package-lock.json index 3bfbc6d..0ebae15 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@sentry/nextjs": "^7.37.1", "@sentry/tracing": "^7.36.0", "@vercel/analytics": "^1.0.1", + "@vercel/kv": "^0.2.2", "badgen": "^3.2.2", "badgen-icons": "^0.22.0", "byte-size": "^8.1.0", @@ -1373,11 +1374,30 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@upstash/redis": { + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/@upstash/redis/-/redis-1.21.0.tgz", + "integrity": "sha512-c6M+cl0LOgGK/7Gp6ooMkIZ1IDAJs8zFR+REPkoSkAq38o7CWFX5FYwYEqGZ6wJpUGBuEOr/7hTmippXGgL25A==", + "dependencies": { + "isomorphic-fetch": "^3.0.0" + } + }, "node_modules/@vercel/analytics": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@vercel/analytics/-/analytics-1.0.1.tgz", "integrity": "sha512-Ux0c9qUfkcPqng3vrR0GTrlQdqNJ2JREn/2ydrVuKwM3RtMfF2mWX31Ijqo1opSjNAq6rK76PwtANw6kl6TAow==" }, + "node_modules/@vercel/kv": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@vercel/kv/-/kv-0.2.2.tgz", + "integrity": "sha512-mqnQOB6bkp4h5eObxfLNIlhlVqOGSH8cWOlC5pDVWTjX3zL8dETO1ZBl6M74HBmeBjbD5+J7wDJklRigY6UNKw==", + "dependencies": { + "@upstash/redis": "1.21.0" + }, + "engines": { + "node": ">=14.6" + } + }, "node_modules/acorn": { "version": "8.10.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.10.0.tgz", @@ -3804,6 +3824,15 @@ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" }, + "node_modules/isomorphic-fetch": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/isomorphic-fetch/-/isomorphic-fetch-3.0.0.tgz", + "integrity": "sha512-qvUtwJ3j6qwsF3jLxkZ72qCgjMysPzDfeV240JHiGZsANBYd+EEuu35v7dfrJ9Up0Ak07D7GGSkGhCHTqg/5wA==", + "dependencies": { + "node-fetch": "^2.6.1", + "whatwg-fetch": "^3.4.1" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -5638,6 +5667,11 @@ "node": ">=10.13.0" } }, + "node_modules/whatwg-fetch": { + "version": "3.6.17", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.17.tgz", + "integrity": "sha512-c4ghIvG6th0eudYwKZY5keb81wtFz9/WeAHAoy8+r18kcWlitUIrmGFQ2rWEl4UCKUilD3zCLHOIPheHx5ypRQ==" + }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", diff --git a/package.json b/package.json index f296233..52af81c 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "@sentry/nextjs": "^7.37.1", "@sentry/tracing": "^7.36.0", "@vercel/analytics": "^1.0.1", + "@vercel/kv": "^0.2.2", "badgen": "^3.2.2", "badgen-icons": "^0.22.0", "byte-size": "^8.1.0", diff --git a/pages/api/memo.ts b/pages/api/memo.ts new file mode 100644 index 0000000..8d1e207 --- /dev/null +++ b/pages/api/memo.ts @@ -0,0 +1,110 @@ +import { kv } from '@vercel/kv' +import { createBadgenHandler, PathArgs, BadgenResponse } from '../../libs/create-badgen-handler-next' +import type { NextApiRequest, NextApiResponse } from 'next' + +const help = ` +A badge with memory. + +## Usage (public badge) + +For any /memo/:key badge, like: + + https://badgen.net/memo/my-badge-with-memory + +you may update it with a PUT request: + + curl -X PUT https://badgen.net/memo/my-badge-with-memory/:label/:status/:color + +WARNING: anyone can update this badge, so use it with caution. + +## Usage (protected badge) + +If you want a protected badge (only you can update it), you may add an Authorization: Bearer XXXXXX header while setting it: + + curl -X PUT --header "Authorization: Bearer XXXXXX https://badgen.net/memo/my-badge-with-memory/:label/:status/:color + +Once created, a memo badge created with token can only be updated with the same token, until it's expired. + +## Expiration + +A memo badge will be expired after 32 days since it's modified, unless it get updated again within the period. + +- When it's updated, it gets another 32 days lifespan. +- When it's expired, it gets cleared like never exists. + +To keep a memo badge, it's recommended to update the badge at least on a monthly basis. Usually this should be done in CI or Cron jobs. +` + +export default createBadgenHandler({ + title: 'Memo', + help, + examples: { + '/memo/deployed': 'memoized badge for deploy status', + }, + handlers: { + '/memo/:key': handler, + '/memo/:key/:label/:status/:color': putHandler + } +}) + +const MEMOIZED_TTL_SECONDS = 2764800 // 32 days + +type MemoizedBadgeItem = { + token: string; + params: { + label: string; + status: string; + color: string; + } +} + +async function handler ({ key }: PathArgs, req: NextApiRequest, res: NextApiResponse): Promise { + const storedData = await kv.get(key) + + if (storedData === null || storedData === undefined) { + res.setHeader('cache-control', `s-maxage=1, stale-while-revalidate=1`) + + return { + subject: key, + status: '404', + color: 'grey' + } + } else { + const ttl = await kv.ttl(key) + res.setHeader('cache-control', `max-age=${ttl}, s-maxage=300, stale-while-revalidate=86400`) + + const { label, status, color } = storedData.params + return { subject: label, status, color } + } +} + +async function putHandler (args: PathArgs, req: NextApiRequest, res: NextApiResponse): Promise { + // Only accept PUT request + if (req.method !== 'PUT') { + res.setHeader('Allow', 'PUT') + res.status(405) + return 'Method Not Allowed' + } + + // If no token(authorization) is provided, + // we will use a default one, which means this badge is public writable. + const PUBLIC_TOKEN = 'Bearer PUBLIC_WRITABLE' + const token = req.headers['authorization'] || PUBLIC_TOKEN + + const { key, label, status, color } = args + + const newData: MemoizedBadgeItem = { token, params: { label, status, color }} + + const storedData = await kv.get(key) + + if (storedData === null || storedData.token === token) { + // If the key is not found, or found and token is validate, ser/update data and ttl + await kv.set(key, newData, { ex: MEMOIZED_TTL_SECONDS }) + return JSON.stringify(newData.params) + } else { + // The key is found but token is invalid, refuse to update the data + res.status(401) + return 'Unauthorized' + } + +} diff --git a/pages/api/winget.ts b/pages/api/winget.ts index 59aaf68..a080402 100644 --- a/pages/api/winget.ts +++ b/pages/api/winget.ts @@ -3,7 +3,7 @@ import { basename, extname } from 'path' import { version, versionColor } from '../../libs/utils' import { createBadgenHandler } from '../../libs/create-badgen-handler-next' -import type { PathArgs, BadgenResult } from '../../libs/create-badgen-handler-next' +import type { PathArgs, BadgenResponse } from '../../libs/create-badgen-handler-next' const WINGET_GITHUB_REPO = 'microsoft/winget-pkgs' @@ -106,7 +106,7 @@ export default createBadgenHandler({ } }) -async function handler ({ topic, appId }: PathArgs): BadgenResult { +async function handler ({ topic, appId }: PathArgs): Promise { switch (topic) { case 'v': { const versions = await fetchVersions(appId) diff --git a/pages/api/xo.ts b/pages/api/xo.ts index 8ecbe6c..a8edcc6 100644 --- a/pages/api/xo.ts +++ b/pages/api/xo.ts @@ -1,7 +1,7 @@ import got from '../../libs/got' import { createBadgenHandler } from '../../libs/create-badgen-handler-next' -import type { PathArgs, BadgenResult } from '../../libs/create-badgen-handler-next' +import type { PathArgs, BadgenResponse } from '../../libs/create-badgen-handler-next' export default createBadgenHandler({ title: 'XO', @@ -24,7 +24,7 @@ const getIndent = space => { return `${space} spaces` } -async function handler ({ topic, scope, name }: PathArgs): BadgenResult { +async function handler ({ topic, scope, name }: PathArgs): Promise { const pkg = scope ? `${scope}/${name}` : name const endpoint = `https://cdn.jsdelivr.net/npm/${pkg}/package.json` const data = await got(endpoint).json() diff --git a/vercel.json b/vercel.json index 08b3725..c425c56 100644 --- a/vercel.json +++ b/vercel.json @@ -108,10 +108,6 @@ "source": "/melpa/:match*", "destination": "https://v2022.badgen.net/melpa/:match*" }, - { - "source": "/memo/:match*", - "destination": "https://v2022.badgen.net/memo/:match*" - }, { "source": "/nuget/:match*", "destination": "https://v2022.badgen.net/nuget/:match*"