diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c55ed33 --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +PORT=3000 +SERVICES=images,avatars,products +IMAGE_HOST=img.repl.net +CF_ZONE_ID=your-zone-id +CF_API_TOKEN=your-cloudflare-api-token +CF_ACCESS_TEAM_DOMAIN=your-team-domain +CF_ACCESS_AUD=your-access-audience +# Local development only. Skips Cloudflare Access verification entirely when set to 1. +# Never set this in a deployed/production environment. +CF_ACCESS_DEV_BYPASS=0 diff --git a/.gitignore b/.gitignore index 255a2be..8aed18e 100644 --- a/.gitignore +++ b/.gitignore @@ -3,5 +3,6 @@ dist/ build/ .env .env.* +!.env.example .playwright-mcp/ .vscode/ diff --git a/.replworks/TECH_STACK.md b/.replworks/TECH_STACK.md index f0c4688..6acb90f 100644 --- a/.replworks/TECH_STACK.md +++ b/.replworks/TECH_STACK.md @@ -31,7 +31,7 @@ Keywords MUST, MUST NOT are used in the RFC 2119 sense. - `index.html` at project root is the static landing page and MUST NOT be a Vite build input. - `purge/index.html` is the Vite entry for the operator application and MUST be served at `/purge/`. - `.github/workflows/` holds CI workflow files. -- Vite `base` MUST be `/purge/`. +- Vite `base` MUST be `/purge/` for the production build. The dev server MAY use a different `base` so that `purge/index.html` is reachable during local development; this MUST NOT change build output. - Landing HTML MUST NOT reference Vite-built assets. - Vite output directory: default `dist`. `tsc` `outDir` MUST NOT equal it. - Test files MUST match `*.test.ts`. @@ -62,6 +62,7 @@ Keywords MUST, MUST NOT are used in the RFC 2119 sense. - Configuration source: environment variables only. No config file. - Env var names: `PORT`, `SERVICES`, `IMAGE_HOST`, `CF_ZONE_ID`, `CF_API_TOKEN`, `CF_ACCESS_TEAM_DOMAIN`, `CF_ACCESS_AUD`. +- Optional env var `CF_ACCESS_DEV_BYPASS` (default off, enabled only by the exact value `1`): local development only, see rule 7. - `IMAGE_HOST` value is a bare hostname without scheme. - `SERVICES` is a comma-separated list. - A missing required env var MUST stop the process at startup. @@ -87,6 +88,7 @@ Keywords MUST, MUST NOT are used in the RFC 2119 sense. - Verification MUST run as Hono middleware on every `/purge` route, including static assets. - JWT claims MUST NOT be read without verification. - Other identity headers (for example `Cf-Access-Authenticated-User-Email`) MUST NOT be trusted. +- `CF_ACCESS_DEV_BYPASS=1` MAY replace the JWT verifier with a verifier that always allows the request, for local development only. It MUST NOT be set in any deployed environment and MUST NOT be read from any source other than the process environment. - JWT or cryptographic logic MUST NOT be hand-written. - `CF_API_TOKEN` MUST be scoped to Zone → Cache Purge on the single zone. - Frontend MUST render upstream error text with `textContent`. `innerHTML` MUST NOT be used with external strings. diff --git a/README.md b/README.md index ce104e8..4fd7b84 100644 --- a/README.md +++ b/README.md @@ -129,10 +129,50 @@ npm run build npm start ``` +## 로컬에서 `/purge` 화면 테스트하기 + +`/purge`는 Cloudflare Access 인증이 걸려 있어서, `npm run dev`로 띄운 로컬 서버에 그냥 접속하면 항상 401이 뜹니다. UI/동작만 로컬에서 확인하려면 두 가지를 같이 사용하세요. + +1. **Cloudflare Access 인증 우회** — `.env`에 다음을 추가합니다. + + ```bash + CF_ACCESS_DEV_BYPASS=1 + ``` + + 이 값이 정확히 `1`일 때만 `/purge` 라우트의 Cloudflare Access JWT 검증을 건너뜁니다. 로컬 개발 전용이며, 배포 환경 변수에는 절대 설정하면 안 됩니다. 기본값은 꺼짐(`0`)입니다. + +2. **Vite dev 서버 + 백엔드를 같이 실행** — Vite(`npm run dev`)는 화면만 서빙하고, `/purge/catalog`·`/purge/execute` 요청은 로컬 Hono 서버로 프록시됩니다. 그래서 백엔드도 같이 떠 있어야 합니다. + + 한 터미널에서 빌드와 두 서버를 모두 실행: + + ```bash + npm run dev:purge + ``` + + 또는 직접 두 터미널로 나눠서: + + ```bash + npm run build + node --env-file-if-exists=.env build/server.js # 터미널 1: 백엔드 + npm run dev # 터미널 2: Vite + ``` + + 이후 `http://localhost:5173/purge/`로 접속하면 운영자 콘솔이 보이고, 서비스 목록도 정상적으로 로드됩니다. + + `npm run dev:purge`는 백엔드를 백그라운드로 띄우고 Vite를 포그라운드로 실행합니다. `Ctrl+C`로 Vite를 끈 뒤에도 백엔드 프로세스가 남아있을 수 있으니, 필요하면 `pkill -f build/server.js`로 정리하세요. + +### 실제 Cloudflare 연동을 켜고 끄기 + +`CF_ACCESS_DEV_BYPASS`는 로그인(접근 제어)만 우회할 뿐, "실행" 버튼을 눌렀을 때 실제 Cloudflare Purge API로 요청이 나가는지와는 무관합니다. `CF_ZONE_ID`/`CF_API_TOKEN`은 둘 다 필수 환경 변수라서 비워두면 서버가 기동하지 않으므로, 연동을 끄고 싶을 때는 **값을 더미 문자열로 바꿔치기**하세요. + +- 실제 Cloudflare와 연동해서 테스트: `.env`에 실제 `CF_ZONE_ID`/`CF_API_TOKEN`을 넣습니다. +- 디자인/동작만 확인(실제 캐시에 영향 주지 않음): `CF_ZONE_ID=dummy-zone-id`, `CF_API_TOKEN=dummy-token`처럼 가짜 값을 넣습니다. 서버는 정상적으로 기동하지만, "실행"을 눌러도 Cloudflare가 인증 실패로 거절해서 실제 캐시는 지워지지 않습니다. + ## 사용 가능한 스크립트 ```bash npm run dev # Vite 개발 서버 실행 +npm run dev:purge # 빌드 + 백엔드(백그라운드) + Vite를 한 번에 실행 npm run build # 타입 체크 및 프로덕트 빌드 npm start # 컴파일된 앱 실행 npm test # Vitest 테스트 실행 diff --git a/package.json b/package.json index ab489fe..c44ebc2 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ }, "scripts": { "dev": "vite", + "dev:purge": "npm run build && (node --env-file-if-exists=.env build/server.js &) && vite", "build": "tsc -p tsconfig.json && vite build", "start": "node --env-file-if-exists=.env build/server.js", "test": "vitest run", diff --git a/src/config.test.ts b/src/config.test.ts index fef08d5..d5f087c 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -52,4 +52,14 @@ describe('configuration', () => { 'Missing or invalid required configuration: CF_ZONE_ID', ); }); + + it('DB1 keeps the Cloudflare Access dev bypass off unless explicitly enabled', () => { + expect(loadConfig(validEnv).cfAccessDevBypass).toBe(false); + expect( + loadConfig({ ...validEnv, CF_ACCESS_DEV_BYPASS: 'true' }).cfAccessDevBypass, + ).toBe(false); + expect( + loadConfig({ ...validEnv, CF_ACCESS_DEV_BYPASS: '1' }).cfAccessDevBypass, + ).toBe(true); + }); }); diff --git a/src/config.ts b/src/config.ts index 8d56f73..a57bce0 100644 --- a/src/config.ts +++ b/src/config.ts @@ -6,6 +6,7 @@ export interface AppConfig { cfApiToken: string; cfAccessTeamDomain: string; cfAccessAud: string; + cfAccessDevBypass: boolean; } const requiredNames = [ @@ -77,5 +78,6 @@ export function loadConfig(env: NodeJS.ProcessEnv): AppConfig { cfApiToken: values.CF_API_TOKEN, cfAccessTeamDomain: values.CF_ACCESS_TEAM_DOMAIN, cfAccessAud: values.CF_ACCESS_AUD, + cfAccessDevBypass: env.CF_ACCESS_DEV_BYPASS?.trim() === '1', }; } diff --git a/src/main.ts b/src/main.ts index 488e5a4..f19901d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -43,7 +43,7 @@ appRoot.innerHTML = ` id="path-input" rows="6" class="w-full rounded-lg border border-slate-300 bg-slate-50 px-3 py-2 text-sm text-slate-900 outline-none transition focus:border-sky-500 focus:ring-2 focus:ring-sky-200" - placeholder="/images/logo.png?width=800\n/images/banner.jpg?format=webp" + placeholder="https://img.repl.net/wifinote/assets/seo/og.png?w=640 https://img.repl.net/wifinote/images/banner.jpg?format=webp" > @@ -75,6 +75,19 @@ const modeButtons = [...document.querySelectorAll('.mode-butt let selectedMode: 'url' | 'prefix' = 'url'; +function updatePlaceholder(): void { + if (!pathInput) { + return; + } + + const service = serviceSelect?.value || '서비스'; + + pathInput.placeholder = + selectedMode === 'url' + ? `https://img.repl.net/${service}/assets/seo/og.png?w=640\nhttps://img.repl.net/${service}/images/banner.jpg?format=webp` + : `/${service}/`; +} + function setMode(mode: 'url' | 'prefix'): void { selectedMode = mode; @@ -88,14 +101,7 @@ function setMode(mode: 'url' | 'prefix'): void { button.classList.toggle('text-slate-700', !active); } - if (!pathInput) { - return; - } - - pathInput.placeholder = - mode === 'url' - ? '/images/logo.png?width=800\n/images/banner.jpg?format=webp' - : '/images/'; + updatePlaceholder(); } function renderErrors(messages: string[]): void { @@ -187,6 +193,7 @@ async function loadCatalog(): Promise { serviceSelect.disabled = false; catalogStatus.textContent = `${services.length}개의 서비스가 로드되었습니다.`; + updatePlaceholder(); } catch { serviceSelect.innerHTML = ''; serviceSelect.disabled = true; @@ -264,5 +271,9 @@ executeButton?.addEventListener('click', () => { void executePurge(); }); +serviceSelect?.addEventListener('change', () => { + updatePlaceholder(); +}); + setMode('url'); void loadCatalog(); diff --git a/src/server.ts b/src/server.ts index 8d4be35..9755215 100644 --- a/src/server.ts +++ b/src/server.ts @@ -14,10 +14,15 @@ const purgeDocument = await readFile( new URL('../dist/purge/index.html', import.meta.url), 'utf8', ); -const verifyIdentity = createIdentityVerifier( - config.cfAccessTeamDomain, - config.cfAccessAud, -); +if (config.cfAccessDevBypass) { + console.warn( + 'CF_ACCESS_DEV_BYPASS=1: Cloudflare Access verification is disabled. Local development only — never set this in production.', + ); +} + +const verifyIdentity = config.cfAccessDevBypass + ? async () => true + : createIdentityVerifier(config.cfAccessTeamDomain, config.cfAccessAud); const ogImage = await readFile(new URL('../og.png', import.meta.url)).catch( () => null, ); diff --git a/vite.config.ts b/vite.config.ts index 044c9ea..b0d68ec 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,19 +1,35 @@ import { fileURLToPath } from 'node:url'; import tailwindcss from '@tailwindcss/vite'; +import { loadEnv } from 'vite'; import { defineConfig } from 'vitest/config'; -export default defineConfig({ - base: '/purge/', - plugins: [tailwindcss()], - build: { - rollupOptions: { - input: { - purge: fileURLToPath(new URL('./purge/index.html', import.meta.url)), +export default defineConfig(({ mode, command }) => { + const env = loadEnv(mode, process.cwd(), ''); + const backendOrigin = `http://localhost:${env.PORT ?? '3000'}`; + + return { + // The dev server mounts everything under `base`, so a request to + // /purge/index.html would resolve to the root index.html instead of + // purge/index.html. Production serves purge/index.html directly from + // the Hono server, so base only needs to be /purge/ for the build. + base: command === 'build' ? '/purge/' : '/', + plugins: [tailwindcss()], + server: { + proxy: { + '/purge/catalog': backendOrigin, + '/purge/execute': backendOrigin, + }, + }, + build: { + rollupOptions: { + input: { + purge: fileURLToPath(new URL('./purge/index.html', import.meta.url)), + }, }, }, - }, - test: { - environment: 'node', - include: ['src/**/*.test.ts'], - }, + test: { + environment: 'node', + include: ['src/**/*.test.ts'], + }, + }; });