hd-garden 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/garden-interactive-chart/SKILL.md +84 -0
- package/.agents/skills/garden-interactive-chart/references/shell.html +49 -0
- package/.agents/skills/garden-interactive-chart/references/style.md +61 -0
- package/.env.example +56 -0
- package/.openai/hosting.json +5 -0
- package/LICENSE +21 -0
- package/Makefile +48 -0
- package/README.md +72 -0
- package/app/about/page.tsx +60 -0
- package/app/admin/page.tsx +117 -0
- package/app/api/admin/distribution/route.ts +120 -0
- package/app/api/admin/posts/pin/route.ts +32 -0
- package/app/api/admin/thoughts/[id]/route.ts +44 -0
- package/app/api/admin/thoughts/route.ts +120 -0
- package/app/api/admin/uploads/route.ts +20 -0
- package/app/api/auth/github/callback/route.ts +136 -0
- package/app/api/auth/github/route.ts +29 -0
- package/app/api/auth/logout/route.ts +13 -0
- package/app/blog/[...path]/page.tsx +135 -0
- package/app/blog/page.tsx +139 -0
- package/app/chatgpt-auth.ts +86 -0
- package/app/columns/[column]/[...path]/page.tsx +160 -0
- package/app/columns/page.tsx +68 -0
- package/app/components/AdminDashboard.tsx +410 -0
- package/app/components/Comments.tsx +55 -0
- package/app/components/ContentEmptyState.tsx +51 -0
- package/app/components/ExternalEmbed.tsx +127 -0
- package/app/components/GardenAdminGuide.tsx +116 -0
- package/app/components/GardenContentGuide.tsx +241 -0
- package/app/components/GardenMediaGuide.tsx +81 -0
- package/app/components/GiscusComments.tsx +71 -0
- package/app/components/GithubLiveOverview.tsx +118 -0
- package/app/components/GithubProfile.tsx +326 -0
- package/app/components/MarkdownArticle.tsx +144 -0
- package/app/components/MermaidDiagram.tsx +117 -0
- package/app/components/PageIntro.tsx +32 -0
- package/app/components/SiteChrome.tsx +18 -0
- package/app/components/SiteFooter.tsx +29 -0
- package/app/components/SiteHeader.tsx +420 -0
- package/app/components/TableOfContents.tsx +433 -0
- package/app/components/ThoughtCard.tsx +242 -0
- package/app/components/Webmentions.tsx +194 -0
- package/app/components/WidgetChart.tsx +222 -0
- package/app/garden/page.tsx +284 -0
- package/app/garden-modules.d.ts +62 -0
- package/app/globals.css +4454 -0
- package/app/layout.tsx +102 -0
- package/app/lib/admin-auth.ts +134 -0
- package/app/lib/content.ts +112 -0
- package/app/lib/fallback-site-config.ts +56 -0
- package/app/lib/generated-content.ts +380 -0
- package/app/lib/github-content.ts +244 -0
- package/app/lib/markdown-plugins.ts +13 -0
- package/app/lib/oss.ts +100 -0
- package/app/lib/public-thoughts.ts +9 -0
- package/app/lib/repository-path.mjs +32 -0
- package/app/lib/site.ts +51 -0
- package/app/page.tsx +119 -0
- package/app/robots.ts +15 -0
- package/app/rss.xml/route.ts +84 -0
- package/app/site.config.ts +35 -0
- package/app/sitemap.ts +46 -0
- package/app/thoughts/[slug]/page.tsx +79 -0
- package/app/thoughts/page.tsx +33 -0
- package/app/yaml.d.ts +4 -0
- package/bin/garden.mjs +348 -0
- package/build/sites-vite-plugin.ts +39 -0
- package/eslint.config.mjs +20 -0
- package/next.config.ts +13 -0
- package/package.json +112 -0
- package/postcss.config.mjs +7 -0
- package/public/favicon.svg +4 -0
- package/public/images/garden/admin-studio.png +0 -0
- package/public/og.png +0 -0
- package/scripts/build-content.mjs +582 -0
- package/scripts/fetch-embed.mjs +189 -0
- package/scripts/garden-paths.mjs +21 -0
- package/scripts/new-post.mjs +73 -0
- package/scripts/prepare-pages-output.mjs +182 -0
- package/scripts/send-webmentions.mjs +27 -0
- package/site.config.yaml +48 -0
- package/templates/site/Makefile +46 -0
- package/templates/site/README.md +13 -0
- package/templates/site/content/columns.yaml +1 -0
- package/templates/site/content/posts/.gitkeep +0 -0
- package/templates/site/content/thoughts/.gitkeep +0 -0
- package/templates/site/package.json +19 -0
- package/templates/site/site.config.yaml +46 -0
- package/tsconfig.json +34 -0
- package/vite.config.ts +130 -0
- package/worker/index.ts +53 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { requireAdminApi, requireAdminMutation } from "../../../lib/admin-auth";
|
|
2
|
+
import { findColumnEntry, findPost } from "../../../lib/content";
|
|
3
|
+
import { absoluteUrl, getSiteUrl } from "../../../lib/site";
|
|
4
|
+
|
|
5
|
+
type Platform = "x" | "csdn" | "zhihu" | "juejin";
|
|
6
|
+
|
|
7
|
+
const platformLabel: Record<Platform, string> = {
|
|
8
|
+
x: "X",
|
|
9
|
+
csdn: "CSDN",
|
|
10
|
+
zhihu: "知乎",
|
|
11
|
+
juejin: "掘金",
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
function platformWebhook(platform: Exclude<Platform, "x">) {
|
|
15
|
+
const keys = {
|
|
16
|
+
csdn: "CSDN_SYNC_WEBHOOK",
|
|
17
|
+
zhihu: "ZHIHU_SYNC_WEBHOOK",
|
|
18
|
+
juejin: "JUEJIN_SYNC_WEBHOOK",
|
|
19
|
+
} as const;
|
|
20
|
+
return process.env[keys[platform]];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function POST(request: Request) {
|
|
24
|
+
const user = await requireAdminMutation(request);
|
|
25
|
+
if (user instanceof Response) return user;
|
|
26
|
+
const payload = (await request.json()) as {
|
|
27
|
+
kind?: unknown;
|
|
28
|
+
path?: unknown;
|
|
29
|
+
column?: unknown;
|
|
30
|
+
platform?: unknown;
|
|
31
|
+
};
|
|
32
|
+
const kind = payload.kind === "column" ? "column" : payload.kind === "post" ? "post" : null;
|
|
33
|
+
const path = typeof payload.path === "string" ? payload.path : "";
|
|
34
|
+
const platform = ["x", "csdn", "zhihu", "juejin"].includes(String(payload.platform))
|
|
35
|
+
? (payload.platform as Platform)
|
|
36
|
+
: null;
|
|
37
|
+
if (!kind || !path || !platform) {
|
|
38
|
+
return Response.json({ error: "分发参数不完整。" }, { status: 400 });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const entry = kind === "post" ? findPost(path) : undefined;
|
|
42
|
+
const column = typeof payload.column === "string" ? payload.column : "";
|
|
43
|
+
const columnEntry = kind === "column" ? findColumnEntry(column, path) : undefined;
|
|
44
|
+
const source = entry ?? columnEntry;
|
|
45
|
+
if (!source) return Response.json({ error: "未找到对应内容。" }, { status: 404 });
|
|
46
|
+
|
|
47
|
+
const publicPath = kind === "post" ? `/blog/${source.path}` : `/columns/${source.column}/${source.path}`;
|
|
48
|
+
const canonicalUrl = absoluteUrl(publicPath, await getSiteUrl());
|
|
49
|
+
let status: "queued" | "published" | "needs_credentials" | "failed" = "queued";
|
|
50
|
+
let externalUrl: string | null = null;
|
|
51
|
+
let error: string | null = null;
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
if (platform === "x") {
|
|
55
|
+
const token = process.env.X_USER_ACCESS_TOKEN;
|
|
56
|
+
if (!token) {
|
|
57
|
+
status = "needs_credentials";
|
|
58
|
+
error = "缺少 X_USER_ACCESS_TOKEN。";
|
|
59
|
+
} else {
|
|
60
|
+
const text = `${source.title}\n${source.description ?? ""}\n${canonicalUrl}`.slice(0, 280);
|
|
61
|
+
const response = await fetch("https://api.x.com/2/tweets", {
|
|
62
|
+
method: "POST",
|
|
63
|
+
headers: {
|
|
64
|
+
Authorization: `Bearer ${token}`,
|
|
65
|
+
"Content-Type": "application/json",
|
|
66
|
+
},
|
|
67
|
+
body: JSON.stringify({ text }),
|
|
68
|
+
});
|
|
69
|
+
const result = (await response.json()) as { data?: { id?: string }; detail?: string };
|
|
70
|
+
if (!response.ok || !result.data?.id) {
|
|
71
|
+
status = "failed";
|
|
72
|
+
error = result.detail ?? "X 未接受这次发布请求。";
|
|
73
|
+
} else {
|
|
74
|
+
status = "published";
|
|
75
|
+
externalUrl = `https://x.com/i/web/status/${result.data.id}`;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
} else {
|
|
79
|
+
const webhook = platformWebhook(platform);
|
|
80
|
+
if (!webhook) {
|
|
81
|
+
status = "needs_credentials";
|
|
82
|
+
error = `缺少 ${platformLabel[platform]} 的同步 Webhook。`;
|
|
83
|
+
} else {
|
|
84
|
+
const response = await fetch(webhook, {
|
|
85
|
+
method: "POST",
|
|
86
|
+
headers: { "Content-Type": "application/json" },
|
|
87
|
+
body: JSON.stringify({
|
|
88
|
+
platform,
|
|
89
|
+
title: source.title,
|
|
90
|
+
markdown: source.content,
|
|
91
|
+
summary: source.description ?? "",
|
|
92
|
+
canonicalUrl,
|
|
93
|
+
tags: source.tags ?? [],
|
|
94
|
+
}),
|
|
95
|
+
});
|
|
96
|
+
const result = (await response.json().catch(() => ({}))) as { url?: string; error?: string };
|
|
97
|
+
if (!response.ok) {
|
|
98
|
+
status = "failed";
|
|
99
|
+
error = result.error ?? `${platformLabel[platform]} 同步服务返回失败。`;
|
|
100
|
+
} else {
|
|
101
|
+
status = "published";
|
|
102
|
+
externalUrl = result.url ?? null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return Response.json({
|
|
108
|
+
job: { platform, status, canonicalUrl, externalUrl, error },
|
|
109
|
+
});
|
|
110
|
+
} catch (exception) {
|
|
111
|
+
const message = exception instanceof Error ? exception.message : "分发服务暂不可用。";
|
|
112
|
+
return Response.json({ error: message }, { status: 503 });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function GET() {
|
|
117
|
+
const user = await requireAdminApi();
|
|
118
|
+
if (user instanceof Response) return user;
|
|
119
|
+
return Response.json({ jobs: [] });
|
|
120
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { requireAdminMutation } from "../../../../lib/admin-auth";
|
|
2
|
+
import { updateRepositoryPostPinned } from "../../../../lib/github-content";
|
|
3
|
+
import { findPost } from "../../../../lib/content";
|
|
4
|
+
|
|
5
|
+
export async function PATCH(request: Request) {
|
|
6
|
+
const user = await requireAdminMutation(request);
|
|
7
|
+
if (user instanceof Response) return user;
|
|
8
|
+
const payload = (await request.json().catch(() => null)) as {
|
|
9
|
+
path?: unknown;
|
|
10
|
+
pinned?: unknown;
|
|
11
|
+
} | null;
|
|
12
|
+
if (typeof payload?.path !== "string" || typeof payload.pinned !== "boolean") {
|
|
13
|
+
return Response.json({ error: "博文路径和置顶状态必须正确填写。" }, { status: 400 });
|
|
14
|
+
}
|
|
15
|
+
const post = findPost(payload.path);
|
|
16
|
+
if (!post) return Response.json({ error: "未找到对应博文。" }, { status: 404 });
|
|
17
|
+
try {
|
|
18
|
+
return Response.json({
|
|
19
|
+
post: await updateRepositoryPostPinned({
|
|
20
|
+
sourcePath: post.sourcePath,
|
|
21
|
+
slug: post.slug,
|
|
22
|
+
title: post.title,
|
|
23
|
+
pinned: payload.pinned,
|
|
24
|
+
}),
|
|
25
|
+
});
|
|
26
|
+
} catch (error) {
|
|
27
|
+
return Response.json(
|
|
28
|
+
{ error: error instanceof Error ? error.message : "置顶状态更新失败。" },
|
|
29
|
+
{ status: 503 },
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { requireAdminMutation } from "../../../../lib/admin-auth";
|
|
2
|
+
import {
|
|
3
|
+
deleteRepositoryThought,
|
|
4
|
+
updateRepositoryThought,
|
|
5
|
+
} from "../../../../lib/github-content";
|
|
6
|
+
|
|
7
|
+
export async function PATCH(
|
|
8
|
+
request: Request,
|
|
9
|
+
{ params }: { params: Promise<{ id: string }> },
|
|
10
|
+
) {
|
|
11
|
+
const user = await requireAdminMutation(request);
|
|
12
|
+
if (user instanceof Response) return user;
|
|
13
|
+
const { id } = await params;
|
|
14
|
+
const payload = (await request.json()) as { status?: unknown };
|
|
15
|
+
if (payload.status !== "draft" && payload.status !== "published") {
|
|
16
|
+
return Response.json({ error: "无效的状态。" }, { status: 400 });
|
|
17
|
+
}
|
|
18
|
+
try {
|
|
19
|
+
return Response.json({ thought: await updateRepositoryThought(id, payload.status) });
|
|
20
|
+
} catch (error) {
|
|
21
|
+
return Response.json(
|
|
22
|
+
{ error: error instanceof Error ? error.message : "更新失败,请稍后重试。" },
|
|
23
|
+
{ status: 503 },
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function DELETE(
|
|
29
|
+
request: Request,
|
|
30
|
+
{ params }: { params: Promise<{ id: string }> },
|
|
31
|
+
) {
|
|
32
|
+
const user = await requireAdminMutation(request);
|
|
33
|
+
if (user instanceof Response) return user;
|
|
34
|
+
const { id } = await params;
|
|
35
|
+
try {
|
|
36
|
+
await deleteRepositoryThought(id);
|
|
37
|
+
return Response.json({ ok: true });
|
|
38
|
+
} catch (error) {
|
|
39
|
+
return Response.json(
|
|
40
|
+
{ error: error instanceof Error ? error.message : "删除失败,请稍后重试。" },
|
|
41
|
+
{ status: 503 },
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { requireAdminApi, requireAdminMutation } from "../../../lib/admin-auth";
|
|
2
|
+
import {
|
|
3
|
+
createRepositoryThought,
|
|
4
|
+
listRepositoryThoughts,
|
|
5
|
+
} from "../../../lib/github-content";
|
|
6
|
+
import type { MediaItem } from "../../../lib/content";
|
|
7
|
+
|
|
8
|
+
type ThoughtPayload = {
|
|
9
|
+
title?: unknown;
|
|
10
|
+
content?: unknown;
|
|
11
|
+
tags?: unknown;
|
|
12
|
+
media?: unknown;
|
|
13
|
+
status?: unknown;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function createThoughtSlug() {
|
|
17
|
+
return `thought-${Date.now().toString(36)}-${crypto.randomUUID().slice(0, 6)}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isHttpUrl(value: unknown) {
|
|
21
|
+
if (typeof value !== "string") return false;
|
|
22
|
+
try {
|
|
23
|
+
const url = new URL(value);
|
|
24
|
+
return url.protocol === "https:" || url.protocol === "http:";
|
|
25
|
+
} catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function parseMedia(value: unknown): MediaItem[] | null {
|
|
31
|
+
if (!Array.isArray(value) || value.length > 12) return null;
|
|
32
|
+
const media: MediaItem[] = [];
|
|
33
|
+
for (const item of value) {
|
|
34
|
+
if (!item || typeof item !== "object") return null;
|
|
35
|
+
const candidate = item as Record<string, unknown>;
|
|
36
|
+
if (
|
|
37
|
+
!["image", "audio", "video", "link"].includes(String(candidate.type)) ||
|
|
38
|
+
!isHttpUrl(candidate.src)
|
|
39
|
+
) {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
media.push({
|
|
43
|
+
type: candidate.type as MediaItem["type"],
|
|
44
|
+
src: String(candidate.src),
|
|
45
|
+
...(typeof candidate.alt === "string" && candidate.alt.trim()
|
|
46
|
+
? { alt: candidate.alt.trim().slice(0, 200) }
|
|
47
|
+
: {}),
|
|
48
|
+
...(typeof candidate.poster === "string" && isHttpUrl(candidate.poster)
|
|
49
|
+
? { poster: candidate.poster }
|
|
50
|
+
: {}),
|
|
51
|
+
...(typeof candidate.mime === "string" && candidate.mime.trim()
|
|
52
|
+
? { mime: candidate.mime.trim().slice(0, 100) }
|
|
53
|
+
: {}),
|
|
54
|
+
...(typeof candidate.title === "string" && candidate.title.trim()
|
|
55
|
+
? { title: candidate.title.trim().slice(0, 200) }
|
|
56
|
+
: {}),
|
|
57
|
+
...(typeof candidate.description === "string" && candidate.description.trim()
|
|
58
|
+
? { description: candidate.description.trim().slice(0, 400) }
|
|
59
|
+
: {}),
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
return media;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function parsePayload(payload: ThoughtPayload) {
|
|
66
|
+
const title = typeof payload.title === "string" ? payload.title.trim() : "";
|
|
67
|
+
const content = typeof payload.content === "string" ? payload.content.trim() : "";
|
|
68
|
+
const tags = Array.isArray(payload.tags)
|
|
69
|
+
? payload.tags
|
|
70
|
+
.filter((tag): tag is string => typeof tag === "string")
|
|
71
|
+
.map((tag) => tag.trim())
|
|
72
|
+
.filter(Boolean)
|
|
73
|
+
.slice(0, 12)
|
|
74
|
+
: [];
|
|
75
|
+
const media = parseMedia(payload.media ?? []);
|
|
76
|
+
const status = payload.status === "draft" ? "draft" : "published";
|
|
77
|
+
|
|
78
|
+
if (!title || title.length > 120) return { error: "标题不能为空,且不得超过 120 个字符。" };
|
|
79
|
+
if (!content || content.length > 20_000) return { error: "正文不能为空,且不得超过 20,000 个字符。" };
|
|
80
|
+
if (!media) return { error: "附件最多 12 个,且必须是 http(s) 地址。" };
|
|
81
|
+
return { value: { title, content, tags, media, status } };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function GET() {
|
|
85
|
+
const user = await requireAdminApi();
|
|
86
|
+
if (user instanceof Response) return user;
|
|
87
|
+
try {
|
|
88
|
+
return Response.json({ thoughts: await listRepositoryThoughts() });
|
|
89
|
+
} catch (error) {
|
|
90
|
+
return Response.json(
|
|
91
|
+
{ error: error instanceof Error ? error.message : "读取 GitHub 内容失败。" },
|
|
92
|
+
{ status: 503 },
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function POST(request: Request) {
|
|
98
|
+
const user = await requireAdminMutation(request);
|
|
99
|
+
if (user instanceof Response) return user;
|
|
100
|
+
const parsed = parsePayload((await request.json()) as ThoughtPayload);
|
|
101
|
+
if ("error" in parsed) return Response.json(parsed, { status: 400 });
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
const thought = await createRepositoryThought({
|
|
105
|
+
slug: createThoughtSlug(),
|
|
106
|
+
title: parsed.value.title,
|
|
107
|
+
content: parsed.value.content,
|
|
108
|
+
tags: parsed.value.tags,
|
|
109
|
+
media: parsed.value.media,
|
|
110
|
+
status: parsed.value.status,
|
|
111
|
+
date: new Date().toISOString(),
|
|
112
|
+
});
|
|
113
|
+
return Response.json({ thought }, { status: 201 });
|
|
114
|
+
} catch (error) {
|
|
115
|
+
return Response.json(
|
|
116
|
+
{ error: error instanceof Error ? error.message : "保存到 GitHub 失败。" },
|
|
117
|
+
{ status: 503 },
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { requireAdminMutation } from "../../../lib/admin-auth";
|
|
2
|
+
import { createOssUploadPolicy } from "../../../lib/oss";
|
|
3
|
+
|
|
4
|
+
export async function POST(request: Request) {
|
|
5
|
+
const user = await requireAdminMutation(request);
|
|
6
|
+
if (user instanceof Response) return user;
|
|
7
|
+
|
|
8
|
+
try {
|
|
9
|
+
const payload = (await request.json()) as { name?: unknown; type?: unknown; size?: unknown };
|
|
10
|
+
if (typeof payload.name !== "string" || typeof payload.type !== "string" || typeof payload.size !== "number") {
|
|
11
|
+
return Response.json({ error: "附件元数据不完整。" }, { status: 400 });
|
|
12
|
+
}
|
|
13
|
+
return Response.json(await createOssUploadPolicy({ name: payload.name, type: payload.type, size: payload.size }));
|
|
14
|
+
} catch (error) {
|
|
15
|
+
return Response.json(
|
|
16
|
+
{ error: error instanceof Error ? error.message : "附件上传失败。" },
|
|
17
|
+
{ status: 503 },
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createAdminSession,
|
|
3
|
+
isGitHubLoginAllowed,
|
|
4
|
+
isGitHubOAuthConfigured,
|
|
5
|
+
sessionCookie,
|
|
6
|
+
} from "../../../../lib/admin-auth";
|
|
7
|
+
|
|
8
|
+
const STATE_COOKIE = "hedon_github_oauth_state";
|
|
9
|
+
|
|
10
|
+
function cookieValue(request: Request, name: string) {
|
|
11
|
+
return request.headers
|
|
12
|
+
.get("cookie")
|
|
13
|
+
?.split(";")
|
|
14
|
+
.map((part) => part.trim().split("="))
|
|
15
|
+
.find(([key]) => key === name)
|
|
16
|
+
?.slice(1)
|
|
17
|
+
.join("=");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function redirectToAdmin(request: Request, reason: string) {
|
|
21
|
+
const target = new URL("/admin", request.url);
|
|
22
|
+
target.searchParams.set("auth", reason);
|
|
23
|
+
return new Response(null, {
|
|
24
|
+
status: 302,
|
|
25
|
+
headers: {
|
|
26
|
+
Location: target.toString(),
|
|
27
|
+
"Set-Cookie": `${STATE_COOKIE}=; Path=/api/auth/github; HttpOnly; Secure; SameSite=Lax; Max-Age=0`,
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function deniedRedirect(request: Request, login: string) {
|
|
33
|
+
const target = new URL("/admin", request.url);
|
|
34
|
+
target.searchParams.set("auth", "denied");
|
|
35
|
+
// This is only reflected to the person who just completed GitHub OAuth. It
|
|
36
|
+
// makes an allow-list mismatch actionable without exposing tokens or secrets.
|
|
37
|
+
target.searchParams.set("account", login);
|
|
38
|
+
return new Response(null, {
|
|
39
|
+
status: 302,
|
|
40
|
+
headers: {
|
|
41
|
+
Location: target.toString(),
|
|
42
|
+
"Set-Cookie": `${STATE_COOKIE}=; Path=/api/auth/github; HttpOnly; Secure; SameSite=Lax; Max-Age=0`,
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function GET(request: Request) {
|
|
48
|
+
const url = new URL(request.url);
|
|
49
|
+
const code = url.searchParams.get("code");
|
|
50
|
+
const state = url.searchParams.get("state");
|
|
51
|
+
if (!isGitHubOAuthConfigured()) {
|
|
52
|
+
console.error("GitHub OAuth callback failed", { stage: "configuration" });
|
|
53
|
+
return redirectToAdmin(request, "failed");
|
|
54
|
+
}
|
|
55
|
+
if (!code || !state) {
|
|
56
|
+
console.error("GitHub OAuth callback failed", { stage: "parameters" });
|
|
57
|
+
return redirectToAdmin(request, "failed");
|
|
58
|
+
}
|
|
59
|
+
if (state !== cookieValue(request, STATE_COOKIE)) {
|
|
60
|
+
console.error("GitHub OAuth callback failed", { stage: "state" });
|
|
61
|
+
return redirectToAdmin(request, "failed");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let stage = "token";
|
|
65
|
+
try {
|
|
66
|
+
// GitHub's OAuth endpoint formally accepts an URL-encoded POST body.
|
|
67
|
+
// Keep provider failures inside this boundary so a rejected/expired code
|
|
68
|
+
// sends the reader back to the sign-in page instead of producing a 500.
|
|
69
|
+
const tokenResponse = await fetch("https://github.com/login/oauth/access_token", {
|
|
70
|
+
method: "POST",
|
|
71
|
+
headers: {
|
|
72
|
+
Accept: "application/json",
|
|
73
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
74
|
+
},
|
|
75
|
+
body: new URLSearchParams({
|
|
76
|
+
client_id: process.env.GITHUB_OAUTH_CLIENT_ID ?? "",
|
|
77
|
+
client_secret: process.env.GITHUB_OAUTH_CLIENT_SECRET ?? "",
|
|
78
|
+
code,
|
|
79
|
+
}),
|
|
80
|
+
});
|
|
81
|
+
const token = (await tokenResponse.json().catch(() => null)) as {
|
|
82
|
+
access_token?: string;
|
|
83
|
+
} | null;
|
|
84
|
+
if (!tokenResponse.ok || !token?.access_token) {
|
|
85
|
+
console.error("GitHub OAuth callback failed", { stage });
|
|
86
|
+
return redirectToAdmin(request, "failed");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
stage = "profile";
|
|
90
|
+
const profileResponse = await fetch("https://api.github.com/user", {
|
|
91
|
+
headers: {
|
|
92
|
+
Accept: "application/vnd.github+json",
|
|
93
|
+
Authorization: `Bearer ${token.access_token}`,
|
|
94
|
+
"User-Agent": "Markdown-Blog-Admin",
|
|
95
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
const profile = (await profileResponse.json().catch(() => null)) as {
|
|
99
|
+
login?: string;
|
|
100
|
+
avatar_url?: string;
|
|
101
|
+
} | null;
|
|
102
|
+
if (!profileResponse.ok || !profile?.login) {
|
|
103
|
+
console.error("GitHub OAuth callback failed", { stage });
|
|
104
|
+
return redirectToAdmin(request, "failed");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const login = profile.login.trim();
|
|
108
|
+
if (!isGitHubLoginAllowed(login)) return deniedRedirect(request, login);
|
|
109
|
+
|
|
110
|
+
stage = "session";
|
|
111
|
+
const session = await createAdminSession({
|
|
112
|
+
login,
|
|
113
|
+
avatarUrl: profile.avatar_url ?? `https://github.com/${encodeURIComponent(login)}.png?size=96`,
|
|
114
|
+
});
|
|
115
|
+
if (!session) {
|
|
116
|
+
console.error("GitHub OAuth callback failed", { stage });
|
|
117
|
+
return redirectToAdmin(request, "failed");
|
|
118
|
+
}
|
|
119
|
+
const headers = new Headers({ Location: new URL("/admin", request.url).toString() });
|
|
120
|
+
headers.append("Set-Cookie", sessionCookie(session));
|
|
121
|
+
headers.append(
|
|
122
|
+
"Set-Cookie",
|
|
123
|
+
`${STATE_COOKIE}=; Path=/api/auth/github; HttpOnly; Secure; SameSite=Lax; Max-Age=0`,
|
|
124
|
+
);
|
|
125
|
+
return new Response(null, {
|
|
126
|
+
status: 302,
|
|
127
|
+
headers,
|
|
128
|
+
});
|
|
129
|
+
} catch (error) {
|
|
130
|
+
console.error("GitHub OAuth callback failed", {
|
|
131
|
+
stage,
|
|
132
|
+
message: error instanceof Error ? error.message : "unknown error",
|
|
133
|
+
});
|
|
134
|
+
return redirectToAdmin(request, "failed");
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { isGitHubOAuthConfigured } from "../../../lib/admin-auth";
|
|
2
|
+
|
|
3
|
+
const STATE_COOKIE = "hedon_github_oauth_state";
|
|
4
|
+
|
|
5
|
+
function redirectUri(request: Request) {
|
|
6
|
+
return process.env.GITHUB_OAUTH_REDIRECT_URI ?? `${new URL(request.url).origin}/api/auth/github/callback`;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function GET(request: Request) {
|
|
10
|
+
if (!isGitHubOAuthConfigured()) {
|
|
11
|
+
return Response.json(
|
|
12
|
+
{ error: "GitHub OAuth 尚未配置。" },
|
|
13
|
+
{ status: 503 },
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
const state = crypto.randomUUID();
|
|
17
|
+
const authorizeUrl = new URL("https://github.com/login/oauth/authorize");
|
|
18
|
+
authorizeUrl.searchParams.set("client_id", process.env.GITHUB_OAUTH_CLIENT_ID!);
|
|
19
|
+
authorizeUrl.searchParams.set("redirect_uri", redirectUri(request));
|
|
20
|
+
authorizeUrl.searchParams.set("scope", "read:user");
|
|
21
|
+
authorizeUrl.searchParams.set("state", state);
|
|
22
|
+
return new Response(null, {
|
|
23
|
+
status: 302,
|
|
24
|
+
headers: {
|
|
25
|
+
Location: authorizeUrl.toString(),
|
|
26
|
+
"Set-Cookie": `${STATE_COOKIE}=${state}; Path=/api/auth/github; HttpOnly; Secure; SameSite=Lax; Max-Age=600`,
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { clearSessionCookie, requireAdminMutation } from "../../../lib/admin-auth";
|
|
2
|
+
|
|
3
|
+
export async function POST(request: Request) {
|
|
4
|
+
const user = await requireAdminMutation(request);
|
|
5
|
+
if (user instanceof Response) return user;
|
|
6
|
+
return new Response(null, {
|
|
7
|
+
status: 302,
|
|
8
|
+
headers: {
|
|
9
|
+
Location: new URL("/admin", request.url).toString(),
|
|
10
|
+
"Set-Cookie": clearSessionCookie(),
|
|
11
|
+
},
|
|
12
|
+
});
|
|
13
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import Link from "next/link";
|
|
2
|
+
import type { Metadata } from "next";
|
|
3
|
+
import { notFound } from "next/navigation";
|
|
4
|
+
import {
|
|
5
|
+
extractHeadings,
|
|
6
|
+
estimateWordCount,
|
|
7
|
+
findPost,
|
|
8
|
+
formatDate,
|
|
9
|
+
postHref,
|
|
10
|
+
posts,
|
|
11
|
+
} from "../../lib/content";
|
|
12
|
+
import { MarkdownArticle } from "../../components/MarkdownArticle";
|
|
13
|
+
import { TableOfContents } from "../../components/TableOfContents";
|
|
14
|
+
import { Comments } from "../../components/Comments";
|
|
15
|
+
import { Webmentions } from "../../components/Webmentions";
|
|
16
|
+
import {
|
|
17
|
+
absoluteUrl,
|
|
18
|
+
getSiteUrl,
|
|
19
|
+
webmentionApiEndpoint,
|
|
20
|
+
} from "../../lib/site";
|
|
21
|
+
import { githubUrl, siteConfig } from "../../site.config";
|
|
22
|
+
|
|
23
|
+
export function generateStaticParams() {
|
|
24
|
+
return posts.map((post) => ({ path: post.path.split("/") }));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function generateMetadata({
|
|
28
|
+
params,
|
|
29
|
+
}: {
|
|
30
|
+
params: Promise<{ path: string[] }>;
|
|
31
|
+
}) {
|
|
32
|
+
const { path } = await params;
|
|
33
|
+
const post = findPost(path.join("/"));
|
|
34
|
+
if (!post) return {};
|
|
35
|
+
const baseUrl = await getSiteUrl();
|
|
36
|
+
const pathname = postHref(post);
|
|
37
|
+
return {
|
|
38
|
+
title: post.title,
|
|
39
|
+
description: post.description,
|
|
40
|
+
alternates: { canonical: pathname },
|
|
41
|
+
openGraph: {
|
|
42
|
+
type: "article",
|
|
43
|
+
url: absoluteUrl(pathname, baseUrl),
|
|
44
|
+
title: post.title,
|
|
45
|
+
description: post.description,
|
|
46
|
+
publishedTime: new Date(post.date).toISOString(),
|
|
47
|
+
modifiedTime: post.updated
|
|
48
|
+
? new Date(post.updated).toISOString()
|
|
49
|
+
: undefined,
|
|
50
|
+
tags: post.tags,
|
|
51
|
+
images: post.cover ? [{ url: post.cover, alt: post.coverAlt }] : undefined,
|
|
52
|
+
},
|
|
53
|
+
twitter: {
|
|
54
|
+
card: "summary_large_image",
|
|
55
|
+
title: post.title,
|
|
56
|
+
description: post.description,
|
|
57
|
+
images: post.cover ? [post.cover] : ["/og.png"],
|
|
58
|
+
},
|
|
59
|
+
} satisfies Metadata;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export default async function PostPage({
|
|
63
|
+
params,
|
|
64
|
+
}: {
|
|
65
|
+
params: Promise<{ path: string[] }>;
|
|
66
|
+
}) {
|
|
67
|
+
const { path } = await params;
|
|
68
|
+
const post = findPost(path.join("/"));
|
|
69
|
+
if (!post) notFound();
|
|
70
|
+
const headings = extractHeadings(post.content);
|
|
71
|
+
const baseUrl = await getSiteUrl();
|
|
72
|
+
const pathname = postHref(post);
|
|
73
|
+
const canonicalUrl = absoluteUrl(pathname, baseUrl);
|
|
74
|
+
const wordCount = estimateWordCount(post.content);
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<main className="reading-page post-reading-page">
|
|
78
|
+
<article className="post-article h-entry">
|
|
79
|
+
<Link className="u-url sr-only" href={pathname}>
|
|
80
|
+
{post.title} 永久链接
|
|
81
|
+
</Link>
|
|
82
|
+
<a
|
|
83
|
+
className="p-author h-card sr-only"
|
|
84
|
+
href={githubUrl}
|
|
85
|
+
rel="author"
|
|
86
|
+
>
|
|
87
|
+
{siteConfig.author.name}
|
|
88
|
+
</a>
|
|
89
|
+
{post.cover && (
|
|
90
|
+
<figure className="article-hero article-cover">
|
|
91
|
+
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
92
|
+
<img
|
|
93
|
+
className="u-photo"
|
|
94
|
+
src={post.cover}
|
|
95
|
+
alt={post.coverAlt ?? `${post.title} 封面`}
|
|
96
|
+
/>
|
|
97
|
+
</figure>
|
|
98
|
+
)}
|
|
99
|
+
<header className="article-header article-summary-row">
|
|
100
|
+
<p className="eyebrow">{post.topic}</p>
|
|
101
|
+
<h1 className="p-name">{post.title}</h1>
|
|
102
|
+
<p className="article-deck p-summary">{post.description}</p>
|
|
103
|
+
<div className="article-meta">
|
|
104
|
+
<time className="dt-published" dateTime={post.date}>
|
|
105
|
+
发布于 {formatDate(post.date)}
|
|
106
|
+
</time>
|
|
107
|
+
{post.updated && <span>更新于 {formatDate(post.updated)}</span>}
|
|
108
|
+
<span>{post.readingTime}</span>
|
|
109
|
+
<span>约 {wordCount.toLocaleString("zh-CN")} 字</span>
|
|
110
|
+
</div>
|
|
111
|
+
</header>
|
|
112
|
+
<div className="article-reading-layout">
|
|
113
|
+
<div className="article-reading-column">
|
|
114
|
+
<div className="e-content">
|
|
115
|
+
<MarkdownArticle content={post.content} />
|
|
116
|
+
</div>
|
|
117
|
+
<div className="article-tags">
|
|
118
|
+
{post.tags?.map((tag) => (
|
|
119
|
+
<span className="p-category" key={tag}>
|
|
120
|
+
#{tag}
|
|
121
|
+
</span>
|
|
122
|
+
))}
|
|
123
|
+
</div>
|
|
124
|
+
<Webmentions
|
|
125
|
+
target={canonicalUrl}
|
|
126
|
+
endpoint={webmentionApiEndpoint()}
|
|
127
|
+
/>
|
|
128
|
+
<Comments slug={post.path} />
|
|
129
|
+
</div>
|
|
130
|
+
<TableOfContents headings={headings} />
|
|
131
|
+
</div>
|
|
132
|
+
</article>
|
|
133
|
+
</main>
|
|
134
|
+
);
|
|
135
|
+
}
|