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
10 changes: 10 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@ dist/
build/
.env
.env.*
!.env.example
.playwright-mcp/
.vscode/
4 changes: 3 additions & 1 deletion .replworks/TECH_STACK.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand Down
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 테스트 실행
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 10 additions & 0 deletions src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
2 changes: 2 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export interface AppConfig {
cfApiToken: string;
cfAccessTeamDomain: string;
cfAccessAud: string;
cfAccessDevBypass: boolean;
}

const requiredNames = [
Expand Down Expand Up @@ -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',
};
}
29 changes: 20 additions & 9 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
></textarea>
</div>

Expand Down Expand Up @@ -75,6 +75,19 @@ const modeButtons = [...document.querySelectorAll<HTMLButtonElement>('.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;

Expand All @@ -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 {
Expand Down Expand Up @@ -187,6 +193,7 @@ async function loadCatalog(): Promise<void> {

serviceSelect.disabled = false;
catalogStatus.textContent = `${services.length}개의 서비스가 로드되었습니다.`;
updatePlaceholder();
} catch {
serviceSelect.innerHTML = '<option value="">서비스 목록을 불러오지 못했습니다.</option>';
serviceSelect.disabled = true;
Expand Down Expand Up @@ -264,5 +271,9 @@ executeButton?.addEventListener('click', () => {
void executePurge();
});

serviceSelect?.addEventListener('change', () => {
updatePlaceholder();
});

setMode('url');
void loadCatalog();
13 changes: 9 additions & 4 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
Expand Down
40 changes: 28 additions & 12 deletions vite.config.ts
Original file line number Diff line number Diff line change
@@ -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'],
},
};
});
Loading