diff --git a/.env.development b/.env.development index f9968d0..dbdaf8c 100644 --- a/.env.development +++ b/.env.development @@ -1 +1,6 @@ VITE_GA_API_URL= + +# 어드민(GitHub OAuth) — 로컬 개발 시 채워서 테스트. docs/admin.md 참고. +# client_id와 프록시 URL은 비밀이 아니므로 커밋해도 됩니다(client_secret은 Worker에만). +VITE_GITHUB_CLIENT_ID= +VITE_OAUTH_PROXY_URL= diff --git a/.env.production b/.env.production index 385c8ec..c852e72 100644 --- a/.env.production +++ b/.env.production @@ -1 +1,6 @@ VITE_GA_API_URL=https://script.google.com/macros/s/AKfycbya_lo-437-LxSwtBlKtuIfLZWim1cfHfB_vDPjqmqlSFq2K3w1cMhYoZqzEyQB19tm/exec + +# 어드민(GitHub OAuth) — OAuth App 생성 후 client_id, Worker 배포 후 프록시 URL을 채우세요. +# 비어 있으면 /admin 진입 시 "미설정" 안내가 표시됩니다. docs/admin.md 참고. +VITE_GITHUB_CLIENT_ID= +VITE_OAUTH_PROXY_URL= diff --git a/docs/admin.md b/docs/admin.md new file mode 100644 index 0000000..96f3369 --- /dev/null +++ b/docs/admin.md @@ -0,0 +1,104 @@ +# 어드민(관리자) 기능 셋업 가이드 + +GitHub OAuth로 로그인한 관리자가 블로그 페이지 안에서 글의 **초안(draft) ↔ 발행** 상태를 전환할 수 있는 기능입니다. + +## 동작 구조 + +``` +[블로그 SPA] [Cloudflare Worker] [GitHub] + /admin 에서 로그인 클릭 ───────────────────────────────────▶ OAuth authorize + ◀──────────── redirect /admin/callback?code=… ────────────── + code 전달 ──────────▶ client_secret으로 교환 ──▶ access_token + ◀──────────────────── access_token ──────────── + GET /user → login === devy1540 확인 → 관리 UI 노출 + draft 토글 ─────────────────────────────────▶ Contents API 커밋 + └▶ Actions 재빌드/재배포 (수 분) +``` + +> ⚠️ draft 글은 프로덕션 번들에 포함되지 않으므로, 관리 화면의 글 목록은 GitHub API로 +> 직접 조회합니다. 토글은 `.md` frontmatter의 `draft` 값을 커밋하며, **즉시 반영이 아니라 +> 재빌드 후** 사이트에 반영됩니다. + +--- + +## 1. GitHub OAuth App 등록 + +1. https://github.com/settings/developers → **New OAuth App** +2. 입력: + - **Application name**: `devy-blog-admin` (자유) + - **Homepage URL**: `https://devy1540.dev` + - **Authorization callback URL**: `https://devy1540.dev/admin/callback/` + - 로컬 테스트도 하려면 **Add another callback URL**로 `http://localhost:5173/admin/callback/` 추가 +3. 생성 후 **Client ID** 복사. **Generate a new client secret**으로 secret도 발급(한 번만 표시되니 보관). + +> client_id는 공개되어도 되는 값입니다. client_secret은 절대 프론트/저장소에 두지 마세요(Worker 전용). + +--- + +## 2. Cloudflare Worker 배포 (`oauth-proxy/`) + +```bash +cd oauth-proxy +npm install +npx wrangler login # 최초 1회 + +# 시크릿 주입 +npx wrangler secret put GITHUB_CLIENT_ID # 위에서 받은 Client ID +npx wrangler secret put GITHUB_CLIENT_SECRET # 위에서 받은 Client Secret + +npm run deploy +``` + +배포되면 `https://devy-blog-oauth-proxy..workers.dev` 같은 URL이 출력됩니다. 이 URL을 메모하세요. + +- `wrangler.toml`의 `ALLOWED_ORIGIN`이 CORS 허용 도메인입니다. 기본값은 `https://devy1540.dev`. + 로컬 테스트 시 임시로 `*`로 바꾸거나 별도 Worker를 쓰세요. + +--- + +## 3. 환경변수 설정 + +`.env.production`에 채웁니다(둘 다 공개 가능 값이라 커밋해도 됩니다): + +```dotenv +VITE_GITHUB_CLIENT_ID=Iv1.xxxxxxxxxxxx +VITE_OAUTH_PROXY_URL=https://devy-blog-oauth-proxy..workers.dev +``` + +비어 있으면 `/admin` 진입 시 "미설정" 안내가 표시됩니다(앱은 정상 빌드/동작). + +--- + +## 4. 빌드 & 배포 + +```bash +npm run build # /admin, /admin/callback 정적 셸도 함께 프리렌더됨 +git commit && git push # main 푸시 → GitHub Actions 배포 +``` + +--- + +## 5. 사용 + +1. `https://devy1540.dev/admin` 접속 → **GitHub로 로그인** +2. 인증되면 글 목록이 뜨고, 각 글의 **초안으로 / 발행하기** 버튼으로 토글 +3. 토글 시 frontmatter가 커밋되고, 재빌드 후 반영(목록 상단에 안내) +4. 로그인하면 사이드바에도 **관리자** 메뉴가 나타남 + +--- + +## 보안 메모 + +- 관리자 판정은 `src/lib/admin/config.ts`의 `ADMIN_LOGIN`(현재 `devy1540`)과 일치하는 GitHub 계정만 통과. + UI 가드일 뿐 아니라, **토큰이 없으면 어떤 변경도 불가능**하므로 실질 권한은 토큰이 강제합니다. +- OAuth scope는 `public_repo`(공개 repo 쓰기)로 최소화. +- access_token은 `sessionStorage`에 저장 → 탭을 닫으면 사라집니다. (XSS 노출 리스크는 1인 관리자 기준 수용) +- client_secret은 Worker 시크릿에만 존재. 저장소/번들 어디에도 없습니다. + +## 로컬 개발 + +```bash +# .env.development 에 client_id / 프록시 URL 채우고 +npm run dev +# OAuth App callback에 http://localhost:5173/admin/callback/ 가 등록되어 있어야 함 +``` diff --git a/oauth-proxy/.gitignore b/oauth-proxy/.gitignore new file mode 100644 index 0000000..a933f10 --- /dev/null +++ b/oauth-proxy/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +.wrangler/ +.dev.vars diff --git a/oauth-proxy/README.md b/oauth-proxy/README.md new file mode 100644 index 0000000..02318ab --- /dev/null +++ b/oauth-proxy/README.md @@ -0,0 +1,30 @@ +# oauth-proxy + +블로그 어드민 기능용 GitHub OAuth code↔token 교환 프록시 (Cloudflare Worker). + +정적 호스팅(GitHub Pages)에는 `client_secret`을 둘 수 없어, 이 Worker가 서버 사이드에서 +토큰 교환을 대신합니다. 프론트는 `code`만 POST하고 `access_token`을 돌려받습니다. + +## 배포 + +```bash +npm install +npx wrangler login +npx wrangler secret put GITHUB_CLIENT_ID +npx wrangler secret put GITHUB_CLIENT_SECRET +npm run deploy +``` + +## 바인딩 + +| 이름 | 종류 | 설명 | +|------|------|------| +| `GITHUB_CLIENT_ID` | secret | OAuth App Client ID | +| `GITHUB_CLIENT_SECRET` | secret | OAuth App Client Secret | +| `ALLOWED_ORIGIN` | var (`wrangler.toml`) | CORS 허용 도메인 (기본 `https://devy1540.dev`) | + +## API + +`POST /` — body `{ "code": "..." }` → `{ "access_token", "token_type", "scope" }` + +전체 셋업은 저장소 루트의 [`docs/admin.md`](../docs/admin.md) 참고. diff --git a/oauth-proxy/package.json b/oauth-proxy/package.json new file mode 100644 index 0000000..01ea0f4 --- /dev/null +++ b/oauth-proxy/package.json @@ -0,0 +1,13 @@ +{ + "name": "devy-blog-oauth-proxy", + "version": "1.0.0", + "private": true, + "description": "GitHub OAuth code↔token exchange proxy for the blog admin feature", + "scripts": { + "dev": "wrangler dev", + "deploy": "wrangler deploy" + }, + "devDependencies": { + "wrangler": "^3.90.0" + } +} diff --git a/oauth-proxy/src/worker.js b/oauth-proxy/src/worker.js new file mode 100644 index 0000000..8a0887a --- /dev/null +++ b/oauth-proxy/src/worker.js @@ -0,0 +1,73 @@ +/** + * GitHub OAuth code → access_token 교환 프록시 (Cloudflare Worker). + * + * 정적 호스팅(GitHub Pages)에는 client_secret을 둘 수 없으므로, 이 Worker가 + * 서버 사이드에서 토큰 교환을 대신한다. 프론트는 code만 보내고 토큰을 받는다. + * + * 필요한 바인딩(시크릿/변수): + * - GITHUB_CLIENT_ID (secret) + * - GITHUB_CLIENT_SECRET (secret) + * - ALLOWED_ORIGIN (var, 예: https://devy1540.dev) + */ + +export default { + async fetch(request, env) { + const cors = corsHeaders(env) + + if (request.method === "OPTIONS") { + return new Response(null, { status: 204, headers: cors }) + } + if (request.method !== "POST") { + return json({ error: "method_not_allowed" }, 405, cors) + } + + let body + try { + body = await request.json() + } catch { + return json({ error: "invalid_body" }, 400, cors) + } + + const code = body?.code + if (!code) { + return json({ error: "missing_code" }, 400, cors) + } + + const tokenRes = await fetch("https://github.com/login/oauth/access_token", { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify({ + client_id: env.GITHUB_CLIENT_ID, + client_secret: env.GITHUB_CLIENT_SECRET, + code, + }), + }) + + const data = await tokenRes.json().catch(() => null) + if (!data || data.error || !data.access_token) { + return json({ error: data?.error_description || data?.error || "exchange_failed" }, 400, cors) + } + + return json( + { access_token: data.access_token, token_type: data.token_type, scope: data.scope }, + 200, + cors + ) + }, +} + +function corsHeaders(env) { + return { + "Access-Control-Allow-Origin": env.ALLOWED_ORIGIN || "*", + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", + "Vary": "Origin", + } +} + +function json(payload, status, cors) { + return new Response(JSON.stringify(payload), { + status, + headers: { "Content-Type": "application/json", ...cors }, + }) +} diff --git a/oauth-proxy/wrangler.toml b/oauth-proxy/wrangler.toml new file mode 100644 index 0000000..84c76c4 --- /dev/null +++ b/oauth-proxy/wrangler.toml @@ -0,0 +1,11 @@ +name = "devy-blog-oauth-proxy" +main = "src/worker.js" +compatibility_date = "2024-11-01" + +# ALLOWED_ORIGIN은 비밀이 아니므로 여기에 둔다. 프로덕션 도메인으로 제한해 CORS를 좁힌다. +[vars] +ALLOWED_ORIGIN = "https://devy1540.dev" + +# GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET은 시크릿으로 주입한다(이 파일에 넣지 말 것): +# wrangler secret put GITHUB_CLIENT_ID +# wrangler secret put GITHUB_CLIENT_SECRET diff --git a/src/app-shell.tsx b/src/app-shell.tsx index 8f972e6..f03e7f1 100644 --- a/src/app-shell.tsx +++ b/src/app-shell.tsx @@ -1,6 +1,7 @@ import { StrictMode, type ReactNode } from "react" import { ThemeProvider } from "./hooks/useTheme" import { LanguageProvider } from "./i18n" +import { AdminAuthProvider } from "./lib/admin/useAdminAuth" import type { Language } from "./i18n" export function AppProviders({ @@ -14,7 +15,7 @@ export function AppProviders({ - {children} + {children} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index ce5f3a2..bb1ab55 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,5 +1,5 @@ import { NavLink, useLocation, useNavigate } from "react-router-dom" -import { Home, FileText, Tags, User, Library, BarChart3 } from "lucide-react" +import { Home, FileText, Tags, User, Library, BarChart3, ShieldCheck } from "lucide-react" import { Sidebar as SidebarRoot, SidebarContent, @@ -19,6 +19,7 @@ import { ColorThemeSelector } from "@/components/ColorThemeSelector" import { LanguageToggle } from "@/components/LanguageToggle" import { KeyboardShortcuts } from "@/components/KeyboardShortcuts" import { useLanguage } from "@/i18n" +import { useAdminAuth } from "@/lib/admin/useAdminAuth" import { localizePath, stripLanguagePrefix } from "@/lib/i18n-routing" const navIcons = { @@ -33,6 +34,7 @@ const navIcons = { export function AppSidebar() { const { pathname } = useLocation() const { language, t } = useLanguage() + const { isAdmin } = useAdminAuth() const navigate = useNavigate() const { isMobile, setOpenMobile } = useSidebar() @@ -91,6 +93,16 @@ export function AppSidebar() { ))} + {isAdmin && ( + + + handleMobileNav(e, "/admin")}> + + 관리자 + + + + )} diff --git a/src/entry-server.tsx b/src/entry-server.tsx index 404299c..ba89bde 100644 --- a/src/entry-server.tsx +++ b/src/entry-server.tsx @@ -185,6 +185,22 @@ export function getPrerenderRoutes(): PrerenderRoute[] { return [ ...localizedStaticRoutes("ko", koPosts), ...localizedStaticRoutes("en", enPosts), + // 어드민 진입 화면은 클라이언트 전용 동작이지만, 정적 셸을 프리렌더해서 + // SPA fallback(404.html) hydration 불일치를 피한다. 색인은 막는다(noindex). + { + path: "/admin/", + language: "ko" as const, + title: "관리자", + description: "블로그 관리자 로그인 및 글 관리.", + noindex: true, + }, + { + path: "/admin/callback/", + language: "ko" as const, + title: "로그인 처리", + description: "GitHub 로그인 처리 중입니다.", + noindex: true, + }, ...PROJECTS.map((project) => ({ path: `/about/projects/${project.slug}/`, language: "ko" as const, diff --git a/src/lib/admin/config.ts b/src/lib/admin/config.ts new file mode 100644 index 0000000..bdc9f8a --- /dev/null +++ b/src/lib/admin/config.ts @@ -0,0 +1,28 @@ +/** + * 어드민(관리자) 기능 설정. + * + * client_id와 프록시 URL은 비밀이 아니므로 빌드 타임 환경변수로 주입한다. + * (client_secret은 절대 프론트에 두지 않고 Cloudflare Worker에만 보관) + */ + +export const GITHUB_OAUTH_CLIENT_ID = import.meta.env.VITE_GITHUB_CLIENT_ID as string | undefined +export const OAUTH_PROXY_URL = import.meta.env.VITE_OAUTH_PROXY_URL as string | undefined + +/** 블로그 repo. user pages 규칙상 owner === repo 이름의 접두어다. */ +export const REPO_OWNER = "devy1540" +export const REPO_NAME = "devy1540.github.io" + +/** 관리자로 인정할 GitHub 로그인 아이디. 이 계정만 어드민 UI에 진입할 수 있다. */ +export const ADMIN_LOGIN = "devy1540" + +/** public repo 쓰기 권한. 글 frontmatter 커밋에 필요한 최소 스코프. */ +export const OAUTH_SCOPE = "public_repo" + +/** OAuth 콜백 경로. prerender 디렉터리(`/admin/callback/index.html`)와 맞추기 위해 슬래시로 끝낸다. */ +export const CALLBACK_PATH = "/admin/callback/" + +export const TOKEN_STORAGE_KEY = "admin_gh_token" +export const OAUTH_STATE_KEY = "admin_oauth_state" + +/** client_id와 프록시 URL이 모두 설정되어야 OAuth 로그인을 시도할 수 있다. */ +export const isAdminConfigured = Boolean(GITHUB_OAUTH_CLIENT_ID && OAUTH_PROXY_URL) diff --git a/src/lib/admin/github.ts b/src/lib/admin/github.ts new file mode 100644 index 0000000..742c2e1 --- /dev/null +++ b/src/lib/admin/github.ts @@ -0,0 +1,212 @@ +import type { Language } from "@/i18n" +import { REPO_NAME, REPO_OWNER } from "./config" + +const API_BASE = "https://api.github.com" +const POST_LANGUAGES: Language[] = ["ko", "en"] + +export class GitHubApiError extends Error { + status: number + constructor(message: string, status: number) { + super(message) + this.name = "GitHubApiError" + this.status = status + } +} + +export interface GitHubUser { + login: string + name: string | null + avatarUrl: string +} + +export interface PostFile { + slug: string + language: Language + path: string + /** blob sha. 커밋(PUT) 시 충돌 방지를 위해 필요하다. */ + sha: string + title: string + draft: boolean + date: string + /** 원본 마크다운 전체(frontmatter 포함). draft 토글 시 그대로 재사용한다. */ + raw: string +} + +function authHeaders(token: string): HeadersInit { + return { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + } +} + +// ── base64 (UTF-8 안전) ────────────────────────────────────────────── +// GitHub Contents API는 본문을 base64로 주고받는다. 한글 등 멀티바이트가 +// 깨지지 않도록 TextEncoder/TextDecoder를 거친다. + +function decodeBase64(b64: string): string { + const binary = atob(b64.replace(/\n/g, "")) + const bytes = Uint8Array.from(binary, (ch) => ch.charCodeAt(0)) + return new TextDecoder().decode(bytes) +} + +function encodeBase64(text: string): string { + const bytes = new TextEncoder().encode(text) + let binary = "" + bytes.forEach((b) => (binary += String.fromCharCode(b))) + return btoa(binary) +} + +// ── frontmatter ────────────────────────────────────────────────────── + +interface ParsedFrontmatter { + title?: string + date?: string + draft: boolean +} + +function parseFrontmatter(raw: string): ParsedFrontmatter { + const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/) + if (!match) return { draft: false } + + const data: Record = {} + for (const line of match[1]!.split(/\r?\n/)) { + const idx = line.indexOf(":") + if (idx === -1) continue + let value = line.slice(idx + 1).trim() + if (value.startsWith('"') && value.endsWith('"')) value = value.slice(1, -1) + data[line.slice(0, idx).trim()] = value + } + + return { + title: data.title, + date: data.date, + draft: data.draft === "true", + } +} + +/** + * frontmatter의 `draft` 값을 설정한다. 기존 내용/순서는 보존한다. + * - draft 라인이 있으면 값만 교체 + * - 없으면 frontmatter 끝에 추가 + * - frontmatter 자체가 없으면 새로 만들어 앞에 붙임 + */ +export function setDraftInFrontmatter(raw: string, draft: boolean): string { + const match = raw.match(/^(---\r?\n)([\s\S]*?)(\r?\n---\r?\n?)([\s\S]*)$/) + if (!match) { + return `---\ndraft: ${draft}\n---\n\n${raw}` + } + + const [, open, body, close, rest] = match + const lines = body!.split(/\r?\n/) + let replaced = false + const nextLines = lines.map((line) => { + if (/^\s*draft\s*:/.test(line)) { + replaced = true + return `draft: ${draft}` + } + return line + }) + if (!replaced) nextLines.push(`draft: ${draft}`) + + return `${open}${nextLines.join("\n")}${close}${rest}` +} + +// ── API ────────────────────────────────────────────────────────────── + +export async function fetchAuthenticatedUser(token: string): Promise { + const res = await fetch(`${API_BASE}/user`, { headers: authHeaders(token) }) + if (!res.ok) { + throw new GitHubApiError("GitHub 사용자 정보를 가져오지 못했습니다.", res.status) + } + const data = await res.json() + return { login: data.login, name: data.name ?? null, avatarUrl: data.avatar_url } +} + +interface ContentEntry { + type: string + name: string + path: string + sha: string +} + +async function listDirectory(token: string, dir: string): Promise { + const res = await fetch(`${API_BASE}/repos/${REPO_OWNER}/${REPO_NAME}/contents/${dir}`, { + headers: authHeaders(token), + }) + if (res.status === 404) return [] + if (!res.ok) throw new GitHubApiError(`디렉터리 조회에 실패했습니다: ${dir}`, res.status) + return res.json() +} + +async function getFile(token: string, path: string): Promise<{ content: string; sha: string }> { + const res = await fetch(`${API_BASE}/repos/${REPO_OWNER}/${REPO_NAME}/contents/${encodeURI(path)}`, { + headers: authHeaders(token), + }) + if (!res.ok) throw new GitHubApiError(`파일 조회에 실패했습니다: ${path}`, res.status) + const data = await res.json() + return { content: decodeBase64(data.content), sha: data.sha } +} + +/** + * 전체 글(발행 + 초안)을 frontmatter와 함께 조회한다. + * draft 글은 프로덕션 번들에 없으므로 GitHub API에서 직접 읽어야 한다. + */ +export async function listPostFiles(token: string): Promise { + const result: PostFile[] = [] + + for (const language of POST_LANGUAGES) { + const entries = (await listDirectory(token, `content/posts/${language}`)).filter( + (entry) => entry.type === "file" && entry.name.endsWith(".md") + ) + + const files = await Promise.all( + entries.map(async (entry) => { + const { content, sha } = await getFile(token, entry.path) + const fm = parseFrontmatter(content) + const slug = entry.name.replace(/\.md$/, "") + return { + slug, + language, + path: entry.path, + sha, + title: fm.title || slug, + draft: fm.draft, + date: fm.date || "", + raw: content, + } satisfies PostFile + }) + ) + + result.push(...files) + } + + return result.sort((a, b) => (a.date > b.date ? -1 : a.date < b.date ? 1 : a.slug.localeCompare(b.slug))) +} + +/** + * 글의 draft 상태를 토글하고 커밋한다. 성공 시 갱신된 blob sha를 돌려준다. + * 커밋 → GitHub Actions 재빌드 → 재배포를 거쳐야 실제 사이트에 반영된다. + */ +export async function setPostDraft( + token: string, + file: PostFile, + draft: boolean +): Promise<{ sha: string; raw: string }> { + const raw = setDraftInFrontmatter(file.raw, draft) + const message = `chore(post): ${file.language}/${file.slug} draft ${draft ? "설정" : "해제"}` + + const res = await fetch(`${API_BASE}/repos/${REPO_OWNER}/${REPO_NAME}/contents/${encodeURI(file.path)}`, { + method: "PUT", + headers: { ...authHeaders(token), "Content-Type": "application/json" }, + body: JSON.stringify({ message, content: encodeBase64(raw), sha: file.sha }), + }) + + if (!res.ok) { + const detail = await res.json().catch(() => null) + throw new GitHubApiError(detail?.message || `커밋에 실패했습니다 (${res.status}).`, res.status) + } + + const data = await res.json() + return { sha: data.content.sha as string, raw } +} diff --git a/src/lib/admin/useAdminAuth.tsx b/src/lib/admin/useAdminAuth.tsx new file mode 100644 index 0000000..34dd46b --- /dev/null +++ b/src/lib/admin/useAdminAuth.tsx @@ -0,0 +1,175 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useState, + type ReactNode, +} from "react" +import { fetchAuthenticatedUser, type GitHubUser } from "./github" +import { + ADMIN_LOGIN, + CALLBACK_PATH, + GITHUB_OAUTH_CLIENT_ID, + OAUTH_PROXY_URL, + OAUTH_SCOPE, + OAUTH_STATE_KEY, + TOKEN_STORAGE_KEY, + isAdminConfigured, +} from "./config" + +type AdminStatus = "idle" | "loading" | "authenticated" | "error" + +interface AdminAuthValue { + user: GitHubUser | null + token: string | null + status: AdminStatus + error: string | null + /** client_id/프록시 URL이 빌드에 주입되었는지 */ + isConfigured: boolean + /** 인증되었고, 로그인 아이디가 허용된 관리자와 일치하는지 */ + isAdmin: boolean + /** GitHub OAuth authorize 화면으로 이동 */ + login: () => void + logout: () => void + /** 콜백에서 받은 code를 프록시를 통해 토큰으로 교환 */ + exchangeCode: (code: string, state: string) => Promise +} + +const AdminAuthContext = createContext(null) + +function readStoredToken(): string | null { + if (typeof window === "undefined") return null + try { + return window.sessionStorage.getItem(TOKEN_STORAGE_KEY) + } catch { + return null + } +} + +function writeStoredToken(token: string | null) { + if (typeof window === "undefined") return + try { + if (token) window.sessionStorage.setItem(TOKEN_STORAGE_KEY, token) + else window.sessionStorage.removeItem(TOKEN_STORAGE_KEY) + } catch { + // 저장 실패는 무시 — 메모리 상태만으로도 동작한다. + } +} + +export function AdminAuthProvider({ children }: { children: ReactNode }) { + const [token, setToken] = useState(null) + const [user, setUser] = useState(null) + const [status, setStatus] = useState("idle") + const [error, setError] = useState(null) + + // 마운트 시 sessionStorage의 토큰으로 세션 복원(클라이언트 전용). + // 초기 렌더 출력은 항상 로그아웃 상태라 SSR 결과와 일치한다(hydration 안전). + useEffect(() => { + const stored = readStoredToken() + if (!stored) return + + setToken(stored) + setStatus("loading") + fetchAuthenticatedUser(stored) + .then((nextUser) => { + setUser(nextUser) + setStatus("authenticated") + }) + .catch(() => { + writeStoredToken(null) + setToken(null) + setUser(null) + setStatus("idle") + }) + }, []) + + const login = useCallback(() => { + if (!isAdminConfigured || typeof window === "undefined") return + + const state = crypto.randomUUID() + try { + window.sessionStorage.setItem(OAUTH_STATE_KEY, state) + } catch { + // sessionStorage를 못 쓰면 state 검증이 불가능하므로 진행하지 않는다. + setError("브라우저 저장소를 사용할 수 없어 로그인할 수 없습니다.") + setStatus("error") + return + } + + const authorizeUrl = new URL("https://github.com/login/oauth/authorize") + authorizeUrl.searchParams.set("client_id", GITHUB_OAUTH_CLIENT_ID!) + authorizeUrl.searchParams.set("redirect_uri", `${window.location.origin}${CALLBACK_PATH}`) + authorizeUrl.searchParams.set("scope", OAUTH_SCOPE) + authorizeUrl.searchParams.set("state", state) + window.location.href = authorizeUrl.toString() + }, []) + + const logout = useCallback(() => { + writeStoredToken(null) + setToken(null) + setUser(null) + setStatus("idle") + setError(null) + }, []) + + const exchangeCode = useCallback(async (code: string, state: string) => { + setStatus("loading") + setError(null) + try { + const savedState = window.sessionStorage.getItem(OAUTH_STATE_KEY) + window.sessionStorage.removeItem(OAUTH_STATE_KEY) + if (!savedState || savedState !== state) { + throw new Error("인증 상태(state)가 일치하지 않습니다. 다시 로그인해 주세요.") + } + + const res = await fetch(OAUTH_PROXY_URL!, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ code }), + }) + const data = await res.json().catch(() => null) + if (!res.ok || !data?.access_token) { + throw new Error(data?.error || "토큰 교환에 실패했습니다.") + } + + const accessToken = data.access_token as string + const nextUser = await fetchAuthenticatedUser(accessToken) + writeStoredToken(accessToken) + setToken(accessToken) + setUser(nextUser) + setStatus("authenticated") + } catch (err) { + setError(err instanceof Error ? err.message : "로그인에 실패했습니다.") + setStatus("error") + } + }, []) + + const isAdmin = status === "authenticated" && user?.login === ADMIN_LOGIN + + return ( + + {children} + + ) +} + +export function useAdminAuth() { + const context = useContext(AdminAuthContext) + if (!context) { + throw new Error("useAdminAuth must be used within an AdminAuthProvider") + } + return context +} diff --git a/src/pages/admin/AdminCallbackPage.tsx b/src/pages/admin/AdminCallbackPage.tsx new file mode 100644 index 0000000..c8846cc --- /dev/null +++ b/src/pages/admin/AdminCallbackPage.tsx @@ -0,0 +1,61 @@ +import { useEffect, useRef, useState } from "react" +import { Link, useNavigate, useSearchParams } from "react-router-dom" +import { AlertTriangle, Loader2 } from "lucide-react" +import { useAdminAuth } from "@/lib/admin/useAdminAuth" +import { useMetaTags } from "@/hooks/useMetaTags" +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" +import { Button } from "@/components/ui/button" + +export function AdminCallbackPage() { + useMetaTags({ title: "로그인 처리", noindex: true }) + const [searchParams] = useSearchParams() + const navigate = useNavigate() + const { status, error, exchangeCode } = useAdminAuth() + const startedRef = useRef(false) + // 초기 렌더는 항상 스피너로 고정한다. 쿼리 누락/실패 판정은 마운트 후에만 하여, + // 쿼리 없이 프리렌더된 정적 셸과 런타임(?code=…) 초기 렌더가 일치하도록 한다. + const [missingParams, setMissingParams] = useState(false) + + const code = searchParams.get("code") + const state = searchParams.get("state") + + // code → token 교환은 한 번만 시도(코드는 1회용, StrictMode 이중 실행 방지). + useEffect(() => { + if (startedRef.current) return + startedRef.current = true + if (!code || !state) { + setMissingParams(true) + return + } + void exchangeCode(code, state) + }, [code, state, exchangeCode]) + + // 인증 성공 시 관리자 페이지로 이동 + useEffect(() => { + if (status === "authenticated") navigate("/admin", { replace: true }) + }, [status, navigate]) + + const failed = missingParams || status === "error" + + return ( +
+ {failed ? ( + + + 로그인을 완료하지 못했습니다 + +

{missingParams ? "인증 정보가 누락되었습니다." : error}

+ +
+
+ ) : ( +
+ + 로그인을 처리하는 중… +
+ )} +
+ ) +} diff --git a/src/pages/admin/AdminPage.tsx b/src/pages/admin/AdminPage.tsx new file mode 100644 index 0000000..62285c9 --- /dev/null +++ b/src/pages/admin/AdminPage.tsx @@ -0,0 +1,245 @@ +import { useCallback, useEffect, useState } from "react" +import { AlertTriangle, LogIn, Loader2, LogOut, RefreshCw, ShieldCheck } from "lucide-react" +import { useAdminAuth } from "@/lib/admin/useAdminAuth" +import { GitHubApiError, listPostFiles, setPostDraft, type PostFile } from "@/lib/admin/github" +import { useMetaTags } from "@/hooks/useMetaTags" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Skeleton } from "@/components/ui/skeleton" + +export function AdminPage() { + useMetaTags({ title: "관리자", noindex: true }) + const { status, error, isConfigured, isAdmin, user, login, logout } = useAdminAuth() + + if (!isConfigured) { + return ( + + + + 어드민 기능이 설정되지 않았습니다 + + VITE_GITHUB_CLIENT_ID, VITE_OAUTH_PROXY_URL 환경변수가 필요합니다. + 설정 방법은 docs/admin.md를 참고하세요. + + + + ) + } + + if (status === "loading") { + return ( + +
+ + 인증 정보를 확인하는 중… +
+
+ ) + } + + if (isAdmin) { + return ( + }> + + + ) + } + + // 인증은 됐지만 허용된 관리자가 아닌 경우 + if (user) { + return ( + }> + + + 접근 권한이 없습니다 + + {user.login} 계정은 이 블로그의 관리자가 아닙니다. + + + + ) + } + + // 로그아웃 상태 — 로그인 화면 + return ( + + + + 관리자 로그인 + GitHub 계정으로 로그인하면 글의 초안/발행 상태를 관리할 수 있습니다. + + + + {status === "error" && error && ( + + + 로그인 실패 + {error} + + )} + + + + ) +} + +function Shell({ children, action }: { children: React.ReactNode; action?: React.ReactNode }) { + return ( +
+
+

