feat(docs): add anonymous GitHub issue submission

This commit is contained in:
t8y2
2026-08-14 17:55:25 +08:00
parent ebad74f81e
commit b855923a21
11 changed files with 1992 additions and 6 deletions
+29 -1
View File
@@ -1,4 +1,6 @@
# Contributor certificate OAuth setup
# Website Worker setup
## Contributor certificate OAuth
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.
@@ -18,3 +20,29 @@ 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.
## Anonymous Issue submission
The `/issue` and `/issues` routes redirect to the localized anonymous feedback page. Creating a draft consumes one allowance from both a per-IP and a temporary-session rolling limit of 8 attempts per hour. Final submission does not consume another allowance. Cloudflare Turnstile is intentionally not used.
Create a GitHub App installed only on `t8y2/dbx` with repository permission `Issues: Read and write`, then configure:
```bash
pnpm dlx wrangler secret put GITHUB_APP_ID
pnpm dlx wrangler secret put GITHUB_APP_PRIVATE_KEY_B64
pnpm dlx wrangler secret put ISSUE_RATE_LIMIT_SECRET
```
`GITHUB_APP_PRIVATE_KEY_B64` accepts the base64-encoded PEM downloaded from the GitHub App. Generate a single-line value with `base64 < private-key.pem | tr -d '\n'`. `ISSUE_RATE_LIMIT_SECRET` should be an independent random value of at least 32 bytes. `SESSION_SECRET` is used as a fallback only to avoid breaking an already deployed Worker.
Configure an OpenAI-compatible multimodal chat-completions provider:
```bash
pnpm dlx wrangler secret put ISSUE_AI_API_BASE
pnpm dlx wrangler secret put ISSUE_AI_API_KEY
pnpm dlx wrangler secret put ISSUE_AI_MODEL
```
`ISSUE_AI_API_BASE` may end at the host, `/v1`, or `/chat/completions`. The model must accept `image_url` data URLs when screenshots are attached.
`docs/wrangler.json` binds `ISSUE_IMAGES` to the existing `dbx` R2 bucket and publishes generated image URLs under `https://dl.dbxio.com` by default. Set `ISSUE_IMAGE_PUBLIC_BASE_URL` if the bucket's public origin changes. Set `ISSUE_GITHUB_REPOSITORY` only when testing against a different repository; production defaults to `t8y2/dbx`.
+40
View File
@@ -0,0 +1,40 @@
import type { Metadata } from "next";
import { LandingFooter } from "@/components/landing/LandingFooter";
import { LandingNav } from "@/components/landing/LandingNav";
import { IssueSubmissionClient } from "@/components/issues/IssueSubmissionClient";
import { buildMetadata } from "@/lib/metadata";
const metadata = {
en: {
title: "Submit an Issue",
description: "Describe a DBX problem or suggestion without a GitHub account. Review the AI-polished draft before publishing.",
},
cn: {
title: "匿名提交 Issue",
description: "无需 GitHub 账号,简短描述 DBX 问题或建议,由 AI 整理并在你确认后公开提交。",
},
};
export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }): Promise<Metadata> {
const { lang } = await params;
const locale = lang === "cn" ? "cn" : "en";
return buildMetadata({
title: metadata[locale].title,
description: metadata[locale].description,
path: `/${locale}/issue`,
lang: locale,
});
}
export default async function IssuePage({ params }: { params: Promise<{ lang: string }> }) {
const { lang } = await params;
const locale = lang === "cn" ? "cn" : "en";
return (
<main className="issue-page min-h-screen text-landing-ink">
<LandingNav lang={locale} active="issue" />
<IssueSubmissionClient lang={locale} />
<LandingFooter lang={locale} />
</main>
);
}
+526
View File
@@ -1300,6 +1300,524 @@ a {
}
}
.issue-page {
--issue-ink: #f4f7fb;
--issue-muted: #9aa9bc;
--issue-line: rgba(155, 176, 205, 0.18);
--issue-panel: rgba(17, 21, 29, 0.84);
min-height: 100vh;
background: #0b1120;
}
.issue-page::before,
.issue-page::after {
display: none;
}
.issue-shell {
width: min(860px, calc(100% - 48px));
min-height: calc(100vh - 80px);
margin: 0 auto;
padding: 96px 0 88px;
}
.issue-success-card h1 {
color: var(--issue-ink);
font-size: 2rem;
font-weight: 800;
letter-spacing: -0.035em;
line-height: 1.1;
}
.issue-layout,
.issue-workbench {
display: grid;
gap: 16px;
}
.issue-public-note,
.issue-panel,
.issue-success-card {
border: 1px solid var(--issue-line);
border-radius: 12px;
background: var(--issue-panel);
}
.issue-public-note {
padding: 13px 15px;
}
.issue-warning-heading {
display: flex;
align-items: center;
gap: 8px;
color: #dce5ed;
font-size: 12px;
}
.issue-warning-heading svg {
color: #e8b85c;
}
.issue-public-note p {
margin-top: 6px;
color: var(--issue-muted);
font-size: 11px;
line-height: 1.5;
}
.issue-panel {
padding: 24px;
}
.issue-preview-panel {
animation: landing-rise 0.25s ease-out both;
}
.issue-panel-heading {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 22px;
}
.issue-step-number {
display: grid;
width: 28px;
height: 28px;
place-items: center;
border: 1px solid rgba(110, 168, 255, 0.3);
border-radius: 50%;
background: rgba(110, 168, 255, 0.08);
color: #8eb9ff;
font-size: 12px;
font-weight: 700;
}
.issue-panel-heading h1,
.issue-panel-heading h2 {
color: var(--issue-ink);
font-size: 17px;
font-weight: 720;
letter-spacing: -0.015em;
}
.issue-field {
position: relative;
display: grid;
gap: 7px;
}
.issue-field > span,
.issue-upload-block strong {
color: #dce5ed;
font-size: 13px;
font-weight: 650;
}
.issue-field small,
.issue-upload-block small {
display: block;
color: var(--issue-muted);
font-size: 12px;
line-height: 1.55;
}
.issue-field textarea,
.issue-field input,
.issue-field select {
width: 100%;
border: 1px solid rgba(155, 176, 205, 0.22);
border-radius: 9px;
outline: none;
background: rgba(6, 12, 20, 0.72);
color: var(--issue-ink);
font-size: 14px;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.issue-field textarea {
min-height: 150px;
resize: vertical;
padding: 13px 14px 32px;
line-height: 1.7;
}
.issue-field input,
.issue-field select {
height: 42px;
padding: 0 12px;
}
.issue-field select {
cursor: pointer;
color-scheme: dark;
}
.issue-field textarea::placeholder,
.issue-field input::placeholder {
color: #637387;
}
.issue-field textarea:focus,
.issue-field input:focus,
.issue-field select:focus {
border-color: rgba(110, 168, 255, 0.72);
box-shadow: 0 0 0 3px rgba(110, 168, 255, 0.1);
}
.issue-field textarea:disabled,
.issue-field input:disabled,
.issue-field select:disabled {
cursor: not-allowed;
opacity: 0.68;
}
.issue-field em {
position: absolute;
right: 12px;
bottom: 9px;
color: #66778a;
font-size: 10px;
font-style: normal;
}
.issue-upload-block {
display: grid;
gap: 12px;
margin-top: 20px;
border: 1px dashed var(--issue-line);
border-radius: 9px;
padding: 16px;
transition: border-color 0.15s ease, background 0.15s ease;
}
.issue-upload-block.is-dragging {
border-color: rgba(110, 168, 255, 0.72);
background: rgba(110, 168, 255, 0.07);
}
.issue-upload-button {
display: inline-flex;
width: fit-content;
min-height: 38px;
cursor: pointer;
align-items: center;
gap: 7px;
border: 1px solid var(--issue-line);
border-radius: 8px;
padding: 0 13px;
background: rgba(255, 255, 255, 0.035);
color: #d6e1ea;
font-size: 12px;
font-weight: 620;
transition: border-color 0.15s ease, background 0.15s ease;
}
.issue-upload-button:hover {
border-color: rgba(110, 168, 255, 0.48);
background: rgba(110, 168, 255, 0.07);
}
.issue-upload-button input {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
}
.issue-image-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.issue-image-preview {
position: relative;
aspect-ratio: 16 / 10;
border: 1px solid var(--issue-line);
border-radius: 8px;
background: #060b12;
overflow: hidden;
}
.issue-image-preview img {
width: 100%;
height: 100%;
object-fit: cover;
}
.issue-image-preview button {
position: absolute;
top: 6px;
right: 6px;
display: grid;
width: 27px;
height: 27px;
cursor: pointer;
place-items: center;
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 7px;
background: rgba(4, 8, 14, 0.82);
color: white;
}
.issue-image-preview span {
position: absolute;
right: 6px;
bottom: 6px;
border-radius: 4px;
padding: 2px 5px;
background: rgba(4, 8, 14, 0.78);
color: #c4cfda;
font-size: 9px;
}
.issue-action-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
margin-top: 22px;
}
.issue-primary-button,
.issue-secondary-button,
.issue-submit-button {
display: inline-flex;
min-height: 42px;
cursor: pointer;
align-items: center;
justify-content: center;
gap: 8px;
border-radius: 8px;
padding: 0 16px;
font-size: 13px;
font-weight: 680;
transition: background 0.15s ease, border-color 0.15s ease, opacity 0.15s ease;
}
.issue-primary-button,
.issue-submit-button {
border: 1px solid #f2f5f8;
background: #f2f5f8;
color: #0b1120;
}
.issue-primary-button:hover:not(:disabled),
.issue-submit-button:hover:not(:disabled) {
border-color: white;
background: white;
}
.issue-secondary-button {
border: 1px solid var(--issue-line);
background: transparent;
color: #d6e0e8;
}
.issue-secondary-button:hover:not(:disabled) {
border-color: rgba(155, 176, 205, 0.38);
background: rgba(255, 255, 255, 0.035);
}
.issue-primary-button:disabled,
.issue-secondary-button:disabled,
.issue-submit-button:disabled {
cursor: not-allowed;
opacity: 0.42;
}
.issue-ai-summary {
display: flex;
gap: 9px;
margin-bottom: 20px;
border-radius: 8px;
padding: 11px 12px;
background: rgba(110, 168, 255, 0.07);
color: #b8c8d6;
font-size: 12px;
line-height: 1.65;
}
.issue-ai-summary svg {
margin-top: 2px;
flex: none;
color: #8eb9ff;
}
.issue-preview-grid {
display: grid;
grid-template-columns: 170px minmax(0, 1fr);
gap: 12px;
margin-bottom: 18px;
}
.issue-body-editor {
min-height: 360px !important;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12.5px !important;
}
.issue-confirm-row {
display: grid;
grid-template-columns: 20px 1fr;
column-gap: 10px;
align-items: start;
cursor: pointer;
margin-top: 18px;
border: 1px solid var(--issue-line);
border-radius: 9px;
padding: 13px;
background: rgba(255, 255, 255, 0.025);
}
.issue-confirm-row input {
position: absolute;
opacity: 0;
}
.issue-confirm-row > span {
display: grid;
width: 20px;
height: 20px;
place-items: center;
border: 1px solid rgba(155, 176, 205, 0.4);
border-radius: 5px;
color: transparent;
}
.issue-confirm-row input:focus-visible + span {
outline: 2px solid #8eb9ff;
outline-offset: 2px;
}
.issue-confirm-row input:checked + span {
border-color: #75d6a5;
background: #75d6a5;
color: #07110c;
}
.issue-confirm-row p {
grid-column: 2;
color: #b9c6d2;
font-size: 12px;
line-height: 1.6;
}
.issue-submit-button {
width: 100%;
margin-top: 12px;
}
.issue-error {
display: flex;
align-items: flex-start;
gap: 9px;
border: 1px solid rgba(251, 113, 133, 0.3);
border-radius: 9px;
padding: 12px 14px;
background: rgba(159, 18, 57, 0.12);
color: #fecdd3;
font-size: 12px;
line-height: 1.6;
}
.issue-error svg {
margin-top: 1px;
flex: none;
color: #fb7185;
}
.issue-spinner {
animation: issue-spin 0.8s linear infinite;
}
.issue-success-shell {
display: grid;
place-items: center;
}
.issue-success-card {
width: min(620px, 100%);
padding: 40px;
text-align: center;
}
.issue-success-card h1 {
margin-top: 18px;
}
.issue-success-card > p {
max-width: 500px;
margin: 14px auto 0;
color: var(--issue-muted);
line-height: 1.7;
}
.issue-success-icon {
display: grid;
width: 54px;
height: 54px;
place-items: center;
margin: 0 auto;
border-radius: 50%;
background: rgba(117, 214, 165, 0.1);
color: #75d6a5;
}
.issue-success-actions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 10px;
margin-top: 26px;
}
@media (max-width: 640px) {
.issue-shell {
width: min(100% - 28px, 860px);
padding: 84px 0 72px;
}
.issue-panel,
.issue-public-note {
padding: 18px;
}
.issue-preview-grid {
grid-template-columns: 1fr;
}
.issue-image-grid {
grid-template-columns: 1fr 1fr;
}
.issue-action-row,
.issue-primary-button {
width: 100%;
}
.issue-action-row {
align-items: stretch;
}
.issue-success-card {
padding: 28px 20px;
}
.issue-success-actions,
.issue-success-actions a,
.issue-success-actions button {
width: 100%;
}
}
@keyframes issue-spin {
to {
transform: rotate(360deg);
}
}
/* Keyframes */
@keyframes landing-rise {
@@ -1376,4 +1894,12 @@ a {
opacity: 1 !important;
transform: none !important;
}
.issue-page *,
.issue-page *::before,
.issue-page *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
@@ -0,0 +1,436 @@
"use client";
import { ArrowUpRight, Check, CheckCircle2, ImagePlus, LoaderCircle, RotateCcw, ShieldAlert, Sparkles, X } from "lucide-react";
import { useEffect, useRef, useState, type ClipboardEvent, type DragEvent } from "react";
type IssueType = "bug" | "feature" | "question" | "compatibility";
type SelectedImage = {
file: File;
url: string;
};
type DraftResponse = {
draftId: string;
expiresAt: number;
preview: {
type: IssueType;
title: string;
summary: string;
body: string;
};
rateLimit: {
remaining: number;
resetAt: number;
};
};
type SubmitResponse = {
issueNumber: number;
issueUrl: string;
};
const copy = {
cn: {
draftStep: "问题描述",
previewStep: "Issue 预览",
descriptionLabel: "发生了什么?",
descriptionHint: "建议包含:你做了什么、看到了什么、期望怎样。版本和环境不知道也没关系,不要猜。",
descriptionPlaceholder: "例如:macOS 上连接 PostgreSQL 后,展开 schema 一直转圈,没有错误提示。重启和重新连接都试过了。",
attachments: "截图(可选)",
attachmentHint: "点击添加、拖入或直接粘贴截图。支持 PNG、JPEG 和 WebP,最多 3 张,单张不超过 5 MB。",
addImages: "添加截图",
generate: "整理为 Issue 草稿",
generateAgain: "重新整理",
generating: "正在整理",
type: "类型",
titleLabel: "标题",
bodyLabel: "GitHub 正文",
bodyHint: "可以自由修改。最终提交时会在末尾追加图片和匿名来源说明。",
summary: "整理摘要",
confirm: "我已检查标题、正文和截图,并理解这些内容将公开显示在 GitHub。",
submit: "确认并公开创建 Issue",
submitting: "正在创建 Issue",
publicWarning: "提交前请确认",
publicNotice: "请勿上传密码、Token、连接串、未脱敏 IP、客户或隐私数据;提交后 Issue 和图片将公开可见。",
successTitle: "Issue 已创建",
successText: (number: number) => `GitHub Issue #${number} 已创建。后续进展会公开记录在该页面。`,
openIssue: "查看 GitHub Issue",
another: "再提交一个",
removeImage: "移除图片",
errors: {
DESCRIPTION_REQUIRED: "请先填写问题描述。",
DESCRIPTION_TOO_SHORT: "描述至少需要 4 个字符。",
DESCRIPTION_TOO_LONG: "描述过长,请控制在 6000 字符以内。",
TOO_MANY_IMAGES: "最多只能上传 3 张图片。",
IMAGE_TOO_LARGE: "单张图片不能超过 5 MB。",
IMAGES_TOO_LARGE: "图片总大小不能超过 12 MB。",
IMAGE_TYPE_UNSUPPORTED: "仅支持真实的 PNG、JPEG 或 WebP 图片。",
RATE_LIMITED: "生成草稿过于频繁,请稍后再试。",
AI_NOT_CONFIGURED: "AI 草稿服务尚未配置,请联系维护者。",
AI_REQUEST_FAILED: "AI 服务暂时不可用,请稍后重试。",
AI_RESPONSE_INVALID: "AI 返回的草稿格式异常,请重新生成。",
DRAFT_EXPIRED: "草稿已过期,请重新生成。",
DRAFT_SUBMITTING: "这个草稿正在提交,请不要重复点击。",
DRAFT_IMAGES_CHANGED: "草稿生成后截图发生变化,请重新生成草稿。",
ISSUE_SESSION_EXPIRED: "临时会话已过期,请重新生成草稿。",
GITHUB_ISSUE_CREATE_FAILED: "GitHub 暂时未能创建 Issue,请稍后重试。",
ISSUE_IMAGE_UPLOAD_FAILED: "截图上传失败,请稍后重试。",
ISSUE_REQUEST_FAILED: "提交失败,请稍后重试。",
},
genericError: "请求失败,请稍后重试。",
clientImageError: "仅支持 PNG、JPEG 或 WebP,最多 3 张,单张不超过 5 MB。",
retryAfter: (minutes: number) => `大约 ${minutes} 分钟后可再次生成。`,
},
en: {
draftStep: "Describe the issue",
previewStep: "Issue preview",
descriptionLabel: "What happened?",
descriptionHint: "Include what you did, what you saw, and what you expected. If you do not know a version or environment detail, do not guess.",
descriptionPlaceholder: "Example: On macOS, expanding a PostgreSQL schema keeps loading without an error. Restarting and reconnecting did not help.",
attachments: "Screenshots (optional)",
attachmentHint: "Choose, drag, or paste screenshots here. PNG, JPEG, or WebP; up to 3 images and 5 MB each.",
addImages: "Add screenshots",
generate: "Prepare Issue draft",
generateAgain: "Prepare again",
generating: "Preparing draft",
type: "Type",
titleLabel: "Title",
bodyLabel: "GitHub body",
bodyHint: "Fully editable. Images and an anonymous-source note are appended when submitted.",
summary: "Draft summary",
confirm: "I reviewed the title, body, and screenshots and understand that they will be publicly visible on GitHub.",
submit: "Confirm and create public Issue",
submitting: "Creating Issue",
publicWarning: "Before submitting",
publicNotice: "Do not upload passwords, tokens, connection strings, unredacted IPs, customer data, or personal data. The Issue and images will be public.",
successTitle: "Issue created",
successText: (number: number) => `GitHub Issue #${number} was created. Follow-up progress will be recorded on that public page.`,
openIssue: "Open GitHub Issue",
another: "Submit another",
removeImage: "Remove image",
errors: {
DESCRIPTION_REQUIRED: "Describe the problem first.",
DESCRIPTION_TOO_SHORT: "The description must contain at least 4 characters.",
DESCRIPTION_TOO_LONG: "Keep the description under 6,000 characters.",
TOO_MANY_IMAGES: "You can upload up to 3 images.",
IMAGE_TOO_LARGE: "Each image must be 5 MB or smaller.",
IMAGES_TOO_LARGE: "Images must be 12 MB or smaller in total.",
IMAGE_TYPE_UNSUPPORTED: "Only real PNG, JPEG, and WebP images are supported.",
RATE_LIMITED: "Too many draft attempts. Please try again later.",
AI_NOT_CONFIGURED: "The AI drafting service has not been configured yet.",
AI_REQUEST_FAILED: "The AI service is temporarily unavailable. Try again later.",
AI_RESPONSE_INVALID: "The AI returned an invalid draft. Generate it again.",
DRAFT_EXPIRED: "This draft expired. Generate a new one.",
DRAFT_SUBMITTING: "This draft is already being submitted.",
DRAFT_IMAGES_CHANGED: "The screenshots changed after drafting. Generate the draft again.",
ISSUE_SESSION_EXPIRED: "The temporary session expired. Generate the draft again.",
GITHUB_ISSUE_CREATE_FAILED: "GitHub could not create the Issue. Try again later.",
ISSUE_IMAGE_UPLOAD_FAILED: "The screenshots could not be uploaded. Try again later.",
ISSUE_REQUEST_FAILED: "Submission failed. Try again later.",
},
genericError: "The request failed. Try again later.",
clientImageError: "Use up to 3 PNG, JPEG, or WebP images, no more than 5 MB each.",
retryAfter: (minutes: number) => `Try again in about ${minutes} minutes.`,
},
};
const issuePrefixes: Record<IssueType, string> = {
bug: "[Bug]",
feature: "[Feature]",
question: "[Question]",
compatibility: "[Compatibility]",
};
function responseErrorMessage(lang: "cn" | "en", code: string, retryAfter?: number): string {
const t = copy[lang];
const base = t.errors[code as keyof typeof t.errors] ?? t.genericError;
if (code !== "RATE_LIMITED" || !retryAfter) return base;
return `${base} ${t.retryAfter(Math.max(1, Math.ceil(retryAfter / 60)))}`;
}
export function IssueSubmissionClient({ lang }: { lang: "cn" | "en" }) {
const t = copy[lang];
const [description, setDescription] = useState("");
const [images, setImages] = useState<SelectedImage[]>([]);
const [draftId, setDraftId] = useState("");
const [draftExpiresAt, setDraftExpiresAt] = useState(0);
const [issueType, setIssueType] = useState<IssueType>("bug");
const [title, setTitle] = useState("");
const [body, setBody] = useState("");
const [summary, setSummary] = useState("");
const [confirmed, setConfirmed] = useState(false);
const [loading, setLoading] = useState<"draft" | "submit" | null>(null);
const [draggingImages, setDraggingImages] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState<SubmitResponse | null>(null);
const imagesRef = useRef<SelectedImage[]>([]);
useEffect(() => {
imagesRef.current = images;
}, [images]);
useEffect(() => {
return () => imagesRef.current.forEach((image) => URL.revokeObjectURL(image.url));
}, []);
function clearDraft() {
setDraftId("");
setDraftExpiresAt(0);
setTitle("");
setBody("");
setSummary("");
setConfirmed(false);
setSuccess(null);
}
function changeDescription(value: string) {
setDescription(value);
if (draftId) clearDraft();
setError("");
}
function addImages(files: FileList | File[] | null) {
if (!files) return;
const candidates = Array.from(files);
if (candidates.length === 0) return;
const valid = candidates.every((file) => ["image/png", "image/jpeg", "image/webp"].includes(file.type) && file.size <= 5 * 1024 * 1024);
const totalBytes = [...images.map((image) => image.file), ...candidates].reduce((total, file) => total + file.size, 0);
if (!valid || images.length + candidates.length > 3 || totalBytes > 12 * 1024 * 1024) {
setError(t.clientImageError);
return;
}
if (draftId) clearDraft();
setImages((current) => [...current, ...candidates.map((file) => ({ file, url: URL.createObjectURL(file) }))]);
setError("");
}
function pasteImages(event: ClipboardEvent<HTMLDivElement>) {
const pastedImages = Array.from(event.clipboardData.items)
.filter((item) => item.kind === "file" && item.type.startsWith("image/"))
.map((item) => item.getAsFile())
.filter((file): file is File => file !== null);
if (pastedImages.length === 0) return;
event.preventDefault();
addImages(pastedImages);
}
function dropImages(event: DragEvent<HTMLDivElement>) {
event.preventDefault();
setDraggingImages(false);
addImages(event.dataTransfer.files);
}
function removeImage(index: number) {
if (draftId) clearDraft();
setImages((current) => {
const target = current[index];
if (target) URL.revokeObjectURL(target.url);
return current.filter((_, currentIndex) => currentIndex !== index);
});
}
function changeIssueType(nextType: IssueType) {
const withoutPrefix = title.replace(/^\[(?:Bug|Feature|Question|Compatibility)]\s*/i, "").trim();
setIssueType(nextType);
setTitle(`${issuePrefixes[nextType]} ${withoutPrefix}`.trim());
setConfirmed(false);
}
async function parseResponse<T>(response: Response): Promise<T> {
let data: Record<string, unknown> = {};
try {
data = (await response.json()) as Record<string, unknown>;
} catch {
data = {};
}
if (!response.ok) {
const code = typeof data.error === "string" ? data.error : "ISSUE_REQUEST_FAILED";
const retryAfter = typeof data.retryAfter === "number" ? data.retryAfter : undefined;
throw new Error(responseErrorMessage(lang, code, retryAfter));
}
return data as T;
}
async function generateDraft() {
setLoading("draft");
setError("");
setSuccess(null);
const form = new FormData();
form.set("description", description);
form.set("language", lang);
images.forEach((image) => form.append("images", image.file));
try {
const response = await fetch("/api/issues/draft", { method: "POST", body: form });
const data = await parseResponse<DraftResponse>(response);
setDraftId(data.draftId);
setDraftExpiresAt(data.expiresAt);
setIssueType(data.preview.type);
setTitle(data.preview.title);
setBody(data.preview.body);
setSummary(data.preview.summary);
setConfirmed(false);
} catch (requestError) {
setError(requestError instanceof Error ? requestError.message : t.genericError);
} finally {
setLoading(null);
}
}
async function submitIssue() {
if (!draftId || !confirmed) return;
if (draftExpiresAt <= Date.now()) {
setError(responseErrorMessage(lang, "DRAFT_EXPIRED"));
return;
}
setLoading("submit");
setError("");
const form = new FormData();
form.set("draftId", draftId);
form.set("type", issueType);
form.set("title", title);
form.set("body", body);
images.forEach((image) => form.append("images", image.file));
try {
const response = await fetch("/api/issues/submit", { method: "POST", body: form });
setSuccess(await parseResponse<SubmitResponse>(response));
} catch (requestError) {
setError(requestError instanceof Error ? requestError.message : t.genericError);
} finally {
setLoading(null);
}
}
function resetForm() {
images.forEach((image) => URL.revokeObjectURL(image.url));
setDescription("");
setImages([]);
clearDraft();
setError("");
}
if (success) {
return (
<section className="issue-shell issue-success-shell">
<div className="issue-success-card">
<span className="issue-success-icon"><CheckCircle2 size={30} /></span>
<h1>{t.successTitle}</h1>
<p>{t.successText(success.issueNumber)}</p>
<div className="issue-success-actions">
<a href={success.issueUrl} target="_blank" rel="noopener noreferrer" className="issue-primary-button">
{t.openIssue}<ArrowUpRight size={17} />
</a>
<button type="button" className="issue-secondary-button" onClick={resetForm}>
<RotateCcw size={16} />{t.another}
</button>
</div>
</div>
</section>
);
}
return (
<section className="issue-shell">
<div className="issue-layout">
<div className="issue-workbench" onPaste={pasteImages}>
<section className="issue-public-note">
<div className="issue-warning-heading"><ShieldAlert size={15} /><strong>{t.publicWarning}</strong></div>
<p>{t.publicNotice}</p>
</section>
<section className="issue-panel">
<div className="issue-panel-heading">
<span className="issue-step-number">1</span>
<h1>{t.draftStep}</h1>
</div>
<label className="issue-field">
<span>{t.descriptionLabel}</span>
<small>{t.descriptionHint}</small>
<textarea value={description} onChange={(event) => changeDescription(event.target.value)} placeholder={t.descriptionPlaceholder} maxLength={6000} rows={7} disabled={loading !== null} />
<em>{description.length} / 6000</em>
</label>
<div
className={`issue-upload-block${draggingImages ? " is-dragging" : ""}`}
onDragEnter={(event) => { event.preventDefault(); setDraggingImages(true); }}
onDragOver={(event) => { event.preventDefault(); event.dataTransfer.dropEffect = "copy"; setDraggingImages(true); }}
onDragLeave={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setDraggingImages(false);
}}
onDrop={dropImages}
>
<div><strong>{t.attachments}</strong><small>{t.attachmentHint}</small></div>
{images.length > 0 && (
<div className="issue-image-grid">
{images.map((image, index) => (
<div className="issue-image-preview" key={`${image.file.name}-${image.file.lastModified}-${index}`}>
<img src={image.url} alt={image.file.name} />
<button type="button" onClick={() => removeImage(index)} aria-label={t.removeImage} disabled={loading !== null}><X size={15} /></button>
<span>{Math.max(1, Math.round(image.file.size / 1024))} KB</span>
</div>
))}
</div>
)}
{images.length < 3 && (
<label className="issue-upload-button">
<ImagePlus size={17} />{t.addImages}
<input type="file" accept="image/png,image/jpeg,image/webp" multiple onChange={(event) => { addImages(event.target.files); event.currentTarget.value = ""; }} disabled={loading !== null} />
</label>
)}
</div>
<div className="issue-action-row">
<button type="button" className="issue-primary-button" onClick={generateDraft} disabled={loading !== null || description.trim().length < 4}>
{loading === "draft" ? <LoaderCircle className="issue-spinner" size={18} /> : <Sparkles size={18} />}
{loading === "draft" ? t.generating : draftId ? t.generateAgain : t.generate}
</button>
</div>
</section>
{draftId && (
<section className="issue-panel issue-preview-panel">
<div className="issue-panel-heading">
<span className="issue-step-number">2</span>
<h2>{t.previewStep}</h2>
</div>
<div className="issue-ai-summary"><Sparkles size={16} /><p>{summary}</p></div>
<div className="issue-preview-grid">
<label className="issue-field issue-type-field">
<span>{t.type}</span>
<select value={issueType} onChange={(event) => changeIssueType(event.target.value as IssueType)} disabled={loading !== null}>
<option value="bug">Bug</option>
<option value="feature">Feature</option>
<option value="question">Question</option>
<option value="compatibility">Compatibility</option>
</select>
</label>
<label className="issue-field issue-title-field">
<span>{t.titleLabel}</span>
<input value={title} onChange={(event) => { setTitle(event.target.value); setConfirmed(false); }} maxLength={160} disabled={loading !== null} />
</label>
</div>
<label className="issue-field">
<span>{t.bodyLabel}</span>
<small>{t.bodyHint}</small>
<textarea className="issue-body-editor" value={body} onChange={(event) => { setBody(event.target.value); setConfirmed(false); }} maxLength={12000} rows={18} disabled={loading !== null} />
<em>{body.length} / 12000</em>
</label>
<label className="issue-confirm-row">
<input type="checkbox" checked={confirmed} onChange={(event) => setConfirmed(event.target.checked)} disabled={loading !== null} />
<span aria-hidden="true"><Check size={14} /></span>
<p>{t.confirm}</p>
</label>
<button type="button" className="issue-submit-button" onClick={submitIssue} disabled={!confirmed || loading !== null || title.trim().length < 8 || body.trim().length < 20}>
{loading === "submit" ? <LoaderCircle className="issue-spinner" size={18} /> : <ArrowUpRight size={18} />}
{loading === "submit" ? t.submitting : t.submit}
</button>
</section>
)}
{error && <div className="issue-error" role="alert"><ShieldAlert size={18} /><span>{error}</span></div>}
</div>
</div>
</section>
);
}
+5 -1
View File
@@ -10,6 +10,7 @@ const i18n = {
docs: "Docs",
changelog: "Changelog",
community: "Community",
issue: "Feedback",
sponsors: "Sponsors",
contributors: "Contributors",
drivers: "Offline Drivers",
@@ -22,6 +23,7 @@ const i18n = {
docs: "文档",
changelog: "更新日志",
community: "交流群",
issue: "反馈",
sponsors: "赞助商",
contributors: "贡献者",
drivers: "离线驱动",
@@ -31,7 +33,7 @@ const i18n = {
},
};
export function LandingNav({ lang, active }: { lang: "en" | "cn"; active?: "home" | "databases" | "changelog" | "community" | "sponsors" | "contributors" | "drivers" }) {
export function LandingNav({ lang, active }: { lang: "en" | "cn"; active?: "home" | "databases" | "changelog" | "community" | "issue" | "sponsors" | "contributors" | "drivers" }) {
const ref = useRef<HTMLElement>(null);
const [menuOpen, setMenuOpen] = useState(false);
const t = i18n[lang];
@@ -40,6 +42,7 @@ export function LandingNav({ lang, active }: { lang: "en" | "cn"; active?: "home
databases: `/${otherLang}/databases`,
changelog: `/${otherLang}/changelog`,
community: `/${otherLang}/community`,
issue: `/${otherLang}/issue`,
sponsors: `/${otherLang}/sponsors`,
contributors: `/${otherLang}/contributors`,
drivers: `/${otherLang}/drivers`,
@@ -50,6 +53,7 @@ export function LandingNav({ lang, active }: { lang: "en" | "cn"; active?: "home
{ id: "docs", href: `/${lang}/docs/what-is-dbx`, label: t.docs, tabletHidden: false },
{ id: "changelog", href: `/${lang}/changelog`, label: t.changelog, tabletHidden: false },
{ id: "community", href: `/${lang}/community`, label: t.community, tabletHidden: false },
{ id: "issue", href: `/${lang}/issue`, label: t.issue, tabletHidden: false },
{ id: "sponsors", href: `/${lang}/sponsors`, label: t.sponsors, tabletHidden: true },
{ id: "contributors", href: `/${lang}/contributors`, label: t.contributors, tabletHidden: true },
{ id: "drivers", href: `/${lang}/drivers`, label: t.drivers, tabletHidden: false },
+62
View File
@@ -0,0 +1,62 @@
import assert from "node:assert/strict";
import { test } from "vitest";
import {
buildGitHubIssueBody,
consumeRollingLimit,
detectIssueImageType,
normalizeIssueTitle,
parseIssueAiResponse,
validateEditableIssue,
} from "./issueSubmission";
test("rolling limits allow eight attempts and release the oldest after one hour", () => {
let timestamps: number[] = [];
for (let index = 0; index < 8; index += 1) {
const result = consumeRollingLimit(timestamps, index * 1000);
assert.equal(result.allowed, true);
timestamps = result.timestamps;
}
const rejected = consumeRollingLimit(timestamps, 8_000);
assert.equal(rejected.allowed, false);
assert.equal(rejected.remaining, 0);
const released = consumeRollingLimit(timestamps, 60 * 60 * 1000 + 1);
assert.equal(released.allowed, true);
assert.equal(released.remaining, 0);
});
test("AI JSON is extracted, typed, and normalized to the repository title prefix", () => {
const preview = parseIssueAiResponse(`\n\`\`\`json\n{
"type": "compatibility",
"title": "PostgreSQL schema remains loading",
"summary": "Schema loading does not finish.",
"body": "## Description\\n\\nThe schema remains loading after connecting."
}\n\`\`\``);
assert.equal(preview.type, "compatibility");
assert.equal(preview.title, "[Compatibility] PostgreSQL schema remains loading");
assert.match(preview.body, /schema remains loading/);
});
test("editable drafts use server-owned labels and one canonical prefix", () => {
assert.equal(normalizeIssueTitle("[Bug] Connection fails on startup", "feature"), "[Feature] Connection fails on startup");
assert.deepEqual(
validateEditableIssue({ type: "question", title: "How to configure an SSH tunnel", body: "## Goal\n\nConnect to PostgreSQL through an SSH tunnel." }).labels,
["question"],
);
});
test("image validation trusts magic bytes rather than the browser MIME value", () => {
assert.deepEqual(detectIssueImageType(Uint8Array.of(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a)), {
contentType: "image/png",
extension: "png",
});
assert.equal(detectIssueImageType(new TextEncoder().encode("not an image")), null);
});
test("published bodies append public attachments and an anonymous-source marker", () => {
const body = buildGitHubIssueBody("## Description\n\nLoading never finishes.", ["https://dl.dbxio.com/issue/image.png"], "en");
assert.match(body, /## Attachments/);
assert.match(body, /anonymous DBX website form/);
});
+453
View File
@@ -0,0 +1,453 @@
export const ISSUE_RATE_LIMIT = 8;
export const ISSUE_RATE_WINDOW_MS = 60 * 60 * 1000;
export const ISSUE_DRAFT_TTL_MS = 30 * 60 * 1000;
export const ISSUE_CLAIM_TTL_MS = 2 * 60 * 1000;
export const MAX_ISSUE_IMAGES = 3;
export const MAX_ISSUE_IMAGE_BYTES = 5 * 1024 * 1024;
export const MAX_ISSUE_IMAGE_TOTAL_BYTES = 12 * 1024 * 1024;
export const MAX_ISSUE_DESCRIPTION_LENGTH = 6000;
export const MAX_ISSUE_TITLE_LENGTH = 160;
export const MAX_ISSUE_BODY_LENGTH = 12000;
export const ISSUE_TYPES = ["bug", "feature", "question", "compatibility"] as const;
export type IssueType = (typeof ISSUE_TYPES)[number];
export type IssueLanguage = "cn" | "en";
export type IssuePreview = {
type: IssueType;
title: string;
summary: string;
body: string;
};
export type IssueImage = {
bytes: Uint8Array;
contentType: "image/png" | "image/jpeg" | "image/webp";
extension: "png" | "jpg" | "webp";
};
export type IssueAiConfig = {
apiBase?: string;
apiKey?: string;
model?: string;
};
export type GitHubIssueConfig = {
appId?: string;
privateKey?: string;
privateKeyBase64?: string;
repository?: string;
};
export type RollingLimitResult = {
allowed: boolean;
timestamps: number[];
remaining: number;
resetAt: number;
};
export class IssueSubmissionError extends Error {
constructor(
public readonly code: string,
public readonly status = 400,
) {
super(code);
this.name = "IssueSubmissionError";
}
}
const ISSUE_TYPE_META: Record<IssueType, { prefix: string; labels: string[] }> = {
bug: { prefix: "[Bug]", labels: ["bug"] },
feature: { prefix: "[Feature]", labels: ["enhancement"] },
question: { prefix: "[Question]", labels: ["question"] },
compatibility: { prefix: "[Compatibility]", labels: ["bug"] },
};
function cleanText(value: string): string {
return value.replaceAll("\u0000", "").replace(/\r\n?/g, "\n").trim();
}
function asArrayBuffer(bytes: Uint8Array): ArrayBuffer {
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
}
function concatBytes(...arrays: Uint8Array[]): Uint8Array {
const result = new Uint8Array(arrays.reduce((total, array) => total + array.length, 0));
let offset = 0;
for (const array of arrays) {
result.set(array, offset);
offset += array.length;
}
return result;
}
function base64Encode(bytes: Uint8Array): string {
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary);
}
function base64Decode(value: string): Uint8Array {
const normalized = value.replace(/\s+/g, "");
const binary = atob(normalized);
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
}
function base64UrlEncode(bytes: Uint8Array): string {
return base64Encode(bytes).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
}
function encodeAsn1Length(length: number): Uint8Array {
if (length < 128) return Uint8Array.of(length);
const bytes: number[] = [];
let remaining = length;
while (remaining > 0) {
bytes.unshift(remaining & 0xff);
remaining >>>= 8;
}
return Uint8Array.of(0x80 | bytes.length, ...bytes);
}
function encodeAsn1(tag: number, content: Uint8Array): Uint8Array {
return concatBytes(Uint8Array.of(tag), encodeAsn1Length(content.length), content);
}
function wrapPkcs1AsPkcs8(pkcs1: Uint8Array): Uint8Array {
const version = Uint8Array.of(0x02, 0x01, 0x00);
const rsaAlgorithm = Uint8Array.of(0x30, 0x0d, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01, 0x05, 0x00);
return encodeAsn1(0x30, concatBytes(version, rsaAlgorithm, encodeAsn1(0x04, pkcs1)));
}
function parsePem(value: string): { label: string; bytes: Uint8Array } | null {
const match = value.match(/-----BEGIN ([A-Z ]+)-----([\s\S]+?)-----END \1-----/);
if (!match) return null;
return { label: match[1], bytes: base64Decode(match[2]) };
}
function loadGitHubPrivateKey(config: GitHubIssueConfig): Uint8Array {
const rawKey = cleanText(config.privateKey?.replaceAll("\\n", "\n") ?? "");
if (rawKey) {
const pem = parsePem(rawKey);
if (!pem) throw new IssueSubmissionError("GITHUB_APP_KEY_INVALID", 503);
return pem.label === "RSA PRIVATE KEY" ? wrapPkcs1AsPkcs8(pem.bytes) : pem.bytes;
}
const encoded = cleanText(config.privateKeyBase64 ?? "");
if (!encoded) throw new IssueSubmissionError("GITHUB_APP_NOT_CONFIGURED", 503);
const decoded = base64Decode(encoded);
const decodedText = new TextDecoder().decode(decoded);
const pem = parsePem(decodedText);
if (!pem) return decoded;
return pem.label === "RSA PRIVATE KEY" ? wrapPkcs1AsPkcs8(pem.bytes) : pem.bytes;
}
export function normalizeIssueLanguage(value: FormDataEntryValue | null): IssueLanguage {
return value === "en" ? "en" : "cn";
}
export function normalizeIssueType(value: unknown): IssueType {
return typeof value === "string" && ISSUE_TYPES.includes(value as IssueType) ? (value as IssueType) : "bug";
}
export function validateIssueDescription(value: FormDataEntryValue | null): string {
if (typeof value !== "string") throw new IssueSubmissionError("DESCRIPTION_REQUIRED");
const description = cleanText(value);
if (description.length < 4) throw new IssueSubmissionError("DESCRIPTION_TOO_SHORT");
if (description.length > MAX_ISSUE_DESCRIPTION_LENGTH) throw new IssueSubmissionError("DESCRIPTION_TOO_LONG");
return description;
}
export function validateEditableIssue(value: { type: unknown; title: unknown; body: unknown }): { type: IssueType; title: string; body: string; labels: string[] } {
const type = normalizeIssueType(value.type);
if (typeof value.title !== "string" || typeof value.body !== "string") throw new IssueSubmissionError("PREVIEW_INVALID");
const title = normalizeIssueTitle(value.title, type);
const body = cleanText(value.body);
if (title.length < 8) throw new IssueSubmissionError("TITLE_TOO_SHORT");
if (title.length > MAX_ISSUE_TITLE_LENGTH) throw new IssueSubmissionError("TITLE_TOO_LONG");
if (body.length < 20) throw new IssueSubmissionError("BODY_TOO_SHORT");
if (body.length > MAX_ISSUE_BODY_LENGTH) throw new IssueSubmissionError("BODY_TOO_LONG");
return { type, title, body, labels: ISSUE_TYPE_META[type].labels };
}
export function normalizeIssueTitle(value: string, type: IssueType): string {
let title = cleanText(value).replace(/\s*\n\s*/g, " ");
for (const meta of Object.values(ISSUE_TYPE_META)) {
if (title.toLowerCase().startsWith(meta.prefix.toLowerCase())) {
title = title.slice(meta.prefix.length).trim();
break;
}
}
const prefix = ISSUE_TYPE_META[type].prefix;
const allowedLength = MAX_ISSUE_TITLE_LENGTH - prefix.length - 1;
return `${prefix} ${title.slice(0, allowedLength).trim()}`;
}
export function consumeRollingLimit(
timestamps: number[],
now: number,
limit = ISSUE_RATE_LIMIT,
windowMs = ISSUE_RATE_WINDOW_MS,
): RollingLimitResult {
const active = timestamps.filter((timestamp) => Number.isFinite(timestamp) && timestamp > now - windowMs).sort((left, right) => left - right);
if (active.length >= limit) {
return { allowed: false, timestamps: active, remaining: 0, resetAt: active[0] + windowMs };
}
active.push(now);
return { allowed: true, timestamps: active, remaining: Math.max(0, limit - active.length), resetAt: active[0] + windowMs };
}
export function detectIssueImageType(bytes: Uint8Array): Pick<IssueImage, "contentType" | "extension"> | null {
if (bytes.length >= 8 && bytes.slice(0, 8).every((byte, index) => byte === Uint8Array.of(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a)[index])) {
return { contentType: "image/png", extension: "png" };
}
if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
return { contentType: "image/jpeg", extension: "jpg" };
}
if (bytes.length >= 12 && new TextDecoder().decode(bytes.slice(0, 4)) === "RIFF" && new TextDecoder().decode(bytes.slice(8, 12)) === "WEBP") {
return { contentType: "image/webp", extension: "webp" };
}
return null;
}
export async function readIssueImages(entries: FormDataEntryValue[]): Promise<IssueImage[]> {
const files = entries.filter((entry): entry is File => typeof entry !== "string" && entry.size > 0);
if (files.length > MAX_ISSUE_IMAGES) throw new IssueSubmissionError("TOO_MANY_IMAGES");
const images: IssueImage[] = [];
let totalBytes = 0;
for (const file of files) {
if (file.size > MAX_ISSUE_IMAGE_BYTES) throw new IssueSubmissionError("IMAGE_TOO_LARGE");
totalBytes += file.size;
if (totalBytes > MAX_ISSUE_IMAGE_TOTAL_BYTES) throw new IssueSubmissionError("IMAGES_TOO_LARGE");
const bytes = new Uint8Array(await file.arrayBuffer());
const detected = detectIssueImageType(bytes);
if (!detected) throw new IssueSubmissionError("IMAGE_TYPE_UNSUPPORTED");
images.push({ bytes, ...detected });
}
return images;
}
function issueAiEndpoint(apiBase: string): string {
const normalized = apiBase.replace(/\/+$/, "");
if (normalized.endsWith("/chat/completions")) return normalized;
if (normalized.endsWith("/v1")) return `${normalized}/chat/completions`;
return `${normalized}/v1/chat/completions`;
}
function issuePrompt(language: IssueLanguage): string {
if (language === "en") {
return [
"You turn a short DBX user report and optional screenshots into a GitHub Issue draft.",
"Return one JSON object only, without a Markdown fence.",
'Schema: {"type":"bug|feature|question|compatibility","title":"...","summary":"...","body":"..."}.',
"The body must be editable Markdown with useful section headings.",
"Preserve facts from the report and screenshots. Never invent versions, logs, reproduction steps, expected behavior, or database details.",
"Mark missing but important information as Not provided. Remove credentials or tokens if visible.",
"Do not put the [Bug], [Feature], [Question], or [Compatibility] prefix in title.",
].join("\n");
}
return [
"你负责把 DBX 用户的简短描述和可选截图整理成 GitHub Issue 草稿。",
"只返回一个 JSON 对象,不要使用 Markdown 代码块。",
'结构:{"type":"bug|feature|question|compatibility","title":"...","summary":"...","body":"..."}。',
"body 使用可编辑的 Markdown 和清晰的小标题。",
"只能保留描述或截图里能确认的事实,不得编造版本、日志、复现步骤、期望行为或数据库信息。",
"重要信息缺失时明确写“未提供”。如果截图里出现密码、Token 或连接串,必须脱敏。",
"title 不要包含 [Bug]、[Feature]、[Question] 或 [Compatibility] 前缀。",
].join("\n");
}
function extractAiContent(data: unknown): string {
if (!data || typeof data !== "object") throw new IssueSubmissionError("AI_RESPONSE_INVALID", 502);
const choices = (data as { choices?: unknown }).choices;
if (!Array.isArray(choices) || !choices[0] || typeof choices[0] !== "object") throw new IssueSubmissionError("AI_RESPONSE_INVALID", 502);
const content = (choices[0] as { message?: { content?: unknown } }).message?.content;
if (typeof content === "string") return content;
if (Array.isArray(content)) {
return content
.map((part) => (part && typeof part === "object" && typeof (part as { text?: unknown }).text === "string" ? (part as { text: string }).text : ""))
.join("\n");
}
throw new IssueSubmissionError("AI_RESPONSE_INVALID", 502);
}
export function parseIssueAiResponse(content: string): IssuePreview {
const cleaned = content.trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "");
const start = cleaned.indexOf("{");
const end = cleaned.lastIndexOf("}");
if (start < 0 || end <= start) throw new IssueSubmissionError("AI_RESPONSE_INVALID", 502);
let parsed: unknown;
try {
parsed = JSON.parse(cleaned.slice(start, end + 1));
} catch {
throw new IssueSubmissionError("AI_RESPONSE_INVALID", 502);
}
if (!parsed || typeof parsed !== "object") throw new IssueSubmissionError("AI_RESPONSE_INVALID", 502);
const value = parsed as Record<string, unknown>;
const type = normalizeIssueType(value.type);
if (typeof value.title !== "string" || typeof value.summary !== "string" || typeof value.body !== "string") {
throw new IssueSubmissionError("AI_RESPONSE_INVALID", 502);
}
const title = normalizeIssueTitle(value.title, type);
const summary = cleanText(value.summary).slice(0, 400);
const body = cleanText(value.body);
validateEditableIssue({ type, title, body });
if (!summary) throw new IssueSubmissionError("AI_RESPONSE_INVALID", 502);
return { type, title, summary, body };
}
export async function createIssuePreview(
config: IssueAiConfig,
description: string,
images: IssueImage[],
language: IssueLanguage,
): Promise<IssuePreview> {
const apiBase = cleanText(config.apiBase ?? "");
const apiKey = cleanText(config.apiKey ?? "");
const model = cleanText(config.model ?? "");
if (!apiBase || !apiKey || !model) throw new IssueSubmissionError("AI_NOT_CONFIGURED", 503);
const userContent: Array<Record<string, unknown>> = [{ type: "text", text: description }];
for (const image of images) {
userContent.push({
type: "image_url",
image_url: { url: `data:${image.contentType};base64,${base64Encode(image.bytes)}`, detail: "auto" },
});
}
let response: Response;
try {
response = await fetch(issueAiEndpoint(apiBase), {
method: "POST",
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
body: JSON.stringify({
model,
temperature: 0.2,
max_tokens: 1800,
messages: [
{ role: "system", content: issuePrompt(language) },
{ role: "user", content: userContent },
],
}),
signal: AbortSignal.timeout(45_000),
});
} catch {
throw new IssueSubmissionError("AI_REQUEST_FAILED", 502);
}
if (!response.ok) throw new IssueSubmissionError("AI_REQUEST_FAILED", 502);
let data: unknown;
try {
data = await response.json();
} catch {
throw new IssueSubmissionError("AI_RESPONSE_INVALID", 502);
}
return parseIssueAiResponse(extractAiContent(data));
}
async function createGitHubAppJwt(config: GitHubIssueConfig): Promise<string> {
const appId = cleanText(config.appId ?? "");
if (!appId) throw new IssueSubmissionError("GITHUB_APP_NOT_CONFIGURED", 503);
const privateKey = await crypto.subtle.importKey(
"pkcs8",
asArrayBuffer(loadGitHubPrivateKey(config)),
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
false,
["sign"],
);
const now = Math.floor(Date.now() / 1000);
const header = base64UrlEncode(new TextEncoder().encode(JSON.stringify({ alg: "RS256", typ: "JWT" })));
const payload = base64UrlEncode(new TextEncoder().encode(JSON.stringify({ iat: now - 60, exp: now + 540, iss: appId })));
const unsignedToken = `${header}.${payload}`;
const signature = await crypto.subtle.sign("RSASSA-PKCS1-v1_5", privateKey, new TextEncoder().encode(unsignedToken));
return `${unsignedToken}.${base64UrlEncode(new Uint8Array(signature))}`;
}
async function githubJson(url: string, init: RequestInit): Promise<{ response: Response; data: unknown }> {
let response: Response;
try {
response = await fetch(url, { ...init, signal: AbortSignal.timeout(20_000) });
} catch {
throw new IssueSubmissionError("GITHUB_REQUEST_FAILED", 502);
}
let data: unknown = null;
try {
data = await response.json();
} catch {
data = null;
}
return { response, data };
}
async function getGitHubInstallationToken(config: GitHubIssueConfig): Promise<{ token: string; repository: string }> {
const repository = cleanText(config.repository ?? "t8y2/dbx");
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) throw new IssueSubmissionError("GITHUB_REPOSITORY_INVALID", 503);
const appJwt = await createGitHubAppJwt(config);
const headers = {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${appJwt}`,
"User-Agent": "dbx-issue-form",
"X-GitHub-Api-Version": "2022-11-28",
};
const installation = await githubJson(`https://api.github.com/repos/${repository}/installation`, { headers });
const installationId = installation.data && typeof installation.data === "object" ? (installation.data as { id?: unknown }).id : null;
if (!installation.response.ok || (typeof installationId !== "number" && typeof installationId !== "string")) {
throw new IssueSubmissionError("GITHUB_APP_INSTALLATION_NOT_FOUND", 502);
}
const repoName = repository.split("/")[1];
const tokenResult = await githubJson(`https://api.github.com/app/installations/${installationId}/access_tokens`, {
method: "POST",
headers: { ...headers, "Content-Type": "application/json" },
body: JSON.stringify({ repositories: [repoName], permissions: { issues: "write" } }),
});
const token = tokenResult.data && typeof tokenResult.data === "object" ? (tokenResult.data as { token?: unknown }).token : null;
if (!tokenResult.response.ok || typeof token !== "string" || !token) throw new IssueSubmissionError("GITHUB_TOKEN_FAILED", 502);
return { token, repository };
}
export async function createPublicGitHubIssue(
config: GitHubIssueConfig,
issue: { title: string; body: string; labels: string[] },
): Promise<{ number: number; url: string }> {
const { token, repository } = await getGitHubInstallationToken(config);
const result = await githubJson(`https://api.github.com/repos/${repository}/issues`, {
method: "POST",
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
"User-Agent": "dbx-issue-form",
"X-GitHub-Api-Version": "2022-11-28",
},
body: JSON.stringify(issue),
});
const data = result.data && typeof result.data === "object" ? (result.data as { number?: unknown; html_url?: unknown }) : {};
if (!result.response.ok || typeof data.number !== "number" || typeof data.html_url !== "string") {
throw new IssueSubmissionError("GITHUB_ISSUE_CREATE_FAILED", 502);
}
return { number: data.number, url: data.html_url };
}
export function buildGitHubIssueBody(body: string, imageUrls: string[], language: IssueLanguage): string {
const sections = [cleanText(body)];
if (imageUrls.length > 0) {
const heading = language === "cn" ? "## 附件" : "## Attachments";
const images = imageUrls.map((url, index) => `![DBX issue image ${index + 1}](${url})`).join("\n\n");
sections.push(`${heading}\n\n${images}`);
}
const source = language === "cn"
? "此 Issue 通过 DBX 官网匿名反馈入口提交,发布前已由提交者确认。"
: "This Issue was submitted through the anonymous DBX website form and confirmed before publication.";
sections.push(`---\n\n_${source}_`);
return sections.join("\n\n");
}
export function issueImageObjectKey(draftId: string, extension: IssueImage["extension"], index: number, now = new Date()): string {
const year = now.getUTCFullYear();
const month = String(now.getUTCMonth() + 1).padStart(2, "0");
return `issue-feedback/${year}/${month}/${draftId}/${index + 1}-${crypto.randomUUID()}.${extension}`;
}
+8 -1
View File
@@ -1,6 +1,6 @@
import assert from "node:assert/strict";
import { test } from "vitest";
import { sanitizeReturnTo, signPayload, verifySignedPayload } from "../worker";
import { issueRedirectPath, sanitizeReturnTo, signPayload, verifySignedPayload } from "../worker";
test("signed OAuth payloads round-trip and reject tampering", async () => {
const signed = await signPayload({ login: "dbx-user" }, "test-secret");
@@ -13,3 +13,10 @@ test("OAuth return paths stay on the DBX origin", () => {
assert.equal(sanitizeReturnTo("//evil.example"), "/en/contributors");
assert.equal(sanitizeReturnTo("https://evil.example"), "/en/contributors");
});
test("anonymous Issue aliases redirect to one localized route", () => {
assert.equal(issueRedirectPath("/issue", "cn"), "/cn/issue");
assert.equal(issueRedirectPath("/issues/", "en"), "/en/issue");
assert.equal(issueRedirectPath("/cn/issues", "en"), "/cn/issue");
assert.equal(issueRedirectPath("/cn/issue", "cn"), null);
});
+2 -1
View File
@@ -1,6 +1,7 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/types/routes.d.ts";
import "./.next/types/root-params.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+410 -1
View File
@@ -1,4 +1,41 @@
import {
ISSUE_CLAIM_TTL_MS,
ISSUE_DRAFT_TTL_MS,
ISSUE_RATE_WINDOW_MS,
IssueSubmissionError,
buildGitHubIssueBody,
consumeRollingLimit,
createIssuePreview,
createPublicGitHubIssue,
issueImageObjectKey,
normalizeIssueLanguage,
readIssueImages,
validateEditableIssue,
validateIssueDescription,
type IssueLanguage,
} from "./lib/issueSubmission";
type AssetsBinding = { fetch(request: Request): Promise<Response> };
type R2BucketBinding = {
put(key: string, value: Uint8Array, options?: { httpMetadata?: { contentType?: string; cacheControl?: string } }): Promise<unknown>;
delete(key: string): Promise<void>;
};
type DurableObjectStubBinding = { fetch(input: string | Request, init?: RequestInit): Promise<Response> };
type DurableObjectNamespaceBinding = {
idFromName(name: string): unknown;
get(id: unknown): DurableObjectStubBinding;
};
type DurableObjectTransactionBinding = {
get<T>(key: string): Promise<T | undefined>;
put<T>(key: string, value: T): Promise<void>;
delete(key: string): Promise<boolean>;
};
type DurableObjectStorageBinding = DurableObjectTransactionBinding & {
transaction<T>(closure: (transaction: DurableObjectTransactionBinding) => Promise<T>): Promise<T>;
setAlarm(scheduledTime: number): Promise<void>;
deleteAll(): Promise<void>;
};
type DurableObjectStateBinding = { storage: DurableObjectStorageBinding };
type Env = {
ASSETS: AssetsBinding;
@@ -6,6 +43,17 @@ type Env = {
GITHUB_CLIENT_SECRET?: string;
GITHUB_OAUTH_CALLBACK_URL?: string;
SESSION_SECRET?: string;
ISSUE_AI_API_BASE?: string;
ISSUE_AI_API_KEY?: string;
ISSUE_AI_MODEL?: string;
ISSUE_RATE_LIMIT_SECRET?: string;
ISSUE_LIMITER?: DurableObjectNamespaceBinding;
ISSUE_IMAGES?: R2BucketBinding;
ISSUE_IMAGE_PUBLIC_BASE_URL?: string;
GITHUB_APP_ID?: string;
GITHUB_APP_PRIVATE_KEY?: string;
GITHUB_APP_PRIVATE_KEY_B64?: string;
ISSUE_GITHUB_REPOSITORY?: string;
};
type OAuthState = {
@@ -22,9 +70,35 @@ type SessionUser = {
expiresAt: number;
};
type IssueSession = {
id: string;
expiresAt: number;
};
type IssueDraftRecord = {
id: string;
imageCount: number;
language: IssueLanguage;
createdAt: number;
expiresAt: number;
status: "ready" | "submitting" | "submitted";
claimExpiresAt?: number;
issueNumber?: number;
issueUrl?: string;
};
type DurableClaimResult =
| { state: "claimed"; draft: IssueDraftRecord }
| { state: "completed"; issueNumber: number; issueUrl: string }
| { state: "missing" }
| { state: "expired" }
| { state: "busy" };
const encoder = new TextEncoder();
const STATE_COOKIE = "dbx_oauth_state";
const SESSION_COOKIE = "dbx_contributor_session";
const ISSUE_SESSION_COOKIE = "dbx_issue_session";
const ISSUE_SESSION_TTL_MS = 24 * 60 * 60 * 1000;
function base64UrlEncode(bytes: Uint8Array): string {
let binary = "";
@@ -100,7 +174,9 @@ async function codeChallenge(verifier: string): Promise<string> {
}
function json(data: unknown, status = 200, headers?: HeadersInit): Response {
return Response.json(data, { status, headers: { "Cache-Control": "no-store", ...headers } });
const responseHeaders = new Headers(headers);
responseHeaders.set("Cache-Control", "no-store");
return Response.json(data, { status, headers: responseHeaders });
}
function requiredConfig(env: Env): { clientId: string; clientSecret: string; sessionSecret: string } | null {
@@ -206,9 +282,342 @@ async function contributorAvatar(request: Request): Promise<Response> {
});
}
function issueSessionCookie(value: string, maxAge: number): string {
return `${ISSUE_SESSION_COOKIE}=${encodeURIComponent(value)}; Max-Age=${maxAge}; Path=/; HttpOnly; Secure; SameSite=Strict`;
}
function issueRuntimeSecret(env: Env): string {
const secret = env.ISSUE_RATE_LIMIT_SECRET || env.SESSION_SECRET;
if (!secret) throw new IssueSubmissionError("ISSUE_RATE_LIMIT_NOT_CONFIGURED", 503);
return secret;
}
async function issueIdentityHash(kind: "ip" | "session", value: string, secret: string): Promise<string> {
const signature = await crypto.subtle.sign("HMAC", await hmacKey(secret), encoder.encode(`${kind}:${value}`));
return base64UrlEncode(new Uint8Array(signature));
}
function durableStub(env: Env, name: string): DurableObjectStubBinding {
if (!env.ISSUE_LIMITER) throw new IssueSubmissionError("ISSUE_LIMITER_NOT_CONFIGURED", 503);
return env.ISSUE_LIMITER.get(env.ISSUE_LIMITER.idFromName(name));
}
async function durableJson<T>(stub: DurableObjectStubBinding, path: string, body: unknown): Promise<T> {
let response: Response;
try {
response = await stub.fetch(`https://issue-internal${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
} catch {
throw new IssueSubmissionError("ISSUE_STATE_UNAVAILABLE", 503);
}
if (!response.ok) throw new IssueSubmissionError("ISSUE_STATE_UNAVAILABLE", 503);
try {
return (await response.json()) as T;
} catch {
throw new IssueSubmissionError("ISSUE_STATE_UNAVAILABLE", 503);
}
}
async function issueSession(request: Request, env: Env, create: boolean): Promise<{ session: IssueSession; setCookie?: string }> {
const secret = issueRuntimeSecret(env);
const existing = await verifySignedPayload<IssueSession>(parseCookies(request)[ISSUE_SESSION_COOKIE], secret);
if (existing && existing.expiresAt > Date.now() && /^[A-Za-z0-9_-]{24,}$/.test(existing.id)) return { session: existing };
if (!create) throw new IssueSubmissionError("ISSUE_SESSION_EXPIRED", 400);
const session = { id: randomToken(24), expiresAt: Date.now() + ISSUE_SESSION_TTL_MS } satisfies IssueSession;
const signed = await signPayload(session, secret);
return { session, setCookie: issueSessionCookie(signed, Math.floor(ISSUE_SESSION_TTL_MS / 1000)) };
}
function requestIp(request: Request): string {
const cloudflareIp = request.headers.get("CF-Connecting-IP")?.trim();
if (cloudflareIp) return cloudflareIp;
const hostname = new URL(request.url).hostname;
if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1") return "local-development";
throw new IssueSubmissionError("IP_ADDRESS_UNAVAILABLE", 400);
}
function verifyIssueOrigin(request: Request): void {
const origin = request.headers.get("Origin");
if (origin && origin !== new URL(request.url).origin) throw new IssueSubmissionError("ORIGIN_NOT_ALLOWED", 403);
}
function issueError(error: unknown, headers?: HeadersInit): Response {
if (error instanceof IssueSubmissionError) return json({ error: error.code }, error.status, headers);
return json({ error: "ISSUE_REQUEST_FAILED" }, 500, headers);
}
function countIssueImageEntries(entries: FormDataEntryValue[]): number {
return entries.filter((entry) => typeof entry !== "string" && entry.size > 0).length;
}
function publicImageUrl(baseUrl: string, key: string): string {
return `${baseUrl.replace(/\/+$/, "")}/${key.split("/").map(encodeURIComponent).join("/")}`;
}
async function uploadIssueImages(env: Env, draftId: string, images: Awaited<ReturnType<typeof readIssueImages>>): Promise<{ keys: string[]; urls: string[] }> {
if (images.length === 0) return { keys: [], urls: [] };
if (!env.ISSUE_IMAGES) throw new IssueSubmissionError("ISSUE_IMAGE_STORAGE_NOT_CONFIGURED", 503);
const baseUrl = env.ISSUE_IMAGE_PUBLIC_BASE_URL || "https://dl.dbxio.com";
const keys: string[] = [];
const urls: string[] = [];
for (const [index, image] of images.entries()) {
const key = issueImageObjectKey(draftId, image.extension, index);
try {
await env.ISSUE_IMAGES.put(key, image.bytes, {
httpMetadata: { contentType: image.contentType, cacheControl: "public, max-age=31536000, immutable" },
});
} catch {
await Promise.all(keys.map((uploadedKey) => env.ISSUE_IMAGES!.delete(uploadedKey).catch(() => undefined)));
throw new IssueSubmissionError("ISSUE_IMAGE_UPLOAD_FAILED", 502);
}
keys.push(key);
urls.push(publicImageUrl(baseUrl, key));
}
return { keys, urls };
}
async function deleteIssueImages(env: Env, keys: string[]): Promise<void> {
if (!env.ISSUE_IMAGES) return;
await Promise.all(keys.map((key) => env.ISSUE_IMAGES!.delete(key).catch(() => undefined)));
}
async function handleIssueDraft(request: Request, env: Env): Promise<Response> {
let responseHeaders: HeadersInit | undefined;
try {
verifyIssueOrigin(request);
const sessionResult = await issueSession(request, env, true);
responseHeaders = sessionResult.setCookie ? { "Set-Cookie": sessionResult.setCookie } : undefined;
const secret = issueRuntimeSecret(env);
const ipHash = await issueIdentityHash("ip", requestIp(request), secret);
const sessionHash = await issueIdentityHash("session", sessionResult.session.id, secret);
const ipStub = durableStub(env, `ip:${ipHash}`);
const sessionStub = durableStub(env, `session:${sessionHash}`);
const ipLimit = await durableJson<ReturnType<typeof consumeRollingLimit>>(ipStub, "/limit/consume", {});
if (!ipLimit.allowed) {
return json({ error: "RATE_LIMITED", retryAfter: Math.max(1, Math.ceil((ipLimit.resetAt - Date.now()) / 1000)) }, 429, responseHeaders);
}
const sessionLimit = await durableJson<ReturnType<typeof consumeRollingLimit>>(sessionStub, "/limit/consume", {});
if (!sessionLimit.allowed) {
return json({ error: "RATE_LIMITED", retryAfter: Math.max(1, Math.ceil((sessionLimit.resetAt - Date.now()) / 1000)) }, 429, responseHeaders);
}
const form = await request.formData();
const description = validateIssueDescription(form.get("description"));
const language = normalizeIssueLanguage(form.get("language"));
const images = await readIssueImages(form.getAll("images"));
const preview = await createIssuePreview(
{ apiBase: env.ISSUE_AI_API_BASE, apiKey: env.ISSUE_AI_API_KEY, model: env.ISSUE_AI_MODEL },
description,
images,
language,
);
const draftId = crypto.randomUUID();
const now = Date.now();
await durableJson(sessionStub, "/draft/create", {
draft: {
id: draftId,
imageCount: images.length,
language,
createdAt: now,
expiresAt: now + ISSUE_DRAFT_TTL_MS,
status: "ready",
} satisfies IssueDraftRecord,
});
return json(
{
draftId,
expiresAt: now + ISSUE_DRAFT_TTL_MS,
preview,
rateLimit: {
remaining: Math.min(ipLimit.remaining, sessionLimit.remaining),
resetAt: Math.max(ipLimit.resetAt, sessionLimit.resetAt),
},
},
200,
responseHeaders,
);
} catch (error) {
return issueError(error, responseHeaders);
}
}
async function handleIssueSubmit(request: Request, env: Env): Promise<Response> {
let claimed = false;
let draftId = "";
let sessionStub: DurableObjectStubBinding | null = null;
let imageKeys: string[] = [];
try {
verifyIssueOrigin(request);
const sessionResult = await issueSession(request, env, false);
const secret = issueRuntimeSecret(env);
const sessionHash = await issueIdentityHash("session", sessionResult.session.id, secret);
sessionStub = durableStub(env, `session:${sessionHash}`);
const form = await request.formData();
const draftValue = form.get("draftId");
if (typeof draftValue !== "string" || !/^[0-9a-f-]{36}$/i.test(draftValue)) throw new IssueSubmissionError("DRAFT_INVALID");
draftId = draftValue;
const imageEntries = form.getAll("images");
const claim = await durableJson<DurableClaimResult>(sessionStub, "/draft/claim", { draftId, now: Date.now() });
if (claim.state === "completed") return json({ issueNumber: claim.issueNumber, issueUrl: claim.issueUrl, alreadySubmitted: true });
if (claim.state === "missing" || claim.state === "expired") throw new IssueSubmissionError("DRAFT_EXPIRED", 410);
if (claim.state === "busy") throw new IssueSubmissionError("DRAFT_SUBMITTING", 409);
claimed = true;
if (countIssueImageEntries(imageEntries) !== claim.draft.imageCount) throw new IssueSubmissionError("DRAFT_IMAGES_CHANGED");
const editable = validateEditableIssue({ type: form.get("type"), title: form.get("title"), body: form.get("body") });
const images = await readIssueImages(imageEntries);
const uploaded = await uploadIssueImages(env, draftId, images);
imageKeys = uploaded.keys;
const issue = await createPublicGitHubIssue(
{
appId: env.GITHUB_APP_ID,
privateKey: env.GITHUB_APP_PRIVATE_KEY,
privateKeyBase64: env.GITHUB_APP_PRIVATE_KEY_B64,
repository: env.ISSUE_GITHUB_REPOSITORY,
},
{
title: editable.title,
body: buildGitHubIssueBody(editable.body, uploaded.urls, claim.draft.language),
labels: editable.labels,
},
);
claimed = false;
try {
await durableJson(sessionStub, "/draft/complete", { draftId, issueNumber: issue.number, issueUrl: issue.url, now: Date.now() });
} catch {
return json({ issueNumber: issue.number, issueUrl: issue.url });
}
return json({ issueNumber: issue.number, issueUrl: issue.url });
} catch (error) {
await deleteIssueImages(env, imageKeys);
if (claimed && sessionStub && draftId) {
try {
await durableJson(sessionStub, "/draft/release", { draftId });
} catch {
return issueError(error);
}
}
return issueError(error);
}
}
function preferredIssueLanguage(request: Request): IssueLanguage {
return request.headers.get("Accept-Language")?.trim().toLowerCase().startsWith("zh") ? "cn" : "en";
}
export function issueRedirectPath(pathname: string, language: IssueLanguage): string | null {
if (pathname === "/issue" || pathname === "/issue/" || pathname === "/issues" || pathname === "/issues/") return `/${language}/issue`;
const localized = pathname.match(/^\/(cn|en)\/issues\/?$/);
return localized ? `/${localized[1]}/issue` : null;
}
export class IssueSubmissionLimiter {
constructor(private readonly state: DurableObjectStateBinding) {}
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
if (request.method !== "POST") return json({ error: "METHOD_NOT_ALLOWED" }, 405);
if (url.pathname === "/limit/consume") {
const now = Date.now();
const result = await this.state.storage.transaction(async (transaction) => {
const timestamps = (await transaction.get<number[]>("rate")) ?? [];
const next = consumeRollingLimit(timestamps, now);
if (next.allowed) await transaction.put("rate", next.timestamps);
return next;
});
const lastTimestamp = result.timestamps.at(-1);
if (lastTimestamp) await this.state.storage.setAlarm(lastTimestamp + ISSUE_RATE_WINDOW_MS + 1000);
return json(result);
}
let payload: Record<string, unknown>;
try {
payload = (await request.json()) as Record<string, unknown>;
} catch {
return json({ error: "INVALID_JSON" }, 400);
}
if (url.pathname === "/draft/create") {
const draft = payload.draft as IssueDraftRecord | undefined;
if (!draft || !/^[0-9a-f-]{36}$/i.test(draft.id)) return json({ error: "DRAFT_INVALID" }, 400);
await this.state.storage.put(`draft:${draft.id}`, draft);
return json({ created: true });
}
if (url.pathname === "/draft/claim") {
const draftId = typeof payload.draftId === "string" ? payload.draftId : "";
const now = typeof payload.now === "number" ? payload.now : Date.now();
const result = await this.state.storage.transaction<DurableClaimResult>(async (transaction) => {
const key = `draft:${draftId}`;
const draft = await transaction.get<IssueDraftRecord>(key);
if (!draft) return { state: "missing" };
if (draft.status === "submitted" && draft.issueNumber && draft.issueUrl) {
return { state: "completed", issueNumber: draft.issueNumber, issueUrl: draft.issueUrl };
}
if (draft.expiresAt <= now) {
await transaction.delete(key);
return { state: "expired" };
}
if (draft.status === "submitting" && (draft.claimExpiresAt ?? 0) > now) return { state: "busy" };
const claimed = { ...draft, status: "submitting", claimExpiresAt: now + ISSUE_CLAIM_TTL_MS } satisfies IssueDraftRecord;
await transaction.put(key, claimed);
return { state: "claimed", draft: claimed };
});
return json(result);
}
if (url.pathname === "/draft/release") {
const draftId = typeof payload.draftId === "string" ? payload.draftId : "";
await this.state.storage.transaction(async (transaction) => {
const key = `draft:${draftId}`;
const draft = await transaction.get<IssueDraftRecord>(key);
if (draft?.status === "submitting") await transaction.put(key, { ...draft, status: "ready", claimExpiresAt: undefined });
});
return json({ released: true });
}
if (url.pathname === "/draft/complete") {
const draftId = typeof payload.draftId === "string" ? payload.draftId : "";
const issueNumber = typeof payload.issueNumber === "number" ? payload.issueNumber : 0;
const issueUrl = typeof payload.issueUrl === "string" ? payload.issueUrl : "";
const now = typeof payload.now === "number" ? payload.now : Date.now();
if (!issueNumber || !issueUrl) return json({ error: "ISSUE_RESULT_INVALID" }, 400);
await this.state.storage.transaction(async (transaction) => {
const key = `draft:${draftId}`;
const draft = await transaction.get<IssueDraftRecord>(key);
if (draft) {
await transaction.put(key, {
...draft,
status: "submitted",
claimExpiresAt: undefined,
issueNumber,
issueUrl,
expiresAt: now + ISSUE_RATE_WINDOW_MS,
});
}
});
return json({ completed: true });
}
return json({ error: "NOT_FOUND" }, 404);
}
async alarm(): Promise<void> {
await this.state.storage.deleteAll();
}
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const issueRedirect = issueRedirectPath(url.pathname, preferredIssueLanguage(request));
if (issueRedirect && request.method === "GET") return Response.redirect(`${url.origin}${issueRedirect}`, 308);
if (url.pathname === "/api/issues/draft" && request.method === "POST") return handleIssueDraft(request, env);
if (url.pathname === "/api/issues/submit" && request.method === "POST") return handleIssueSubmit(request, env);
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);
+21 -1
View File
@@ -5,5 +5,25 @@
"assets": {
"directory": "./out",
"binding": "ASSETS"
}
},
"r2_buckets": [
{
"binding": "ISSUE_IMAGES",
"bucket_name": "dbx"
}
],
"durable_objects": {
"bindings": [
{
"name": "ISSUE_LIMITER",
"class_name": "IssueSubmissionLimiter"
}
]
},
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": ["IssueSubmissionLimiter"]
}
]
}