Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
6e7536d
chore(deps): add @milkdown/crepe and update related dependencies in p…
Crokily Nov 7, 2025
065bbf3
Test selective commit with improved pre-commit hook
Crokily Nov 14, 2025
9060f48
Fix: 修复了回调参数的error,目前未登录的页面能有可回调的登录页了
Crokily Nov 14, 2025
875dd76
Feat: 第一阶段的编辑器组件
Crokily Nov 14, 2025
4d682bb
feat: enhance EditorMetadataForm and MarkdownEditor with improved inp…
Crokily Nov 15, 2025
037f9c0
chore: update .gitignore to include .claude and enhance pre-commit ho…
Crokily Nov 15, 2025
bfa8af3
Fix: 修复了标签不能正常分割的问题
Crokily Nov 16, 2025
ef0f53f
chore: update .gitignore to change Agents.md to AGENTS.md for consist…
Crokily Nov 16, 2025
99f07f9
Fix: 修复了粘贴图片逻辑没走blob的问题
Crokily Nov 16, 2025
01f4468
chore: add R2 storage configuration to .env.sample for image upload s…
Crokily Nov 16, 2025
e093559
chore(deps): add AWS SDK S3 client and request presigner to package.j…
Crokily Nov 16, 2025
1509e2d
feat: implement image upload functionality to Cloudflare R2 with pre-…
Crokily Nov 16, 2025
871d832
feat: 对 MarkdownEditor 进行了解耦重构,并修复了删除同步问题
Crokily Nov 16, 2025
2a52b07
refactor: 抽离投稿目录与文件名工具
Crokily Nov 16, 2025
b56effe
feat: 编辑器接入 GitHub 投稿流程
Crokily Nov 16, 2025
d13d9ea
test: 添加一个编辑器生成的测试稿件
Crokily Nov 16, 2025
15c63ee
chore(deps): add @milkdown/kit dependency to package.json and update …
Crokily Nov 16, 2025
c032a48
chore(deps): add @types/mdast and unist-util-visit dependencies to pa…
Crokily Nov 16, 2025
5f07377
refactor: 移动 remarkImage 配置至 source.config.ts,并禁用 Next.js 图片优化以应对 Ver…
Crokily Nov 16, 2025
eafc34b
refactor: 使用原生 <img> 标签替代 Next.js Image 组件以解决 Vercel 配额限制和运行时问题
Crokily Nov 16, 2025
efb7d77
fix: correct JavaScript syntax highlighting in documentation
Crokily Nov 16, 2025
503b03b
refactor: improve tag input handling in EditorMetadataForm for better…
Crokily Nov 16, 2025
eb0adbd
chore: remove deprecated test markdown file from agents-todo document…
Crokily Nov 16, 2025
6b21a3c
feat: add navigation to editor in Contribute component
Crokily Nov 16, 2025
d33dbbf
Merge branch 'main' into feat/Crokily/mdEditor
Crokily Nov 16, 2025
f44f5df
chore(deps): update pnpm-lock.yaml to standardize package names and v…
Crokily Nov 16, 2025
15e1988
refactor: remove eslint directive for <img> tag in MDX components to …
Crokily Nov 16, 2025
66bfa55
fix: change overflow property in MarkdownEditor component to improve …
Crokily Nov 16, 2025
e00ceaf
fix: 修改 upload api 的注释以符合 API COMMENT 的规范
Crokily Dec 6, 2025
83776ee
feat: R2 表单签名限流
Crokily Dec 7, 2025
75cdadf
fix: 编辑器图片表单上传
Crokily Dec 7, 2025
f67bc88
fix: 投稿目录与文件名校验统一
Crokily Dec 7, 2025
eea8b85
chore: 对齐 aws sdk 版本
Crokily Dec 7, 2025
608d26b
chore: update ESLint configuration and Next.js settings, add new prox…
Crokily Dec 11, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,10 @@ POSTGRES_PRISMA_URL=
NEXT_PUBLIC_STACK_PROJECT_ID=
NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY=
STACK_SECRET_SERVER_KEY=