+ + 관리자 +

+ {action} +
+ {children} +
+ ) +} + +function LogoutButton({ + onLogout, + user, + avatarUrl, +}: { + onLogout: () => void + user: string + avatarUrl?: string +}) { + return ( +
+
+ + + {user.slice(0, 1).toUpperCase()} + + {user} +
+ +
+ ) +} + +function DraftManager() { + const { token } = useAdminAuth() + const [posts, setPosts] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [pendingPath, setPendingPath] = useState(null) + const [notice, setNotice] = useState(null) + + const load = useCallback(async () => { + if (!token) return + setLoading(true) + setError(null) + try { + setPosts(await listPostFiles(token)) + } catch (err) { + setError(err instanceof GitHubApiError ? err.message : "글 목록을 불러오지 못했습니다.") + } finally { + setLoading(false) + } + }, [token]) + + useEffect(() => { + void load() + }, [load]) + + async function handleToggle(file: PostFile) { + if (!token) return + setPendingPath(file.path) + setNotice(null) + setError(null) + try { + const { sha, raw } = await setPostDraft(token, file, !file.draft) + setPosts((prev) => + prev.map((p) => (p.path === file.path ? { ...p, draft: !file.draft, sha, raw } : p)) + ) + setNotice( + `'${file.title}' 글을 ${!file.draft ? "초안으로 전환" : "발행"}했습니다. 재배포까지 수 분 정도 걸립니다.` + ) + } catch (err) { + setError(err instanceof GitHubApiError ? err.message : "변경에 실패했습니다.") + } finally { + setPendingPath(null) + } + } + + return ( +
+
+

+ 토글하면 frontmatter의 draft 값이 커밋되고, GitHub Actions 재빌드 후 반영됩니다. +

+ +
+ + {notice && ( + + + 커밋 완료 + {notice} + + )} + {error && ( + + + 오류 + {error} + + )} + + {loading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ ) : posts.length === 0 ? ( +

글이 없습니다.

+ ) : ( +
    + {posts.map((post) => ( +
  • +
    +
    + {post.language.toUpperCase()} + + {post.draft ? "초안" : "발행"} + +
    +

    {post.title}

    +

    + {post.slug} + {post.date ? ` · ${post.date}` : ""} +

    +
    + +
  • + ))} +
+ )} +
+ ) +} diff --git a/src/routes.server.tsx b/src/routes.server.tsx index e3d7b4a..1575840 100644 --- a/src/routes.server.tsx +++ b/src/routes.server.tsx @@ -12,6 +12,8 @@ import { AnalyticsPage } from "./pages/AnalyticsPage" import { AboutPage } from "./pages/AboutPage" import { ProjectDetailPage } from "./pages/ProjectDetailPage" import { PrivacyPage } from "./pages/PrivacyPage" +import { AdminPage } from "./pages/admin/AdminPage" +import { AdminCallbackPage } from "./pages/admin/AdminCallbackPage" export function createServerRoutes(): RouteObject[] { const childRoutes: RouteObject[] = [ @@ -34,6 +36,8 @@ export function createServerRoutes(): RouteObject[] { children: [ ...childRoutes, { path: "en", children: childRoutes }, + { path: "admin", element: }, + { path: "admin/callback", element: }, { path: "*", element: }, ], }, diff --git a/src/routes.tsx b/src/routes.tsx index 7e3be83..b2edd51 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -16,6 +16,8 @@ type RouteComponentKey = | "about" | "project" | "privacy" + | "admin" + | "adminCallback" type ResolvedRouteComponents = Partial> @@ -29,6 +31,8 @@ const routeComponentLoaders: Record Promise<{ Component about: () => import("./pages/AboutPage").then((module) => ({ Component: module.AboutPage })), project: () => import("./pages/ProjectDetailPage").then((module) => ({ Component: module.ProjectDetailPage })), privacy: () => import("./pages/PrivacyPage").then((module) => ({ Component: module.PrivacyPage })), + admin: () => import("./pages/admin/AdminPage").then((module) => ({ Component: module.AdminPage })), + adminCallback: () => import("./pages/admin/AdminCallbackPage").then((module) => ({ Component: module.AdminCallbackPage })), } function routeComponent(key: RouteComponentKey, resolvedComponents: ResolvedRouteComponents) { @@ -47,6 +51,8 @@ function getRouteComponentKey(pathname: string): RouteComponentKey | null { if (path === "/about") return "about" if (path.startsWith("/about/projects/")) return "project" if (path === "/privacy") return "privacy" + if (path === "/admin") return "admin" + if (path === "/admin/callback") return "adminCallback" return null } @@ -79,6 +85,8 @@ export function createRoutes(resolvedComponents: ResolvedRouteComponents = {}): children: [ ...childRoutes, { path: "en", children: childRoutes }, + { path: "admin", ...routeComponent("admin", resolvedComponents) }, + { path: "admin/callback", ...routeComponent("adminCallback", resolvedComponents) }, { path: "*", element: }, ], }, diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 11f02fe..bea6af7 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -1 +1,14 @@ /// + +interface ImportMetaEnv { + /** Google Apps Script API URL (조회수 조회) */ + readonly VITE_GA_API_URL?: string + /** GitHub OAuth App client_id (어드민 로그인) */ + readonly VITE_GITHUB_CLIENT_ID?: string + /** OAuth code↔token 교환 프록시(Cloudflare Worker) URL */ + readonly VITE_OAUTH_PROXY_URL?: string +} + +interface ImportMeta { + readonly env: ImportMetaEnv +}