Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions .env.development
Original file line number Diff line number Diff line change
@@ -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=
5 changes: 5 additions & 0 deletions .env.production
Original file line number Diff line number Diff line change
@@ -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=
104 changes: 104 additions & 0 deletions docs/admin.md
Original file line number Diff line number Diff line change
@@ -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.<your-subdomain>.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.<your-subdomain>.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/ 가 등록되어 있어야 함
```
3 changes: 3 additions & 0 deletions oauth-proxy/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
node_modules/
.wrangler/
.dev.vars
30 changes: 30 additions & 0 deletions oauth-proxy/README.md
Original file line number Diff line number Diff line change
@@ -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) 참고.
13 changes: 13 additions & 0 deletions oauth-proxy/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
73 changes: 73 additions & 0 deletions oauth-proxy/src/worker.js
Original file line number Diff line number Diff line change
@@ -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 },
})
}
11 changes: 11 additions & 0 deletions oauth-proxy/wrangler.toml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion src/app-shell.tsx
Original file line number Diff line number Diff line change
@@ -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({
Expand All @@ -14,7 +15,7 @@ export function AppProviders({
<StrictMode>
<ThemeProvider>
<LanguageProvider initialLanguage={initialLanguage}>
{children}
<AdminAuthProvider>{children}</AdminAuthProvider>
</LanguageProvider>
</ThemeProvider>
</StrictMode>
Expand Down
14 changes: 13 additions & 1 deletion src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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 = {
Expand All @@ -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()

Expand Down Expand Up @@ -91,6 +93,16 @@ export function AppSidebar() {
</SidebarMenuButton>
</SidebarMenuItem>
))}
{isAdmin && (
<SidebarMenuItem>
<SidebarMenuButton asChild isActive={isActive("/admin")} tooltip="관리자">
<NavLink to="/admin" viewTransition onClick={(e) => handleMobileNav(e, "/admin")}>
<ShieldCheck />
<span>관리자</span>
</NavLink>
</SidebarMenuButton>
</SidebarMenuItem>
)}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
Expand Down
16 changes: 16 additions & 0 deletions src/entry-server.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
28 changes: 28 additions & 0 deletions src/lib/admin/config.ts
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading