mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-21 06:00:39 +00:00
feat(docs): add contributor certificates
This commit is contained in:
@@ -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
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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");
|
||||
});
|
||||
@@ -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" });
|
||||
});
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
@@ -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
@@ -1,7 +1,9 @@
|
||||
{
|
||||
"name": "dbx",
|
||||
"compatibility_date": "2026-05-07",
|
||||
"main": "./worker.ts",
|
||||
"assets": {
|
||||
"directory": "./out"
|
||||
"directory": "./out",
|
||||
"binding": "ASSETS"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user