feat: new /memo badge built on Vercel KV (#637)

pull/638/head
Amio Jin 2023-07-30 13:55:21 +08:00 zatwierdzone przez GitHub
rodzic 6016e85e70
commit b22ea33d90
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 4AEE18F83AFDEB23
12 zmienionych plików z 386 dodań i 109 usunięć

Wyświetl plik

@ -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<any>()
data.subject = data.subject || data.label
return data
}

Wyświetl plik

@ -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,

Wyświetl plik

@ -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<ReturnType<typeof matchRoute>>
export type BadgenResult = Promise<BadgenParams>
export type BadgenResponse = BadgenParams | string
export type BadgenHandler = (pathArgs: PathArgs, req: NextApiRequest, res: NextApiResponse) => Promise<BadgenResponse>
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 {

Wyświetl plik

@ -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)

Wyświetl plik

@ -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: `
<link rel="icon" href="/favicon.png" />
<!-- Google tag (gtag.js) -->
<script async src="https://www.googletagmanager.com/gtag/js?id=${GA_MEASUREMENT_ID}"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '${GA_MEASUREMENT_ID}');
</script>
`,
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 = `<h4 id="${hash}"><a href="#${hash}"><code>${header.replace(/</g, '&lt;')}</code></a></h4>`
const ul = (list as Array<any>).reduce((acc, { url, desc }) => {
return `${acc}\n- ![${url}](${url}) [${url}](${url}) <i>${desc}</i>`
}, '')
return `${accu}\n\n${h4}\n\n${ul}`
}, '')
return [mainTitle, customHelp, exampleTitle, examplesSection].join('\n\n')
}
// turn `/github/:topic<commits|last-commit>/: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 = `
<footer>
<div class='footer-content'>
<div>
<h3><img src='/statics/badgen-logo-w.svg' />Badgen Service</h3>
<div class='sitemap'>
<a href='https://badgen.net'>Classic</a>
<em>/</em>
<a href='https://flat.badgen.net'>Flat</a>
<em>/</em>
<a href='/builder'>Builder</a>
<em>/</em>
<a href='https://github.com/badgen/badgen.net'>GitHub</a>
<em>/</em>
<a href='https://twitter.com/badgen_net'>Twitter</a>
<br />
</div>
</div>
<div class='bottom'>
<div>
Built with by <a href='https://github.com/amio'>Amio</a> and awesome <a href='https://github.com/badgen/badgen.net/graphs/contributors'>contributors</a>. Powered by <a href='https://vercel.com'>Vercel</a>. License under <a href='https://github.com/badgen/badgen.net/blob/master/LICENSE.md'>ISC</a>.
</div>
<div class='links'>
<a href='https://twitter.com/badgen_net'>
<img src='https://simpleicons.now.sh/twitter/fff' />
</a>
<a href='https://github.com/badgen/badgen.net'>
<img src='https://simpleicons.now.sh/github/fff' />
</a>
</div>
</div>
</div>
<style>
footer {
margin-top: 5rem;
background-color: #222;
padding: 2rem 2rem;
color: #777;
font-size: 16px;
}
footer a {
text-decoration: none;
}
.footer-content {
margin: 0 auto;
}
footer h3 {
font: 24px/32px Merriweather, serif;
letter-spacing: 0.5px;
color: #DDD;
}
footer h3 img {
height: 21px;
opacity: 0.8;
margin-right: 8px;
position: relative;
top: 1px;
}
footer .sitemap {
line-height: 26px;
padding-bottom: 2em;
}
footer .sitemap a {
color: #999;
font-family: Merriweather;
text-transform: uppercase;
letter-spacing: 0.5px;
}
footer a:hover {
color: #EEE;
text-decoration: underline;
}
footer .sitemap em {
color: #555;
margin: 0 0.6rem;
}
footer .bottom {
margin-top: 2rem;
border-top: 1px solid #444;
padding-top: 2rem;
display: grid;
grid-template-columns: 1fr 100px;
}
footer .bottom a {
color: #999;
}
footer .links {
text-align: right;
}
footer .links a {
margin-left: 1em;
opacity: 0.7;
}
footer .links a:hover {
opacity: 1;
}
footer .links img {
height: 22px;
width: 22px
}
</style>
</footer>
`

Wyświetl plik

@ -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
},

34
package-lock.json wygenerowano
Wyświetl plik

@ -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",

Wyświetl plik

@ -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",

110
pages/api/memo.ts 100644
Wyświetl plik

@ -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 <code>/memo/:key</code> badge, like:
https://badgen.net/memo/my-badge-with-memory
you may update it with a <code>PUT</code> 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 <code>Authorization: Bearer XXXXXX</code> 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 <b>32 days</b> 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<BadgenResponse> {
const storedData = await kv.get<MemoizedBadgeItem>(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<BadgenResponse> {
// 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<MemoizedBadgeItem>(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'
}
}

Wyświetl plik

@ -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<BadgenResponse> {
switch (topic) {
case 'v': {
const versions = await fetchVersions(appId)

Wyświetl plik

@ -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<BadgenResponse> {
const pkg = scope ? `${scope}/${name}` : name
const endpoint = `https://cdn.jsdelivr.net/npm/${pkg}/package.json`
const data = await got(endpoint).json<any>()

Wyświetl plik

@ -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*"