import { randomBytes } from "node:crypto"; import { db } from "./db.mjs"; import { json, readBody, redirect } from "./http.mjs"; import { getRequestSession } from "./auth.mjs"; import { createSession, sessionCookie, updateSession } from "./session.mjs"; const oauthStates = new Map(); function getBaseUrl(request) { const configuredBaseUrl = process.env.GITHUB_OAUTH_REDIRECT_BASE_URL; if (configuredBaseUrl) return configuredBaseUrl.replace(/\/$/, ""); const host = request.headers.host ?? "localhost:5173"; const protocol = host.startsWith("localhost") || host.startsWith("127.0.0.1") ? "http" : "https"; return `${protocol}://${host}`; } function getOAuthConfig(request) { const clientId = process.env.GITHUB_CLIENT_ID; const clientSecret = process.env.GITHUB_CLIENT_SECRET; const baseUrl = getBaseUrl(request); if (!clientId || !clientSecret) { throw new Error("请先配置 GITHUB_CLIENT_ID 和 GITHUB_CLIENT_SECRET"); } return { clientId, clientSecret, redirectUri: `${baseUrl}/api/github/callback`, }; } async function githubFetch(session, path, init = {}) { const response = await fetch(`https://api.github.com${path}`, { ...init, headers: { Accept: "application/vnd.github+json", Authorization: `Bearer ${session.accessToken}`, "X-GitHub-Api-Version": "2022-11-28", ...init.headers, }, }); if (!response.ok) { const details = await response.text(); throw new Error(`GitHub API error ${response.status}: ${details}`); } return response.json(); } function requireSession(request, response) { const { session } = getRequestSession(request); if (!session?.github?.accessToken) { json(response, 401, { error: "Not signed in" }); return undefined; } return { ...session, accessToken: session.github.accessToken, }; } function publicCmsUser(user) { return { id: user.id, email: user.email, name: user.name, avatarUrl: user.avatar_url, role: user.role, }; } function findOrCreateGithubUser(githubUser, accessToken, currentSession) { const providerUserId = String(githubUser.id); const linkedAccount = db .prepare("SELECT user_id FROM oauth_accounts WHERE provider = 'github' AND provider_user_id = ?") .get(providerUserId); let user; if (linkedAccount) { user = db.prepare("SELECT * FROM users WHERE id = ?").get(linkedAccount.user_id); } else if (currentSession?.user?.id) { user = db.prepare("SELECT * FROM users WHERE id = ?").get(currentSession.user.id); } else { const email = githubUser.email ? String(githubUser.email).toLowerCase() : null; const existingByEmail = email ? db.prepare("SELECT * FROM users WHERE email = ?").get(email) : null; if (existingByEmail) { user = existingByEmail; } else { const result = db .prepare("INSERT INTO users (email, name, avatar_url, role, email_verified) VALUES (?, ?, ?, 'user', ?)") .run(email, githubUser.name || githubUser.login, githubUser.avatar_url, email ? 1 : 0); user = db.prepare("SELECT * FROM users WHERE id = ?").get(result.lastInsertRowid); } } db.prepare(` INSERT INTO oauth_accounts (user_id, provider, provider_user_id, provider_login, access_token, avatar_url, updated_at) VALUES (?, 'github', ?, ?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT(provider, provider_user_id) DO UPDATE SET user_id = excluded.user_id, provider_login = excluded.provider_login, access_token = excluded.access_token, avatar_url = excluded.avatar_url, updated_at = CURRENT_TIMESTAMP `).run(user.id, providerUserId, githubUser.login, accessToken, githubUser.avatar_url); db.prepare("UPDATE users SET avatar_url = COALESCE(avatar_url, ?), updated_at = CURRENT_TIMESTAMP WHERE id = ?").run( githubUser.avatar_url, user.id, ); return db.prepare("SELECT * FROM users WHERE id = ?").get(user.id); } async function handleLogin(request, response) { const { clientId, redirectUri } = getOAuthConfig(request); const state = randomBytes(16).toString("hex"); oauthStates.set(state, Date.now()); const params = new URLSearchParams({ client_id: clientId, redirect_uri: redirectUri, scope: "repo", state, }); redirect(response, `https://github.com/login/oauth/authorize?${params.toString()}`); } async function handleCallback(request, response, url) { const code = url.searchParams.get("code"); const state = url.searchParams.get("state"); if (!code || !state || !oauthStates.has(state)) { redirect(response, "/?github=oauth_error"); return; } oauthStates.delete(state); const { clientId, clientSecret, redirectUri } = getOAuthConfig(request); const tokenResponse = await fetch("https://github.com/login/oauth/access_token", { method: "POST", headers: { Accept: "application/json", "Content-Type": "application/json", }, body: JSON.stringify({ client_id: clientId, client_secret: clientSecret, code, redirect_uri: redirectUri, }), }); const tokenPayload = await tokenResponse.json(); if (!tokenPayload.access_token) { redirect(response, "/?github=token_error"); return; } const userResponse = await fetch("https://api.github.com/user", { headers: { Accept: "application/vnd.github+json", Authorization: `Bearer ${tokenPayload.access_token}`, "X-GitHub-Api-Version": "2022-11-28", }, }); const user = await userResponse.json(); const { session: currentSession } = getRequestSession(request); const cmsUser = findOrCreateGithubUser(user, tokenPayload.access_token, currentSession); const sessionId = createSession({ user: publicCmsUser(cmsUser), github: { accessToken: tokenPayload.access_token, login: user.login, avatarUrl: user.avatar_url, name: user.name, }, }); redirect(response, "/?github=connected", { "Set-Cookie": sessionCookie(sessionId), }); } async function handleMe(request, response) { const { session } = getRequestSession(request); if (!session) { json(response, 200, { user: null }); return; } json(response, 200, { user: session.github ?? null }); } async function handleLogout(request, response) { const { sessionId, session } = getRequestSession(request); if (session) { const { github, ...restSession } = session; updateSession(sessionId, restSession); } json(response, 200, { ok: true }); } async function handleRepos(request, response) { const session = requireSession(request, response); if (!session) return; const repos = await githubFetch( session, "/user/repos?per_page=100&sort=updated&affiliation=owner,collaborator,organization_member", ); json(response, 200, { repos: repos.map((repo) => ({ id: repo.id, fullName: repo.full_name, owner: repo.owner.login, name: repo.name, private: repo.private, defaultBranch: repo.default_branch, updatedAt: repo.updated_at, })), }); } async function handleCreateRepo(request, response) { const session = requireSession(request, response); if (!session) return; const body = JSON.parse(await readBody(request) || "{}"); if (!body.name) { json(response, 400, { error: "Repository name is required" }); return; } const repo = await githubFetch(session, "/user/repos", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ name: body.name, private: Boolean(body.private), auto_init: true, description: body.description || "Created by VitePress-CMS", }), }); json(response, 201, { repo: { id: repo.id, fullName: repo.full_name, owner: repo.owner.login, name: repo.name, private: repo.private, defaultBranch: repo.default_branch, updatedAt: repo.updated_at, }, }); } function normalizeSiteRoot(siteRoot = "") { return siteRoot.trim().replace(/^\/+|\/+$/g, ""); } function toRepoPath(siteRoot, path) { const normalizedRoot = normalizeSiteRoot(siteRoot); return normalizedRoot ? `${normalizedRoot}/${path}` : path; } function fromRepoPath(siteRoot, path) { const normalizedRoot = normalizeSiteRoot(siteRoot); return normalizedRoot && path.startsWith(`${normalizedRoot}/`) ? path.slice(normalizedRoot.length + 1) : path; } function decodeBase64Content(content) { return Buffer.from(content.replace(/\n/g, ""), "base64").toString("utf-8"); } function encodeBase64Content(content) { return Buffer.from(content, "utf-8").toString("base64"); } function parseFrontmatter(source) { const match = source.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/); if (!match) { return { title: "", description: "", content: source, }; } const frontmatter = match[1]; const title = frontmatter.match(/^title:\s*(.+)$/m)?.[1]?.trim().replace(/^["']|["']$/g, "") ?? ""; const description = frontmatter.match(/^description:\s*(.+)$/m)?.[1]?.trim().replace(/^["']|["']$/g, "") ?? ""; return { title, description, content: source.slice(match[0].length), }; } function stringifyMarkdownPage(page) { return [ "---", `title: ${page.title || "未命名页面"}`, `description: ${page.description || ""}`, "---", "", page.content.trimStart(), ].join("\n"); } function readStringValue(source, key, fallback = "") { const pattern = new RegExp(`${key}:\\s*["'\`]([^"'\`]+)["'\`]`); return source.match(pattern)?.[1] ?? fallback; } function readBooleanValue(source, key, fallback = false) { const pattern = new RegExp(`${key}:\\s*(true|false)`); const value = source.match(pattern)?.[1]; return value ? value === "true" : fallback; } function parseSiteSettings(source) { const socialLinksSource = source.match(/socialLinks:\s*\[([\s\S]*?)\]/)?.[1] ?? ""; return { title: readStringValue(source, "title", "VitePress Site"), description: readStringValue(source, "description"), logo: readStringValue(source, "logo", "/logo.svg"), lastUpdated: readBooleanValue(source, "lastUpdated", true), localSearch: /search:\s*{[\s\S]*?provider:\s*["']local["'][\s\S]*?}/.test(source), outline: readBooleanValue(source, "outline", true), socialKind: readStringValue(socialLinksSource, "icon", "github"), socialLink: readStringValue(socialLinksSource, "link", "https://github.com/example/vitepress-cms"), }; } function stringifySiteConfig(settings) { const searchConfig = settings.localSearch ? ` search: { provider: "local", },` : ""; return `import { defineConfig } from "vitepress"; export default defineConfig({ title: ${JSON.stringify(settings.title)}, description: ${JSON.stringify(settings.description)}, lastUpdated: ${settings.lastUpdated}, themeConfig: { logo: ${JSON.stringify(settings.logo)}, outline: ${settings.outline}, nav: [ { text: "首页", link: "/" }, { text: "指南", link: "/guide/getting-started" }, { text: "配置", link: "/guide/config" }, { text: "更新日志", link: "/changelog" }, ], sidebar: { "/guide/": [ { text: "指南", items: [ { text: "快速开始", link: "/guide/getting-started" }, { text: "页面管理", link: "/guide/pages" }, { text: "配置说明", link: "/guide/config" }, ], }, ], }, socialLinks: [ { icon: ${JSON.stringify(settings.socialKind)}, link: ${JSON.stringify(settings.socialLink)} }, ], footer: { message: "Powered by VitePress-CMS", copyright: "Copyright 2026", }, ${searchConfig} }, }); `; } function getSiteQuery(url) { const owner = url.searchParams.get("owner"); const repo = url.searchParams.get("repo"); const branch = url.searchParams.get("branch") || "main"; const siteRoot = url.searchParams.get("siteRoot") || ""; if (!owner || !repo) { throw new Error("owner and repo are required"); } return { owner, repo, branch, siteRoot }; } async function getRepoContent(session, site, path) { const repoPath = toRepoPath(site.siteRoot, path); const encodedPath = repoPath.split("/").map(encodeURIComponent).join("/"); return githubFetch( session, `/repos/${site.owner}/${site.repo}/contents/${encodedPath}?ref=${encodeURIComponent(site.branch)}`, ); } async function handleSitePages(request, response, url) { const session = requireSession(request, response); if (!session) return; if (request.method === "GET") { const site = getSiteQuery(url); const tree = await githubFetch( session, `/repos/${site.owner}/${site.repo}/git/trees/${encodeURIComponent(site.branch)}?recursive=1`, ); const siteRoot = normalizeSiteRoot(site.siteRoot); const prefix = siteRoot ? `${siteRoot}/` : ""; const markdownFiles = tree.tree .filter((entry) => entry.type === "blob") .filter((entry) => entry.path.endsWith(".md")) .filter((entry) => !entry.path.includes("/node_modules/")) .filter((entry) => entry.path !== `${prefix}README.md`) .filter((entry) => !prefix || entry.path.startsWith(prefix)); const pages = await Promise.all( markdownFiles.sort((a, b) => a.path.localeCompare(b.path)).map(async (entry) => { const file = await getRepoContent(session, site, fromRepoPath(site.siteRoot, entry.path)); const source = decodeBase64Content(file.content); const parsed = parseFrontmatter(source); const path = fromRepoPath(site.siteRoot, entry.path); return { id: path, path, sha: file.sha, title: parsed.title || path.replace(/\.md$/, ""), description: parsed.description, content: parsed.content, }; }), ); json(response, 200, { pages }); return; } if (request.method === "PUT") { const body = JSON.parse(await readBody(request) || "{}"); const site = body.site; const page = body.page; const repoPath = toRepoPath(site.siteRoot, page.path); const encodedPath = repoPath.split("/").map(encodeURIComponent).join("/"); const result = await githubFetch(session, `/repos/${site.owner}/${site.repo}/contents/${encodedPath}`, { method: "PUT", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ message: `docs: update ${page.path} from VitePress-CMS`, content: encodeBase64Content(stringifyMarkdownPage(page)), branch: site.branch, sha: page.sha, }), }); json(response, 200, { sha: result.content.sha }); return; } json(response, 405, { error: "Method not allowed" }); } async function handleSiteSettings(request, response, url) { const session = requireSession(request, response); if (!session) return; if (request.method === "GET") { const site = getSiteQuery(url); const file = await getRepoContent(session, site, ".vitepress/config.ts"); json(response, 200, { settings: parseSiteSettings(decodeBase64Content(file.content)), sha: file.sha, }); return; } if (request.method === "PUT") { const body = JSON.parse(await readBody(request) || "{}"); const site = body.site; const settings = body.settings; const repoPath = toRepoPath(site.siteRoot, ".vitepress/config.ts"); const encodedPath = repoPath.split("/").map(encodeURIComponent).join("/"); const result = await githubFetch(session, `/repos/${site.owner}/${site.repo}/contents/${encodedPath}`, { method: "PUT", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ message: "docs: update VitePress config from VitePress-CMS", content: encodeBase64Content(stringifySiteConfig(settings)), branch: site.branch, sha: body.sha, }), }); json(response, 200, { sha: result.content.sha }); return; } json(response, 405, { error: "Method not allowed" }); } export async function handleGithubApi(request, response, url) { try { if (url.pathname === "/api/github/login" && request.method === "GET") { await handleLogin(request, response); return true; } if (url.pathname === "/api/github/callback" && request.method === "GET") { await handleCallback(request, response, url); return true; } if (url.pathname === "/api/github/me" && request.method === "GET") { await handleMe(request, response); return true; } if (url.pathname === "/api/github/logout" && request.method === "POST") { await handleLogout(request, response); return true; } if (url.pathname === "/api/github/repos" && request.method === "GET") { await handleRepos(request, response); return true; } if (url.pathname === "/api/github/repos" && request.method === "POST") { await handleCreateRepo(request, response); return true; } if (url.pathname === "/api/github/site/pages") { await handleSitePages(request, response, url); return true; } if (url.pathname === "/api/github/site/settings") { await handleSiteSettings(request, response, url); return true; } return false; } catch (error) { json(response, 500, { error: error instanceof Error ? error.message : "Unknown GitHub API error", }); return true; } }