# R2的存储桶,用于提供图片自动上传服务
R2_ACCOUNT_ID=?
R2_ACCESS_KEY_ID=?
R2_SECRET_ACCESS_KEY=?
R2_BUCKET_NAME=?
R2_PUBLIC_URL=?
3 changes: 2 additions & 1 deletion .github/workflows/sync-uuid.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ concurrency:
jobs:
backfill:
# 防止 fork、限定 main、并避免机器人循环
if: (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/feat/contributor') &&
if:
(github.ref == 'refs/heads/main' || github.ref == 'refs/heads/feat/contributor') &&
github.actor != 'github-actions[bot]'
runs-on: ubuntu-latest
permissions:
Expand Down
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ next-env.d.ts
.package-lock.json

# Agents.md
Agents.md
AGENTS.md

# Environment variables
.env
Expand All @@ -58,3 +58,4 @@ Agents.md
/generated/prisma

.idea
.claude
13 changes: 11 additions & 2 deletions .husky/pre-commit
Original file line number Diff line number Diff line change
@@ -1,8 +1,17 @@
# 1) 将 /images/* 文章图片就近复制并更新引用
# 记录迁移前的变更状态
BEFORE_STATUS=$(git status --porcelain)

pnpm migrate:images || exit 1

# 将迁移后的变更加入暂存,确保本次提交包含更新
git add -A
# 检查迁移后是否有新的变更
AFTER_STATUS=$(git status --porcelain)

if [ "$BEFORE_STATUS" != "$AFTER_STATUS" ]; then
echo "检测到迁移脚本产生了变更,添加所有变更到暂存区以确保完整提交..."
# 只添加由于迁移产生的变更,而不是用户未暂存的所有变更
git add -u
fi
Comment on lines +2 to +14
Copy link

Copilot AI Nov 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The pre-commit hook script uses shell variable comparison which may not work reliably across different systems. The comparison [ "$BEFORE_STATUS" != "$AFTER_STATUS" ] could fail if the status contains special characters or multiline output.

Consider a more robust approach:

# Check if git diff shows any changes after migration
if ! git diff --quiet; then
  echo "检测到迁移脚本产生了变更,添加所有变更到暂存区以确保完整提交..."
  git add -u
fi

This uses git diff --quiet which properly handles all edge cases and is the recommended way to check for changes.

Copilot uses AI. Check for mistakes.

# 2) 校验图片路径与命名(不合规则阻止提交)
pnpm lint:images || exit 1
Expand Down
191 changes: 191 additions & 0 deletions app/api/upload/route.ts
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

文件大小 & 类型上限没在后端做限制, 可能直接打爆

Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import { auth } from "@/auth";
import { NextRequest, NextResponse } from "next/server";
import { sanitizeSlug, validateSlug } from "@/lib/submission";
import {
MAX_IMAGE_UPLOAD_BYTES,
isAllowedImageContentType,
isExtensionAllowedForContentType,
isValidFileSize,
sanitizeFilename,
} from "@/lib/uploads";
import { createImageUploadPost, createR2Client } from "@/lib/r2";
import type { AllowedImageContentType } from "@/lib/uploads";

const MAX_OBJECT_KEY_BYTES = 1024;

interface UploadRequest {
filename: string;
contentType: string;
articleSlug: string;
fileSize: number;
}

/**
* @description POST /api/upload - 生成 R2 预签名 URL,用于客户端直接上传图片
* @param request - NextRequest 对象,请求体包含以下字段:
* - filename: 文件名
* - contentType: 文件 MIME 类型
* - articleSlug: 文章 slug(用于组织文件路径)
* - fileSize: 文件大小(字节,用于限制超大上传)
* @returns NextResponse - 返回 JSON 对象:
* - uploadUrl: 预签名上传 URL(用于表单 POST)
* - fields: 需随表单一同提交的字段
* - publicUrl: 图片的公开访问 URL
* - key: R2 对象键
*/
export async function POST(request: NextRequest) {
try {
const session = await auth();

if (!session?.user?.id) {
return NextResponse.json({ error: "未授权访问" }, { status: 401 });
}

const {
R2_ACCOUNT_ID,
R2_ACCESS_KEY_ID,
R2_SECRET_ACCESS_KEY,
R2_BUCKET_NAME,
R2_PUBLIC_URL,
} = process.env;

if (
!R2_ACCOUNT_ID ||
!R2_ACCESS_KEY_ID ||
!R2_SECRET_ACCESS_KEY ||
!R2_BUCKET_NAME ||
!R2_PUBLIC_URL
) {
console.error("R2 环境变量未配置");
return NextResponse.json(
{ error: "服务器配置错误:R2 未配置" },
{ status: 500 },
);
}

let body: UploadRequest;
try {
body = (await request.json()) as UploadRequest;
} catch {
return NextResponse.json(
{ error: "请求体格式错误:应为 JSON" },
{ status: 400 },
);
}

const { filename, contentType, articleSlug, fileSize } = body;

if (
typeof filename !== "string" ||
typeof contentType !== "string" ||
typeof articleSlug !== "string" ||
typeof fileSize === "undefined"
) {
return NextResponse.json(
{
error: "缺少必要参数:filename, contentType, articleSlug, fileSize",
},
{ status: 400 },
);
}

const normalizedContentType = contentType.toLowerCase();
const sanitizedSlug = sanitizeSlug(articleSlug);
if (!validateSlug(sanitizedSlug) || sanitizedSlug !== articleSlug) {
return NextResponse.json(
{
error:
"articleSlug 不符合规范(需为 1-100 位小写字母、数字、连字符或下划线)",
},
{ status: 400 },
);
}

if (!isValidFileSize(fileSize)) {
return NextResponse.json(
{
error: `文件大小无效或超过限制(最大 ${
MAX_IMAGE_UPLOAD_BYTES / (1024 * 1024)
}MB)`,
},
{ status: 400 },
);
}

Copy link

Copilot AI Nov 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing input validation: The articleSlug parameter is not validated for path traversal or malicious content. While it's used in constructing the S3 key, an attacker could potentially inject path separators to organize files in unintended locations.

Add validation:

// Validate articleSlug
if (!/^[a-zA-Z0-9_-]+$/.test(articleSlug)) {
  return NextResponse.json(
    { error: "articleSlug 包含非法字符" },
    { status: 400 }
  );
}
Suggested change
// Validate articleSlug
if (!/^[a-zA-Z0-9_-]+$/.test(articleSlug)) {
return NextResponse.json(
{ error: "articleSlug 包含非法字符" },
{ status: 400 }
);
}

Copilot uses AI. Check for mistakes.
if (!isAllowedImageContentType(normalizedContentType)) {
return NextResponse.json(
{
error: "仅支持图片类型:image/jpeg, image/png, image/gif, image/webp",
},
{ status: 400 },
);
}

const sanitizedFilename = sanitizeFilename(filename);

if (!sanitizedFilename) {
return NextResponse.json(
{
error: "文件名不合法,仅支持字母、数字、., _, -,且不能以 . 开头",
},
{ status: 400 },
);
}

if (
!isExtensionAllowedForContentType(
sanitizedFilename,
normalizedContentType,
)
) {
return NextResponse.json(
{
error: "文件扩展名与 contentType 不匹配或不受支持",
},
{ status: 400 },
);
}

const timestamp = Date.now();
const userId = session.user.id;
const key = `users/${userId}/${sanitizedSlug}/${timestamp}-${sanitizedFilename}`;

if (Buffer.byteLength(key, "utf8") > MAX_OBJECT_KEY_BYTES) {
return NextResponse.json(
{ error: "生成的对象 Key 过长,请缩短文件名或文章 slug" },
{ status: 400 },
);
}

const r2Client = createR2Client({
accountId: R2_ACCOUNT_ID,
accessKeyId: R2_ACCESS_KEY_ID,
secretAccessKey: R2_SECRET_ACCESS_KEY,
});

const presignedPost = await createImageUploadPost({
client: r2Client,
bucket: R2_BUCKET_NAME,
key,
contentType: normalizedContentType as AllowedImageContentType,
});

const publicUrl = `${R2_PUBLIC_URL}/${key}`;

return NextResponse.json({
uploadUrl: presignedPost.url,
fields: presignedPost.fields,
publicUrl,
key,
});
} catch (error) {
console.error("生成预签名 URL 失败:", error);
return NextResponse.json(
{
error: "生成上传链接失败",
details: error instanceof Error ? error.message : "未知错误",
},
{ status: 500 },
);
}
}
80 changes: 37 additions & 43 deletions app/components/Contribute.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,24 @@ import {
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { ExternalLink, Plus, Sparkles } from "lucide-react";
import { ExternalLink, Sparkles } from "lucide-react";
import styles from "./Contribute.module.css";
import { useRouter } from "next/navigation";

// --- antd
import { TreeSelect } from "antd";
import type { DefaultOptionType } from "antd/es/select";
import { DataNode } from "antd/es/tree";
import { buildDocsNewUrl } from "@/lib/github";

type DirNode = { name: string; path: string; children?: DirNode[] };

const FILENAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]+$/;
import {
MAX_SLUG_LENGTH,
sanitizeSlug,
type DirNode,
validateSlug,
} from "@/lib/submission";
import {
CREATE_SUBDIR_SUFFIX,
toTreeSelectData,
} from "@/app/components/contribute/tree-utils";

// 统一调用工具函数生成 GitHub 新建链接,路径规则与 Edit 按钮一致
function buildGithubNewUrl(dirPath: string, filename: string, title: string) {
Expand All @@ -44,35 +50,8 @@ Write your content here.
return buildDocsNewUrl(dirPath, params);
}

// ✅ 用纯文本 label + 一级节点 selectable:false
function toTreeSelectData(tree: DirNode[]): DefaultOptionType[] {
return tree.map((l1) => ({
key: l1.path,
value: l1.path,
label: l1.name,
selectable: false, // ✅ 一级不可选
children: [
...(l1.children || []).map((l2) => ({
key: l2.path,
value: l2.path,
label: `${l1.name} / ${l2.name}`, // 纯文本,方便搜索
isLeaf: true,
})),
{
key: `${l1.path}/__create__`,
value: `${l1.path}/__create__`,
label: (
<span className="inline-flex items-center">
<Plus className="mr-1 h-3.5 w-3.5" />
在「{l1.name}」下新建二级子栏目…
</span>
),
},
],
}));
}

export function Contribute() {
const router = useRouter();
const [open, setOpen] = useState(false);
const [tree, setTree] = useState<DirNode[]>([]);
const [loading, setLoading] = useState(false);
Expand All @@ -86,21 +65,31 @@ export function Contribute() {
const [articleFileTouched, setArticleFileTouched] = useState(false);

const trimmedArticleFile = useMemo(() => articleFile.trim(), [articleFile]);
const sanitizedArticleFile = useMemo(
() => sanitizeSlug(trimmedArticleFile),
[trimmedArticleFile],
);
const { isFileNameValid, fileNameError } = useMemo(() => {
if (!trimmedArticleFile) {
return {
isFileNameValid: false,
fileNameError: "请填写文件名。",
};
}
if (!FILENAME_PATTERN.test(trimmedArticleFile)) {
if (!validateSlug(sanitizedArticleFile)) {
return {
isFileNameValid: false,
fileNameError: `文件名仅支持英文、数字、连字符或下划线(最长 ${MAX_SLUG_LENGTH} 个字符)。`,
};
}
if (sanitizedArticleFile !== trimmedArticleFile) {
return {
isFileNameValid: false,
fileNameError: "文件名仅支持英文、数字、连字符或下划线。",
fileNameError: `请使用规范化后的文件名:${sanitizedArticleFile}`,
};
}
return { isFileNameValid: true, fileNameError: "" };
}, [trimmedArticleFile]);
}, [sanitizedArticleFile, trimmedArticleFile]);

useEffect(() => {
let mounted = true;
Expand All @@ -124,22 +113,23 @@ export function Contribute() {
}, []);

const options = useMemo(() => toTreeSelectData(tree), [tree]);
const sanitizedSubdir = useMemo(() => sanitizeSlug(newSub), [newSub]);

const finalDirPath = useMemo(() => {
if (!selectedKey) return "";
if (selectedKey.endsWith("/__create__")) {
if (selectedKey.endsWith(CREATE_SUBDIR_SUFFIX)) {
const l1 = selectedKey.split("/")[0];
if (!newSub.trim()) return "";
return `${l1}/${newSub.trim().replace(/\s+/g, "-")}`;
if (!sanitizedSubdir) return "";
return `${l1}/${sanitizedSubdir}`;
}
return selectedKey;
}, [selectedKey, newSub]);
}, [sanitizedSubdir, selectedKey]);

const canProceed = !!finalDirPath && isFileNameValid;

const handleOpenGithub = () => {
if (!canProceed) return;
const filename = trimmedArticleFile.toLowerCase();
const filename = sanitizedArticleFile;
const title = articleTitle || filename;
window.open(
buildGithubNewUrl(finalDirPath, filename, title),
Expand Down Expand Up @@ -173,6 +163,10 @@ export function Contribute() {
bg-gradient-to-r from-sky-300 via-sky-400 to-blue-600
dark:from-indigo-950 dark:via-slate-900 dark:to-black
hover:shadow-[0_25px_60px_-12px] hover:scale-[1.03] transition-all duration-300 ease-out"
onClick={(event) => {
event.preventDefault();
router.push("/editor");
}}
>
{/* Day gradient shimmer */}
<span
Expand Down Expand Up @@ -276,7 +270,7 @@ export function Contribute() {
/>
</div>

{selectedKey.endsWith("/__create__") && (
{selectedKey.endsWith(CREATE_SUBDIR_SUFFIX) && (
<div className="space-y-1">
<label className="text-sm font-medium">新建二级子栏目名称</label>
<Input
Expand Down
Loading