mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
feat(docs): add anonymous GitHub issue submission
This commit is contained in:
+29
-1
@@ -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`.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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) => ``).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}`;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Vendored
+2
-1
@@ -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
@@ -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
@@ -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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user