feat(docs): add contributor certificates

This commit is contained in:
t8y2
2026-07-19 14:02:21 +08:00
parent 0802477874
commit 6b975cf797
16 changed files with 2206 additions and 7 deletions
+38
View File
@@ -0,0 +1,38 @@
name: Update Contributors
on:
schedule:
- cron: "23 3 * * 1"
workflow_dispatch:
permissions:
contents: write
concurrency:
group: contributors
cancel-in-progress: true
jobs:
update:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v6
with:
node-version: 22
- uses: pnpm/action-setup@v6
- name: Refresh contributor activity
working-directory: docs
run: pnpm contributors:update
env:
GITHUB_TOKEN: ${{ github.token }}
- name: Commit refreshed snapshot
run: |
if git diff --quiet -- docs/data/contributors.json; then
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add docs/data/contributors.json
git commit -m "chore(docs): refresh contributor activity"
git push
+20
View File
@@ -0,0 +1,20 @@
# Contributor certificate OAuth setup
The contributors page treats an account as eligible only when it has authored at least one pull request merged into `t8y2/dbx`. Commit counts are displayed as an additional metric but do not grant eligibility by themselves.
Create a GitHub OAuth App with:
- Homepage URL: `https://dbxio.com`
- Authorization callback URL: `https://dbxio.com/api/auth/github/callback`
Configure the deployed Cloudflare Worker secrets from `docs/`:
```bash
pnpm dlx wrangler secret put GITHUB_CLIENT_ID
pnpm dlx wrangler secret put GITHUB_CLIENT_SECRET
pnpm dlx wrangler secret put SESSION_SECRET
```
`SESSION_SECRET` should be a randomly generated value of at least 32 bytes. The OAuth flow requests no repository scopes, reads the authenticated user's public GitHub identity, and discards the access token immediately afterward.
For a non-production callback origin, also configure `GITHUB_OAUTH_CALLBACK_URL` as a Worker secret matching the callback registered in the OAuth App.
+55
View File
@@ -0,0 +1,55 @@
import type { Metadata } from "next";
import contributorSnapshot from "@/data/contributors.json";
import { ContributorsExperience } from "@/components/contributors/ContributorsExperience";
import { LandingFooter } from "@/components/landing/LandingFooter";
import { LandingNav } from "@/components/landing/LandingNav";
import type { ContributorActivityData } from "@/lib/contributorActivity";
import { SITE_URL } from "@/lib/metadata";
const pageMetadata = {
en: {
title: "DBX Contributors",
description: "Explore the people building DBX and download a certificate generated from public GitHub activity.",
},
cn: {
title: "DBX 贡献者",
description: "查看共同建设 DBX 的开源贡献者,并根据公开 GitHub 活动生成贡献证书。",
},
};
export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }): Promise<Metadata> {
const { lang } = await params;
const locale = lang === "cn" ? "cn" : "en";
const metadata = pageMetadata[locale];
return {
...metadata,
alternates: {
canonical: `${SITE_URL}/${locale}/contributors`,
languages: {
en: `${SITE_URL}/en/contributors`,
zh: `${SITE_URL}/cn/contributors`,
"x-default": `${SITE_URL}/en/contributors`,
},
},
openGraph: {
title: metadata.title,
description: metadata.description,
url: `${SITE_URL}/${locale}/contributors`,
},
};
}
export default async function ContributorsPage({ params }: { params: Promise<{ lang: string }> }) {
const { lang } = await params;
const locale = lang === "cn" ? "cn" : "en";
const data = contributorSnapshot as ContributorActivityData;
return (
<main className="landing min-h-screen bg-[#061016]">
<LandingNav lang={locale} active="contributors" />
<ContributorsExperience data={data} lang={locale} />
<LandingFooter lang={locale} />
</main>
);
}
+1 -1
View File
@@ -594,7 +594,7 @@ export default async function LandingPage({ params }: { params: Promise<{ lang:
{/* Contributors */}
<RevealSection className="max-w-[1180px] mx-auto px-7 pt-[70px] pb-1 max-[760px]:px-[18px]">
<ContributorsWallContent contributors={contributors} title={t.contributorsTitle} desc={t.contributorsDesc} />
<ContributorsWallContent contributors={contributors} title={t.contributorsTitle} desc={t.contributorsDesc} lang={l} />
</RevealSection>
{/* Updates */}
@@ -0,0 +1,253 @@
.experience {
--violet: #6ea8ff;
--violet-deep: #348bcf;
--lavender: #52d5ff;
--gold: #d9ad3d;
--ink: #f7fbff;
--muted: #9aa9bc;
min-height: 100vh;
overflow: hidden;
background:
radial-gradient(circle at 82% 20%, rgba(82, 213, 255, 0.05), transparent 28rem),
#10151d;
color: var(--ink);
font-family: "Avenir Next", "PingFang SC", sans-serif;
}
.hero {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(390px, 0.72fr);
align-items: center;
gap: 58px;
min-height: 590px;
padding: 108px max(28px, calc((100vw - 1180px) / 2)) 66px;
overflow: hidden;
background:
radial-gradient(circle at 14% 8%, rgba(82, 213, 255, 0.14), transparent 24rem),
radial-gradient(circle at 84% 64%, rgba(217, 173, 61, 0.08), transparent 26rem),
linear-gradient(138deg, #10151d 0%, #0c1528 58%, #111827 100%);
color: #fff;
}
.hero::before,
.hero::after {
content: "";
position: absolute;
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 50%;
}
.hero::before { width: 560px; height: 560px; right: -230px; top: 30px; }
.hero::after { width: 310px; height: 310px; right: -105px; top: 155px; }
.sparkles { position: absolute; inset: 0; pointer-events: none; }
.sparkles i {
--x: calc((var(--i) * 47) % 96);
--y: calc((var(--i) * 71) % 88);
position: absolute;
left: calc(var(--x) * 1%);
top: calc(var(--y) * 1%);
width: 4px;
height: 4px;
border-radius: 50%;
background: rgba(139, 232, 247, 0.78);
box-shadow: 0 0 18px rgba(82, 213, 255, 0.72);
animation: twinkle 2.8s ease-in-out infinite;
animation-delay: calc(var(--i) * -110ms);
}
.heroCopy,
.heroCertificate { position: relative; z-index: 2; }
.hero h1 {
max-width: 680px;
margin: 28px 0 22px;
font-family: "Avenir Next Condensed", "DIN Alternate", "PingFang SC", sans-serif;
font-size: clamp(50px, 4.8vw, 74px);
font-weight: 800;
line-height: 1.02;
letter-spacing: -0.045em;
text-wrap: balance;
}
.heroCopy > p {
max-width: 650px;
margin: 0;
color: rgba(255, 255, 255, 0.8);
font-size: 17px;
line-height: 1.72;
}
.githubButton {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 10px;
min-height: 52px;
margin-top: 28px;
padding: 0 22px;
border: 0;
border-radius: 10px;
background: #f7fbff;
color: #111827;
box-shadow: 0 18px 38px rgba(0, 0, 0, 0.24);
font-size: 14px;
font-weight: 750;
cursor: pointer;
transition: transform 160ms ease, box-shadow 160ms ease;
}
.githubButton:hover { transform: translateY(-2px); box-shadow: 0 22px 46px rgba(0, 0, 0, 0.34); }
.authLoading { margin-top: 34px; color: rgba(255, 255, 255, 0.76); font: 600 13px ui-monospace, monospace; }
.verifiedPanel {
display: grid;
grid-template-columns: 48px minmax(0, 1fr) auto auto;
align-items: center;
gap: 13px;
max-width: 610px;
margin-top: 34px;
padding: 10px;
border: 1px solid rgba(255, 255, 255, 0.26);
border-radius: 14px;
background: rgba(17, 21, 29, 0.7);
backdrop-filter: blur(18px);
}
.verifiedPanel img { width: 48px; height: 48px; border-radius: 10px; object-fit: cover; }
.verifiedPanel small { display: block; color: rgba(255, 255, 255, 0.66); font-size: 10px; }
.verifiedPanel strong { display: block; margin-top: 2px; font-size: 14px; }
.verifiedPanel > button:not(.logoutButton),
.verifiedPanel a {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
min-height: 40px;
padding: 0 14px;
border: 0;
border-radius: 8px;
background: #fff;
color: var(--violet-deep);
font-size: 12px;
font-weight: 760;
text-decoration: none;
cursor: pointer;
}
.logoutButton { display: grid; place-items: center; width: 38px; height: 38px; border: 0; border-radius: 8px; background: rgba(37, 33, 91, 0.22); color: #fff; cursor: pointer; }
.notEligible { margin-top: 12px !important; color: #fff4c7 !important; font-size: 12px !important; line-height: 1.55 !important; }
.heroCertificate { perspective: 1000px; }
.certificatePreview {
position: relative;
min-height: 360px;
padding: 32px;
overflow: hidden;
border-radius: 24px;
background: rgba(255, 255, 255, 0.96);
color: #24233b;
box-shadow: 0 32px 76px rgba(0, 0, 0, 0.36);
transform: rotate(1.5deg);
}
.certificatePreview::before { content: ""; position: absolute; inset: 15px; border: 2px solid rgba(217, 173, 61, 0.72); border-radius: 16px; pointer-events: none; }
.certificatePreview::after { content: ""; position: absolute; width: 270px; height: 270px; right: -100px; bottom: -120px; border-radius: 50%; background: rgba(98, 95, 224, 0.07); }
.previewTop { display: flex; align-items: center; justify-content: space-between; color: var(--violet); font: 800 13px ui-monospace, monospace; letter-spacing: 0.12em; }
.previewTop > span { display: inline-flex; align-items: center; gap: 10px; }
.previewTop img { border-radius: 7px; }
.certificatePreview > small { display: block; margin-top: 48px; color: var(--gold); font-size: 13px; font-weight: 750; letter-spacing: 0.08em; }
.certificatePreview > strong { display: block; max-width: 76%; margin-top: 12px; overflow: hidden; color: var(--violet-deep); font-size: clamp(38px, 4vw, 58px); line-height: 1; text-overflow: ellipsis; white-space: nowrap; }
.certificatePreview > p { max-width: 75%; margin: 16px 0 0; color: #716f86; font-size: 12px; line-height: 1.55; }
.previewMetrics { display: grid; grid-template-columns: repeat(2, 1fr); gap: 9px; width: 72%; margin-top: 24px; }
.previewMetrics div { padding: 13px; border-radius: 10px; background: #f6f2e8; }
.previewMetrics b { display: block; font: 800 24px ui-monospace, monospace; }
.previewMetrics span { display: block; margin-top: 3px; color: #9c7e31; font: 700 8px ui-monospace, monospace; letter-spacing: 0.06em; }
.previewSeal { position: absolute; right: 40px; top: 120px; display: grid; place-items: center; width: 84px; height: 84px; overflow: hidden; border: 5px solid var(--gold); border-radius: 50%; background: var(--gold); color: #fff; box-shadow: 0 0 0 10px rgba(217, 173, 61, 0.12); font: 800 28px ui-monospace, monospace; }
.previewSeal img { width: 100%; height: 100%; object-fit: cover; }
.metrics {
position: relative;
z-index: 4;
display: grid;
grid-template-columns: repeat(4, 1fr);
max-width: 1180px;
margin: -22px auto 0;
overflow: hidden;
border: 1px solid rgba(155, 176, 205, 0.18);
border-radius: 18px;
background: rgba(17, 21, 29, 0.96);
box-shadow: 0 18px 54px rgba(0, 0, 0, 0.24);
}
.metrics div { padding: 22px 28px; border-right: 1px solid rgba(155, 176, 205, 0.16); }
.metrics div:last-child { border-right: 0; }
.metrics strong { display: block; color: #f7fbff; font-size: 28px; }
.metrics span { display: block; margin-top: 4px; color: var(--muted); font-size: 11px; }
.directorySection { max-width: 1180px; margin: 0 auto; padding: 92px 28px 120px; }
.directoryHeader h2 { margin: 0; font-size: clamp(30px, 3vw, 42px); line-height: 1.12; letter-spacing: -0.03em; }
.directoryHeader { display: flex; align-items: end; justify-content: space-between; gap: 32px; margin-bottom: 28px; }
.directoryHeader label { display: flex; align-items: center; gap: 9px; width: min(360px, 100%); height: 46px; padding: 0 14px; border: 1px solid rgba(155, 176, 205, 0.2); border-radius: 10px; background: rgba(17, 21, 29, 0.88); color: var(--violet); }
.directoryHeader input { width: 100%; outline: 0; background: transparent; color: var(--ink); font-size: 13px; }
.directoryGrid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
.directoryGrid a { display: grid; grid-template-columns: 46px minmax(0, 1fr) auto; align-items: center; gap: 12px; min-width: 0; padding: 12px; border: 1px solid rgba(155, 176, 205, 0.16); border-radius: 10px; background: rgba(17, 21, 29, 0.82); color: var(--ink); text-decoration: none; transition: transform 160ms ease, border-color 160ms ease, background 160ms ease; }
.directoryGrid a:hover { transform: translateY(-3px); border-color: rgba(110, 168, 255, 0.38); background: rgba(23, 28, 38, 0.96); }
.directoryGrid img { width: 46px; height: 46px; border-radius: 10px; object-fit: cover; }
.directoryGrid strong { display: block; overflow: hidden; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
.directoryGrid small { display: block; margin-top: 4px; color: var(--muted); font-size: 10px; }
.directoryGrid svg { color: #718396; }
.empty { padding: 60px; border: 1px dashed rgba(155, 176, 205, 0.2); border-radius: 12px; color: var(--muted); text-align: center; }
.modalBackdrop { position: fixed; z-index: 100; inset: 0; display: grid; place-items: center; padding: 24px; background: rgba(31, 27, 66, 0.74); backdrop-filter: blur(16px); }
.modal { width: min(980px, 100%); }
.fullCertificate { position: relative; min-height: 590px; padding: 54px; overflow: hidden; border-radius: 24px; background: #fff; color: #24233b; box-shadow: 0 45px 130px rgba(0, 0, 0, 0.42); }
.fullCertificate::before { content: ""; position: absolute; inset: 18px; border: 2px solid rgba(217, 173, 61, 0.74); border-radius: 16px; pointer-events: none; }
.fullCertificate::after { content: ""; position: absolute; right: -110px; bottom: -150px; width: 380px; height: 380px; border-radius: 50%; background: rgba(98, 95, 224, 0.06); }
.verifiedText { display: block; margin-top: 8px; color: var(--gold); font: 750 10px ui-monospace, monospace; letter-spacing: 0.12em; }
.fullCertificate > small { display: block; margin-top: 72px; color: var(--gold); font-size: 14px; font-weight: 760; }
.fullCertificate > em { display: block; margin-top: 25px; color: var(--muted); font-size: 13px; font-style: normal; }
.fullCertificate h2 { max-width: 72%; margin: 7px 0 16px; overflow: hidden; color: var(--violet-deep); font-size: clamp(48px, 7vw, 82px); line-height: 1; letter-spacing: -0.04em; text-overflow: ellipsis; white-space: nowrap; }
.fullCertificate > p { max-width: 690px; color: var(--muted); line-height: 1.65; }
.fullMetrics { display: grid; grid-template-columns: repeat(2, minmax(0, 230px)); gap: 12px; margin-top: 36px; }
.fullMetrics div { padding: 17px; border-radius: 12px; background: #f7f1df; }
.fullMetrics b { display: block; font: 800 30px ui-monospace, monospace; }
.fullMetrics span { display: block; margin-top: 4px; color: #9a7c2e; font: 700 9px ui-monospace, monospace; letter-spacing: 0.08em; }
.fullSeal { position: absolute; right: 72px; top: 180px; display: grid; place-items: center; width: 128px; height: 128px; overflow: hidden; border: 7px solid var(--gold); border-radius: 50%; background: var(--gold); color: #fff; box-shadow: 0 0 0 13px rgba(217, 173, 61, 0.12); font: 800 38px ui-monospace, monospace; }
.fullSeal img { width: 100%; height: 100%; object-fit: cover; }
.fullCertificate footer { position: absolute; right: 54px; bottom: 36px; left: 54px; display: flex; justify-content: space-between; color: #aaa7b8; font: 650 10px ui-monospace, monospace; letter-spacing: 0.08em; }
.modalActions { display: flex; justify-content: flex-end; gap: 9px; margin-top: 12px; }
.modalActions button { display: inline-flex; align-items: center; gap: 8px; min-height: 43px; padding: 0 16px; border: 1px solid rgba(255, 255, 255, 0.25); border-radius: 9px; background: rgba(255, 255, 255, 0.12); color: #fff; font-size: 12px; font-weight: 740; cursor: pointer; }
.modalActions button:first-child { border-color: #fff; background: #fff; color: var(--violet-deep); }
@keyframes twinkle { 50% { opacity: 0.3; transform: scale(0.65); } }
@media (max-width: 1050px) {
.hero { grid-template-columns: 1fr; padding-top: 112px; }
.heroCopy { max-width: 760px; margin: 0 auto; text-align: center; }
.heroCopy > p { margin-right: auto; margin-left: auto; }
.heroCertificate { width: min(620px, 100%); margin: 0 auto; }
.verifiedPanel { margin-right: auto; margin-left: auto; text-align: left; }
.directoryGrid { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 760px) {
.hero { gap: 48px; min-height: 0; padding: 100px 18px 72px; }
.hero h1 { font-size: 51px; }
.verifiedPanel { grid-template-columns: 44px minmax(0, 1fr) auto; }
.verifiedPanel > button:not(.logoutButton), .verifiedPanel a { grid-column: 1 / -1; }
.metrics { grid-template-columns: repeat(2, 1fr); margin: -24px 18px 0; }
.metrics div:nth-child(2) { border-right: 0; }
.metrics div:nth-child(-n + 2) { border-bottom: 1px solid rgba(155, 176, 205, 0.16); }
.directorySection { padding-right: 18px; padding-left: 18px; }
.directoryHeader { align-items: stretch; flex-direction: column; }
.directoryGrid { grid-template-columns: 1fr; }
.certificatePreview { min-height: 390px; padding: 30px; }
.certificatePreview > p { max-width: 68%; }
.previewSeal { right: 34px; width: 74px; height: 74px; font-size: 22px; }
.fullCertificate { min-height: 650px; padding: 38px 32px; }
.fullCertificate h2 { max-width: 100%; font-size: 48px; }
.fullSeal { top: 120px; right: 42px; width: 82px; height: 82px; font-size: 26px; }
.fullMetrics { grid-template-columns: 1fr; max-width: 230px; }
.fullCertificate footer { right: 32px; left: 32px; }
}
@media (prefers-reduced-motion: reduce) {
.sparkles i { animation: none; opacity: 1; transform: none; }
.githubButton, .directoryGrid a { transition: none; }
}
@@ -0,0 +1,312 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { Award, Download, ExternalLink, Github, LogOut, Search, ShieldCheck } from "lucide-react";
import type { ContributorActivity, ContributorActivityData } from "@/lib/contributorActivity";
import styles from "./ContributorsExperience.module.css";
type Language = "en" | "cn";
type AuthUser = { login: string; avatarUrl: string; profileUrl: string };
type AuthState = { status: "loading" } | { status: "signed-out" } | { status: "signed-in"; user: AuthUser };
const copy = {
en: {
title: "Your work is part of DBX.",
intro: "Sign in with GitHub to verify your identity and collect a shareable certificate for pull requests merged into DBX.",
signIn: "Verify with GitHub",
signingIn: "Checking GitHub session…",
contributors: "verified contributors",
merged: "merged pull requests",
commits: "repository commits",
stars: "GitHub stars",
directory: "Contributor directory",
search: "Search GitHub login",
noResults: "No matching contributor.",
signedInAs: "Verified as",
claim: "Open my certificate",
notEligible: "This GitHub account does not have a merged pull request in DBX yet.",
contributionCallout: "Ship a focused pull request and join the wall.",
viewGuide: "Contribution guide",
signOut: "Sign out",
certificate: "Open-source contributor",
awardedTo: "Presented to",
certificateBody: "For work accepted and merged into DBX, helping make database tools better for everyone.",
download: "Download certificate",
close: "Close",
mergedLabel: "MERGED PULL REQUESTS",
verified: "GITHUB IDENTITY VERIFIED",
},
cn: {
title: "你的代码,已经成为 DBX 的一部分。",
intro: "使用 GitHub 验证身份,领取一张属于你的贡献者证书,记录被 DBX 接受并合并的 Pull Request。",
signIn: "使用 GitHub 验证身份",
signingIn: "正在检查 GitHub 登录状态…",
contributors: "位贡献者",
merged: "个已合并 PR",
commits: "次仓库 Commit",
stars: "个 GitHub Star",
directory: "完整贡献者名单",
search: "搜索 GitHub 用户名",
noResults: "没有匹配的贡献者。",
signedInAs: "已验证身份",
claim: "打开我的证书",
notEligible: "这个 GitHub 账号暂时还没有被 DBX 合并的 Pull Request。",
contributionCallout: "提交一个专注、可合并的 PR,加入这面贡献者墙。",
viewGuide: "查看贡献指南",
signOut: "退出验证",
certificate: "开源项目贡献者",
awardedTo: "授予",
certificateBody: "感谢你的代码被 DBX 接受并合并,让每个人都能使用更好的数据库工具。",
download: "下载贡献者证书",
close: "关闭",
mergedLabel: "已合并 PULL REQUEST",
verified: "GITHUB 身份已验证",
},
} as const;
function formatNumber(value: number, lang: Language) {
return new Intl.NumberFormat(lang === "cn" ? "zh-CN" : "en-US").format(value);
}
function initials(login: string) {
return login.slice(0, 2).toUpperCase();
}
function loadImage(source: string): Promise<HTMLImageElement | null> {
return new Promise((resolve) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => resolve(null);
image.src = source;
});
}
async function drawCertificate(contributor: ContributorActivity, lang: Language) {
const canvas = document.createElement("canvas");
canvas.width = 1800;
canvas.height = 1120;
const context = canvas.getContext("2d");
if (!context) return;
const text = copy[lang];
const [logo, avatar] = await Promise.all([loadImage("/logo.png"), loadImage(`/api/contributor-avatar?login=${encodeURIComponent(contributor.login)}`)]);
// The downloaded artifact is the certificate itself, not a screenshot of the page backdrop.
context.fillStyle = "#fffdf8";
context.fillRect(0, 0, 1800, 1120);
context.fillStyle = "#fffdf8";
context.beginPath();
context.roundRect(92, 82, 1616, 956, 38);
context.fill();
context.strokeStyle = "#e2b94f";
context.lineWidth = 4;
context.strokeRect(126, 116, 1548, 888);
context.strokeStyle = "rgba(226,185,79,0.38)";
context.lineWidth = 1;
context.strokeRect(144, 134, 1512, 852);
if (logo) context.drawImage(logo, 196, 174, 58, 58);
context.fillStyle = "#5b67d9";
context.font = "800 32px ui-monospace, monospace";
context.fillText("DBX · OPEN SOURCE DATABASE TOOL", 276, 216);
context.fillStyle = "#a17c21";
context.font = "700 22px ui-monospace, monospace";
context.fillText(text.verified, 196, 260);
context.fillStyle = "#242640";
context.font = "800 70px sans-serif";
context.fillText(text.certificate, 196, 410);
context.fillStyle = "#79788a";
context.font = "500 28px sans-serif";
context.fillText(text.awardedTo, 196, 474);
context.fillStyle = "#5b67d9";
context.font = "800 104px sans-serif";
context.fillText(`@${contributor.login}`, 196, 596);
context.fillStyle = "#5e5e72";
context.font = "400 29px sans-serif";
context.fillText(text.certificateBody, 196, 672);
const metrics = [
[formatNumber(contributor.mergedPullRequests, lang), text.mergedLabel],
[formatNumber(contributor.commits, lang), "COMMITS"],
];
metrics.forEach(([value, label], index) => {
const x = 196 + index * 500;
context.fillStyle = "#f7f0d8";
context.beginPath();
context.roundRect(x, 746, 470, 142, 22);
context.fill();
context.fillStyle = "#242640";
context.font = "800 58px ui-monospace, monospace";
context.fillText(value, x + 34, 818);
context.fillStyle = "#9a7a25";
context.font = "700 18px ui-monospace, monospace";
context.fillText(label, x + 34, 854);
});
context.fillStyle = "#e2b94f";
context.beginPath();
context.arc(1458, 540, 134, 0, Math.PI * 2);
context.fill();
context.save();
context.beginPath();
context.arc(1458, 540, 118, 0, Math.PI * 2);
context.clip();
if (avatar) {
context.drawImage(avatar, 1340, 422, 236, 236);
} else {
context.fillStyle = "#fffdf6";
context.fillRect(1340, 422, 236, 236);
context.fillStyle = "#5b67d9";
context.font = "800 60px ui-monospace, monospace";
context.textAlign = "center";
context.fillText(initials(contributor.login), 1458, 560);
context.textAlign = "left";
}
context.restore();
context.fillStyle = "#8b8998";
context.font = "500 19px ui-monospace, monospace";
context.fillText(`t8y2/dbx · ${new Date().toISOString().slice(0, 10)}`, 196, 948);
const link = document.createElement("a");
link.download = `dbx-contributor-${contributor.login}.png`;
link.href = canvas.toDataURL("image/png");
link.click();
}
function GithubMark() {
return <Github size={19} strokeWidth={2.2} />;
}
export function ContributorsExperience({ data, lang }: { data: ContributorActivityData; lang: Language }) {
const text = copy[lang];
const [auth, setAuth] = useState<AuthState>({ status: "loading" });
const [query, setQuery] = useState("");
const [certificateOpen, setCertificateOpen] = useState(false);
const mergedPullRequests = useMemo(() => data.contributors.reduce((total, contributor) => total + contributor.mergedPullRequests, 0), [data.contributors]);
const commits = useMemo(() => data.contributors.reduce((total, contributor) => total + contributor.commits, 0), [data.contributors]);
const verifiedContributor = useMemo(() => {
if (auth.status !== "signed-in") return null;
return data.contributors.find((contributor) => contributor.login.toLowerCase() === auth.user.login.toLowerCase()) ?? null;
}, [auth, data.contributors]);
const filtered = useMemo(() => {
const normalized = query.trim().toLowerCase();
if (!normalized) return data.contributors;
return data.contributors.filter((contributor) => contributor.login.toLowerCase().includes(normalized));
}, [data.contributors, query]);
useEffect(() => {
fetch("/api/auth/me", { credentials: "include" })
.then(async (response) => (response.ok ? response.json() : null))
.then((result: { authenticated?: boolean; user?: AuthUser } | null) => {
if (result?.authenticated && result.user) setAuth({ status: "signed-in", user: result.user });
else setAuth({ status: "signed-out" });
})
.catch(() => setAuth({ status: "signed-out" }));
}, []);
function signIn() {
const returnTo = `${window.location.pathname}${window.location.search}`;
window.location.assign(`/api/auth/github/start?returnTo=${encodeURIComponent(returnTo)}`);
}
async function signOut() {
await fetch("/api/auth/logout", { method: "POST", credentials: "include" }).catch(() => undefined);
setAuth({ status: "signed-out" });
setCertificateOpen(false);
}
return (
<div className={styles.experience}>
<section className={styles.hero}>
<div className={styles.sparkles} aria-hidden="true">{Array.from({ length: 18 }, (_, index) => <i key={index} style={{ "--i": index } as React.CSSProperties} />)}</div>
<div className={styles.heroCopy}>
<h1>{text.title}</h1>
<p>{text.intro}</p>
{auth.status === "loading" ? (
<div className={styles.authLoading}>{text.signingIn}</div>
) : auth.status === "signed-out" ? (
<button type="button" className={styles.githubButton} onClick={signIn}><GithubMark /> {text.signIn}</button>
) : (
<div className={styles.verifiedPanel}>
<img src={auth.user.avatarUrl} alt="" width={48} height={48} />
<div><small>{text.signedInAs}</small><strong>@{auth.user.login}</strong></div>
{verifiedContributor ? (
<button type="button" onClick={() => setCertificateOpen(true)}><Award size={17} /> {text.claim}</button>
) : (
<a href={`/${lang}/docs/contributing`}>{text.viewGuide}<ExternalLink size={14} /></a>
)}
<button type="button" className={styles.logoutButton} onClick={signOut} aria-label={text.signOut}><LogOut size={16} /></button>
</div>
)}
{auth.status === "signed-in" && !verifiedContributor ? <p className={styles.notEligible}>{text.notEligible}<br />{text.contributionCallout}</p> : null}
</div>
<div className={styles.heroCertificate}>
<div className={styles.certificatePreview}>
<div className={styles.previewTop}><span><img src="/logo.png" alt="DBX" width={28} height={28} />DBX</span><ShieldCheck size={18} /></div>
<small>{text.certificate}</small>
<strong>@{verifiedContributor?.login ?? "YOUR_NAME"}</strong>
<p>{text.certificateBody}</p>
<div className={styles.previewMetrics}>
<div><b>{verifiedContributor?.mergedPullRequests ?? "—"}</b><span>{text.mergedLabel}</span></div>
<div><b>{verifiedContributor?.commits ?? "—"}</b><span>COMMITS</span></div>
</div>
<div className={styles.previewSeal}>{verifiedContributor ? <img src={verifiedContributor.avatarUrl} alt={verifiedContributor.login} width={92} height={92} /> : <Award size={32} />}</div>
</div>
</div>
</section>
<section className={styles.metrics}>
<div><strong>{formatNumber(data.contributors.length, lang)}</strong><span>{text.contributors}</span></div>
<div><strong>{formatNumber(mergedPullRequests, lang)}</strong><span>{text.merged}</span></div>
<div><strong>{formatNumber(commits, lang)}</strong><span>{text.commits}</span></div>
<div><strong>{formatNumber(data.stars, lang)}</strong><span>{text.stars}</span></div>
</section>
<section className={styles.directorySection}>
<div className={styles.directoryHeader}><div><h2>{text.directory}</h2></div><label><Search size={17} /><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder={text.search} /></label></div>
{filtered.length ? (
<div className={styles.directoryGrid}>
{filtered.map((contributor) => (
<a key={contributor.login} href={contributor.profileUrl} target="_blank" rel="noopener noreferrer">
<img src={contributor.avatarUrl} alt="" width={46} height={46} loading="lazy" />
<span><strong>@{contributor.login}</strong><small>{contributor.mergedPullRequests} {text.merged} · {contributor.commits} Commit</small></span>
<ExternalLink size={15} />
</a>
))}
</div>
) : <div className={styles.empty}>{text.noResults}</div>}
</section>
{certificateOpen && verifiedContributor ? (
<div className={styles.modalBackdrop} role="presentation" onMouseDown={(event) => { if (event.target === event.currentTarget) setCertificateOpen(false); }}>
<section className={styles.modal} role="dialog" aria-modal="true" aria-label={text.certificate}>
<div className={styles.fullCertificate}>
<div className={styles.previewTop}><span><img src="/logo.png" alt="DBX" width={30} height={30} />DBX · OPEN SOURCE DATABASE TOOL</span><ShieldCheck size={20} /></div>
<span className={styles.verifiedText}>{text.verified}</span>
<small>{text.certificate}</small>
<em>{text.awardedTo}</em>
<h2>@{verifiedContributor.login}</h2>
<p>{text.certificateBody}</p>
<div className={styles.fullMetrics}>
<div><b>{verifiedContributor.mergedPullRequests}</b><span>{text.mergedLabel}</span></div>
<div><b>{verifiedContributor.commits}</b><span>COMMITS</span></div>
</div>
<div className={styles.fullSeal}><img src={verifiedContributor.avatarUrl} alt={verifiedContributor.login} width={128} height={128} /></div>
<footer><span>t8y2/dbx</span><span>dbxio.com</span></footer>
</div>
<div className={styles.modalActions}>
<button type="button" onClick={() => drawCertificate(verifiedContributor, lang)}><Download size={17} /> {text.download}</button>
<button type="button" onClick={() => setCertificateOpen(false)}>{text.close}</button>
</div>
</section>
</div>
) : null}
</div>
);
}
+7 -3
View File
@@ -33,7 +33,7 @@ function ContributorAvatar({ c }: { c: Contributor }) {
);
}
export function ContributorsWallContent({ contributors, title, desc }: { contributors: Contributor[]; title: string; desc: string }) {
export function ContributorsWallContent({ contributors, title, desc, lang }: { contributors: Contributor[]; title: string; desc: string; lang: "en" | "cn" }) {
const uniqueContributors = dedupeContributors(contributors);
if (uniqueContributors.length === 0) return null;
@@ -43,8 +43,12 @@ export function ContributorsWallContent({ contributors, title, desc }: { contrib
<h2 className="m-0 text-[25px] font-[720] text-landing-ink">{title}</h2>
<p className="mt-2 max-w-[650px] text-landing-muted text-sm leading-[1.65] justify-self-end text-right max-[760px]:max-w-none max-[760px]:text-left">
{desc}{" "}
<Link href="https://github.com/t8y2/dbx/graphs/contributors" target="_blank" className="landing-inline-link inline-flex items-center gap-[5px]">
{uniqueContributors.length}+ contributors on GitHub
<Link href={`/${lang}/contributors`} className="landing-inline-link inline-flex items-center gap-[5px]">
{lang === "cn" ? `查看 ${uniqueContributors.length}+ 位贡献者` : `Explore ${uniqueContributors.length}+ contributors`}
</Link>
<span className="mx-1.5 text-landing-muted" aria-hidden="true">·</span>
<Link href={`/${lang}/contributors`} className="landing-inline-link inline-flex items-center gap-[5px]">
{lang === "cn" ? "下载贡献者证书" : "Download contributor certificate"}
</Link>
</p>
</div>
+7 -2
View File
@@ -10,13 +10,14 @@ const i18n = {
databases: "Databases",
changelog: "Changelog",
community: "Community",
contributors: "Contributors",
drivers: "Offline Drivers",
lang: "中文",
},
cn: { home: "首页", docs: "文档", databases: "数据库", changelog: "更新日志", community: "交流群", drivers: "离线驱动", lang: "English" },
cn: { home: "首页", docs: "文档", databases: "数据库", changelog: "更新日志", community: "交流群", contributors: "贡献者", drivers: "离线驱动", lang: "English" },
};
export function LandingNav({ lang, active }: { lang: "en" | "cn"; active?: "home" | "databases" | "changelog" | "community" | "drivers" }) {
export function LandingNav({ lang, active }: { lang: "en" | "cn"; active?: "home" | "databases" | "changelog" | "community" | "contributors" | "drivers" }) {
const ref = useRef<HTMLElement>(null);
const t = i18n[lang];
const otherLang = lang === "cn" ? "en" : "cn";
@@ -24,6 +25,7 @@ export function LandingNav({ lang, active }: { lang: "en" | "cn"; active?: "home
databases: `/${otherLang}/databases`,
changelog: `/${otherLang}/changelog`,
community: `/${otherLang}/community`,
contributors: `/${otherLang}/contributors`,
drivers: `/${otherLang}/drivers`,
};
const langHref = langHrefMap[active ?? ""] ?? `/${otherLang}`;
@@ -64,6 +66,9 @@ export function LandingNav({ lang, active }: { lang: "en" | "cn"; active?: "home
<Link href={`/${lang}/community`} className={`landing-nav-link rounded-[7px] px-[11px] py-2 text-[13px] font-medium max-[760px]:hidden ${active === "community" ? "text-landing-ink" : "text-landing-muted"}`}>
{t.community}
</Link>
<Link href={`/${lang}/contributors`} className={`landing-nav-link rounded-[7px] px-[11px] py-2 text-[13px] font-medium max-[900px]:hidden ${active === "contributors" ? "text-landing-ink" : "text-landing-muted"}`}>
{t.contributors}
</Link>
<Link href={`/${lang}/drivers`} className={`landing-nav-link rounded-[7px] px-[11px] py-2 text-[13px] font-medium max-[760px]:hidden ${active === "drivers" ? "text-landing-ink" : "text-landing-muted"}`}>
{t.drivers}
</Link>
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
import assert from "node:assert/strict";
import { test } from "vitest";
import { sortContributorActivity, type ContributorActivityData } from "./contributorActivity";
const data: ContributorActivityData = {
repository: "t8y2/dbx",
generatedAt: "2026-07-19T00:00:00.000Z",
stars: 100,
contributors: [
{
login: "reviewer",
avatarUrl: "https://example.com/reviewer.png",
profileUrl: "https://github.com/reviewer",
commits: 2,
mergedPullRequests: 3,
firstContributionAt: null,
latestContributionAt: null,
},
],
};
test("sortContributorActivity ranks eligible contributors by commits", () => {
const sorted = sortContributorActivity([
{ ...data.contributors[0], login: "newcomer", commits: 99, mergedPullRequests: 1 },
data.contributors[0],
]);
assert.equal(sorted[0].login, "newcomer");
});
+28
View File
@@ -0,0 +1,28 @@
export type ContributorActivity = {
login: string;
avatarUrl: string;
profileUrl: string;
commits: number;
mergedPullRequests: number;
firstContributionAt: string | null;
latestContributionAt: string | null;
};
export type ContributorActivityData = {
repository: string;
generatedAt: string;
stars: number;
contributors: ContributorActivity[];
};
export function sortContributorActivity(contributors: ContributorActivity[]): ContributorActivity[] {
return [...contributors].sort((left, right) => {
const commitDifference = right.commits - left.commits;
if (commitDifference !== 0) return commitDifference;
const pullRequestDifference = right.mergedPullRequests - left.mergedPullRequests;
if (pullRequestDifference !== 0) return pullRequestDifference;
return left.login.localeCompare(right.login, "en", { sensitivity: "base" });
});
}
+15
View File
@@ -0,0 +1,15 @@
import assert from "node:assert/strict";
import { test } from "vitest";
import { sanitizeReturnTo, signPayload, verifySignedPayload } from "../worker";
test("signed OAuth payloads round-trip and reject tampering", async () => {
const signed = await signPayload({ login: "dbx-user" }, "test-secret");
assert.deepEqual(await verifySignedPayload<{ login: string }>(signed, "test-secret"), { login: "dbx-user" });
assert.equal(await verifySignedPayload(`${signed}x`, "test-secret"), null);
});
test("OAuth return paths stay on the DBX origin", () => {
assert.equal(sanitizeReturnTo("/cn/contributors"), "/cn/contributors");
assert.equal(sanitizeReturnTo("//evil.example"), "/en/contributors");
assert.equal(sanitizeReturnTo("https://evil.example"), "/en/contributors");
});
+1
View File
@@ -6,6 +6,7 @@
"dev": "next dev",
"build": "next build",
"postbuild": "node scripts/generate-sitemap.mjs",
"contributors:update": "node scripts/update-contributors.mjs",
"start": "next start",
"postinstall": "fumadocs-mdx"
},
+113
View File
@@ -0,0 +1,113 @@
import { mkdir, writeFile } from "node:fs/promises";
import { resolve } from "node:path";
const repository = process.env.DBX_GITHUB_REPOSITORY || "t8y2/dbx";
const token = process.env.GITHUB_TOKEN;
const apiBase = "https://api.github.com";
const outputPath = resolve(import.meta.dirname, "../data/contributors.json");
const headers = {
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
"User-Agent": "dbx-contributor-sync",
...(token ? { Authorization: `Bearer ${token}` } : {}),
};
function nextLink(linkHeader) {
if (!linkHeader) return null;
for (const part of linkHeader.split(",")) {
const [url, rel] = part.split(";").map((value) => value.trim());
if (rel === 'rel="next"') return url.slice(1, -1);
}
return null;
}
async function request(url) {
const response = await fetch(url, { headers });
if (!response.ok) {
const body = await response.text();
throw new Error(`GitHub API ${response.status} for ${url}: ${body.slice(0, 240)}`);
}
return response;
}
async function fetchAll(path) {
const records = [];
let url = `${apiBase}${path}${path.includes("?") ? "&" : "?"}per_page=100`;
while (url) {
const response = await request(url);
const page = await response.json();
if (!Array.isArray(page)) throw new Error(`Expected an array from ${url}`);
records.push(...page);
url = nextLink(response.headers.get("Link"));
}
return records;
}
function isBot(user) {
return !user?.login || user.type === "Bot" || user.login.endsWith("[bot]");
}
function updateDate(record, date) {
if (!date) return;
if (!record.firstContributionAt || date < record.firstContributionAt) record.firstContributionAt = date;
if (!record.latestContributionAt || date > record.latestContributionAt) record.latestContributionAt = date;
}
function ensureContributor(contributors, user) {
const key = user.login.toLowerCase();
let contributor = contributors.get(key);
if (!contributor) {
contributor = {
login: user.login,
avatarUrl: user.avatar_url || `https://github.com/${user.login}.png`,
profileUrl: user.html_url || `https://github.com/${user.login}`,
commits: 0,
mergedPullRequests: 0,
firstContributionAt: null,
latestContributionAt: null,
};
contributors.set(key, contributor);
}
return contributor;
}
const [repositoryInfo, commitContributors, pullRequests] = await Promise.all([
request(`${apiBase}/repos/${repository}`).then((response) => response.json()),
fetchAll(`/repos/${repository}/contributors?anon=0`),
fetchAll(`/repos/${repository}/pulls?state=closed&sort=created&direction=desc`),
]);
const contributors = new Map();
for (const pullRequest of pullRequests) {
if (!pullRequest.merged_at || isBot(pullRequest.user)) continue;
const contributor = ensureContributor(contributors, pullRequest.user);
contributor.mergedPullRequests += 1;
updateDate(contributor, pullRequest.created_at);
updateDate(contributor, pullRequest.merged_at);
}
for (const entry of commitContributors) {
if (isBot(entry)) continue;
// Commit counts enrich merged-PR contributors but never grant eligibility alone.
const contributor = contributors.get(entry.login.toLowerCase());
if (contributor) contributor.commits = entry.contributions || 0;
}
const rankedContributors = [...contributors.values()].filter((contributor) => contributor.mergedPullRequests > 0).sort((left, right) => {
return right.commits - left.commits || right.mergedPullRequests - left.mergedPullRequests || left.login.localeCompare(right.login);
});
const output = {
repository,
generatedAt: new Date().toISOString(),
stars: repositoryInfo.stargazers_count || 0,
contributors: rankedContributors,
};
await mkdir(resolve(outputPath, ".."), { recursive: true });
await writeFile(outputPath, `${JSON.stringify(output, null, 2)}\n`);
console.log(`Wrote ${rankedContributors.length} contributors to ${outputPath}`);
+219
View File
@@ -0,0 +1,219 @@
type AssetsBinding = { fetch(request: Request): Promise<Response> };
type Env = {
ASSETS: AssetsBinding;
GITHUB_CLIENT_ID?: string;
GITHUB_CLIENT_SECRET?: string;
GITHUB_OAUTH_CALLBACK_URL?: string;
SESSION_SECRET?: string;
};
type OAuthState = {
state: string;
verifier: string;
returnTo: string;
expiresAt: number;
};
type SessionUser = {
login: string;
avatarUrl: string;
profileUrl: string;
expiresAt: number;
};
const encoder = new TextEncoder();
const STATE_COOKIE = "dbx_oauth_state";
const SESSION_COOKIE = "dbx_contributor_session";
function base64UrlEncode(bytes: Uint8Array): string {
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
}
function base64UrlDecode(value: string): Uint8Array {
const base64 = value.replaceAll("-", "+").replaceAll("_", "/").padEnd(Math.ceil(value.length / 4) * 4, "=");
const binary = atob(base64);
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
}
function asArrayBuffer(bytes: Uint8Array): ArrayBuffer {
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
}
async function hmacKey(secret: string) {
return crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign", "verify"]);
}
export async function signPayload(payload: object, secret: string): Promise<string> {
const encodedPayload = base64UrlEncode(encoder.encode(JSON.stringify(payload)));
const signature = await crypto.subtle.sign("HMAC", await hmacKey(secret), encoder.encode(encodedPayload));
return `${encodedPayload}.${base64UrlEncode(new Uint8Array(signature))}`;
}
export async function verifySignedPayload<T>(value: string | undefined, secret: string): Promise<T | null> {
if (!value) return null;
const [payload, signature] = value.split(".");
if (!payload || !signature) return null;
const valid = await crypto.subtle.verify("HMAC", await hmacKey(secret), asArrayBuffer(base64UrlDecode(signature)), encoder.encode(payload));
if (!valid) return null;
try {
return JSON.parse(new TextDecoder().decode(base64UrlDecode(payload))) as T;
} catch {
return null;
}
}
export function sanitizeReturnTo(value: string | null): string {
if (!value || !value.startsWith("/") || value.startsWith("//")) return "/en/contributors";
return value;
}
function parseCookies(request: Request): Record<string, string> {
return Object.fromEntries(
(request.headers.get("Cookie") ?? "")
.split(";")
.map((part) => part.trim())
.filter(Boolean)
.map((part) => {
const separator = part.indexOf("=");
return separator < 0 ? [part, ""] : [part.slice(0, separator), decodeURIComponent(part.slice(separator + 1))];
}),
);
}
function cookie(name: string, value: string, maxAge: number, path = "/"): string {
return `${name}=${encodeURIComponent(value)}; Max-Age=${maxAge}; Path=${path}; HttpOnly; Secure; SameSite=Lax`;
}
function randomToken(size = 32): string {
const bytes = new Uint8Array(size);
crypto.getRandomValues(bytes);
return base64UrlEncode(bytes);
}
async function codeChallenge(verifier: string): Promise<string> {
return base64UrlEncode(new Uint8Array(await crypto.subtle.digest("SHA-256", encoder.encode(verifier))));
}
function json(data: unknown, status = 200, headers?: HeadersInit): Response {
return Response.json(data, { status, headers: { "Cache-Control": "no-store", ...headers } });
}
function requiredConfig(env: Env): { clientId: string; clientSecret: string; sessionSecret: string } | null {
if (!env.GITHUB_CLIENT_ID || !env.GITHUB_CLIENT_SECRET || !env.SESSION_SECRET) return null;
return { clientId: env.GITHUB_CLIENT_ID, clientSecret: env.GITHUB_CLIENT_SECRET, sessionSecret: env.SESSION_SECRET };
}
async function startOAuth(request: Request, env: Env): Promise<Response> {
const config = requiredConfig(env);
if (!config) return json({ error: "GitHub OAuth is not configured" }, 503);
const url = new URL(request.url);
const state = randomToken();
const verifier = randomToken(48);
const returnTo = sanitizeReturnTo(url.searchParams.get("returnTo"));
const callbackUrl = env.GITHUB_OAUTH_CALLBACK_URL || `${url.origin}/api/auth/github/callback`;
const stateCookie = await signPayload({ state, verifier, returnTo, expiresAt: Date.now() + 10 * 60 * 1000 } satisfies OAuthState, config.sessionSecret);
const authorizeUrl = new URL("https://github.com/login/oauth/authorize");
authorizeUrl.searchParams.set("client_id", config.clientId);
authorizeUrl.searchParams.set("redirect_uri", callbackUrl);
authorizeUrl.searchParams.set("state", state);
authorizeUrl.searchParams.set("code_challenge", await codeChallenge(verifier));
authorizeUrl.searchParams.set("code_challenge_method", "S256");
return new Response(null, {
status: 302,
headers: {
Location: authorizeUrl.toString(),
"Set-Cookie": cookie(STATE_COOKIE, stateCookie, 10 * 60, "/api/auth/github"),
"Cache-Control": "no-store",
},
});
}
async function finishOAuth(request: Request, env: Env): Promise<Response> {
const config = requiredConfig(env);
if (!config) return json({ error: "GitHub OAuth is not configured" }, 503);
const url = new URL(request.url);
const storedState = await verifySignedPayload<OAuthState>(parseCookies(request)[STATE_COOKIE], config.sessionSecret);
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
if (!storedState || storedState.expiresAt < Date.now() || !code || state !== storedState.state) return json({ error: "Invalid or expired OAuth state" }, 400);
const callbackUrl = env.GITHUB_OAUTH_CALLBACK_URL || `${url.origin}/api/auth/github/callback`;
const tokenResponse = await fetch("https://github.com/login/oauth/access_token", {
method: "POST",
headers: { Accept: "application/json", "Content-Type": "application/json", "User-Agent": "dbx-contributors" },
body: JSON.stringify({ client_id: config.clientId, client_secret: config.clientSecret, code, redirect_uri: callbackUrl, code_verifier: storedState.verifier }),
});
const tokenData = (await tokenResponse.json()) as { access_token?: string; error?: string };
if (!tokenResponse.ok || !tokenData.access_token) return json({ error: tokenData.error || "GitHub token exchange failed" }, 502);
const userResponse = await fetch("https://api.github.com/user", {
headers: { Accept: "application/vnd.github+json", Authorization: `Bearer ${tokenData.access_token}`, "User-Agent": "dbx-contributors", "X-GitHub-Api-Version": "2022-11-28" },
});
const githubUser = (await userResponse.json()) as { login?: string; avatar_url?: string; html_url?: string };
if (!userResponse.ok || !githubUser.login) return json({ error: "Unable to read GitHub identity" }, 502);
// The access token is intentionally discarded after reading the public identity.
const session = await signPayload(
{
login: githubUser.login,
avatarUrl: githubUser.avatar_url || `https://github.com/${githubUser.login}.png`,
profileUrl: githubUser.html_url || `https://github.com/${githubUser.login}`,
expiresAt: Date.now() + 7 * 24 * 60 * 60 * 1000,
} satisfies SessionUser,
config.sessionSecret,
);
const headers = new Headers({ Location: storedState.returnTo, "Cache-Control": "no-store" });
headers.append("Set-Cookie", cookie(STATE_COOKIE, "", 0, "/api/auth/github"));
headers.append("Set-Cookie", cookie(SESSION_COOKIE, session, 7 * 24 * 60 * 60));
return new Response(null, { status: 302, headers });
}
async function currentUser(request: Request, env: Env): Promise<Response> {
const config = requiredConfig(env);
if (!config) return json({ authenticated: false, configured: false });
const session = await verifySignedPayload<SessionUser>(parseCookies(request)[SESSION_COOKIE], config.sessionSecret);
if (!session || session.expiresAt < Date.now()) return json({ authenticated: false, configured: true });
return json({ authenticated: true, configured: true, user: { login: session.login, avatarUrl: session.avatarUrl, profileUrl: session.profileUrl } });
}
function logout(): Response {
return json({ authenticated: false }, 200, { "Set-Cookie": cookie(SESSION_COOKIE, "", 0) });
}
async function contributorAvatar(request: Request): Promise<Response> {
const login = new URL(request.url).searchParams.get("login") ?? "";
if (!/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/.test(login)) return json({ error: "Invalid GitHub login" }, 400);
const response = await fetch(`https://github.com/${login}.png?size=256`, { redirect: "follow" });
if (!response.ok || !response.body) return json({ error: "Avatar unavailable" }, 404);
return new Response(response.body, {
headers: {
"Content-Type": response.headers.get("Content-Type") || "image/png",
"Cache-Control": "public, max-age=86400, s-maxage=604800",
"Cross-Origin-Resource-Policy": "same-origin",
},
});
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
if (url.pathname === "/api/auth/github/start" && request.method === "GET") return startOAuth(request, env);
if (url.pathname === "/api/auth/github/callback" && request.method === "GET") return finishOAuth(request, env);
if (url.pathname === "/api/auth/me" && request.method === "GET") return currentUser(request, env);
if (url.pathname === "/api/auth/logout" && request.method === "POST") return logout();
if (url.pathname === "/api/contributor-avatar" && request.method === "GET") return contributorAvatar(request);
return env.ASSETS.fetch(request);
},
};
+3 -1
View File
@@ -1,7 +1,9 @@
{
"name": "dbx",
"compatibility_date": "2026-05-07",
"main": "./worker.ts",
"assets": {
"directory": "./out"
"directory": "./out",
"binding": "ASSETS"
}
}