diff --git a/.env.example b/.env.example index 9dfcdd5..2e5cd8d 100644 --- a/.env.example +++ b/.env.example @@ -18,4 +18,7 @@ AWS_SECRET_ACCESS_KEY= CLOUDFRONT_DOMAIN= # Data Source (datocms | supabase) -DATA_SOURCE=datocms \ No newline at end of file +DATA_SOURCE=datocms + +# Admin +ADMIN_PASSWORD= \ No newline at end of file diff --git a/.gitignore b/.gitignore index f42ee58..8a3170f 100644 --- a/.gitignore +++ b/.gitignore @@ -36,4 +36,10 @@ yarn-error.log* next-env.d.ts # etc -/src/__mocks__/dato.ts \ No newline at end of file +/src/__mocks__/dato.ts + +# MCP config +.mcp.json + +# migration output +scripts/migration/output/ \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index d905523..6f75334 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,7 +40,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ### 성능 최적화 - Next.js Image 컴포넌트 필수 사용 - `next.config.js`에 허용된 이미지 도메인만 사용 -- ISR(Incremental Static Regeneration) 활용: `REVALIDATE_TIME = 10초` +- ISR(Incremental Static Regeneration): DatoCMS는 `REVALIDATE_TIME = 10초`, Supabase는 Next.js 기본 캐싱 - **Request Deduplication**: `React.cache()`로 API 중복 호출 자동 제거 - **병렬 데이터 페칭**: `Promise.all()`로 독립적인 API 호출 동시 실행 @@ -93,13 +93,21 @@ const fuseOptions = { src/ ├── app/ # App Router 구조 │ ├── _components/ # 공용 컴포넌트 -│ ├── api/dato/ # DatoCMS API 엔드포인트 +│ ├── api/ # 데이터 레이어 +│ │ ├── index.ts # 통합 API 파사드 (Feature Flag 기반 전환) +│ │ ├── dato/ # DatoCMS API (롤백용 유지) +│ │ └── supabase/ # Supabase API (현재 주력) │ ├── post/[id]/ # 동적 포스트 페이지 │ └── posts/ # 카테고리별 포스트 목록 -├── libs/dato/ # DatoCMS GraphQL 클라이언트 +├── config/ # 설정 (dataSource.ts 등) +├── libs/ +│ ├── dato/ # DatoCMS GraphQL 클라이언트 (롤백용) +│ └── supabase/ # Supabase 클라이언트, 타입, 컨버터 ├── utils/ # 유틸리티 함수 ├── types/ # TypeScript 타입 정의 └── styles/ # 글로벌 SCSS 스타일 +scripts/ +└── migration/ # DatoCMS → Supabase 마이그레이션 스크립트 ``` ### 컴포넌트 설계 원칙 @@ -161,31 +169,94 @@ styles/ - BEM 방법론 적용하되 Module 스코핑 활용 - 전역 스타일은 최소한으로 제한 -## DatoCMS 연동 가이드 +## 데이터 소스 아키텍처 -### GraphQL 쿼리 최적화 -- 필요한 필드만 요청 -- 이미지 최적화: `responsiveImage` 활용 -- 페이지네이션: `first: "100"` 제한 +### Feature Flag 기반 이중 데이터 소스 +DatoCMS에서 Supabase로 마이그레이션 완료. Feature Flag로 롤백 가능. + +```typescript +// src/config/dataSource.ts +export const DATA_SOURCE = process.env.DATA_SOURCE || "datocms"; // "datocms" | "supabase" + +// src/app/api/index.ts - 통합 API 파사드 +const api = DATA_SOURCE === "supabase" ? supabase : dato; +export const { getPosts, getPostById, getCategories, getPostIds } = api; +``` + +**규칙**: 컴포넌트에서는 반드시 `src/app/api/index.ts`에서 import. 직접 dato/supabase 모듈 참조 금지. + +### Supabase 스키마 +``` +Tables: +├── posts # id(UUID), datocms_id, title, description, markdown, category_id, thumbnail_id, is_public +├── categories # id(UUID), main_category, sub_category +└── images # id(UUID), s3_key, alt, title, width, height, blur_data_url +``` + +### 데이터 컨버터 패턴 +- **위치**: `src/libs/supabase/converter.ts` +- Supabase 행 데이터를 기존 `PostType`/`PostWithoutMarkdownType`으로 변환 +- 컴포넌트 계층에 대한 변경 없이 데이터 소스 교체 가능 + +### URL ID 이중 해석 +```typescript +// src/app/api/supabase/getPostById.ts +if (isUuid(postId)) → supabase "id" 필드로 조회 (신규 포스트) +else → supabase "datocms_id" 필드로 조회 (마이그레이션된 포스트) +``` + +### 이미지 호스팅: AWS S3 + CloudFront +- DatoCMS assets → S3 저장 + CloudFront CDN 배포 +- `converter.ts`에서 `s3_key` → CloudFront URL 자동 생성 +- `blur_data_url`로 블러 플레이스홀더 제공 +- `next.config.js`에 CloudFront 도메인 동적 등록 + +### DatoCMS (롤백용 유지) +- `src/libs/dato/` - GraphQL 클라이언트 +- `src/app/api/dato/` - 기존 API 함수들 +- `DATA_SOURCE=datocms`로 즉시 롤백 가능 ### 환경변수 관리 -- `API_TOKEN`: DatoCMS API 토큰 -- `GTM_ID`: Google Tag Manager ID +```bash +# Data Source 전환 +DATA_SOURCE=datocms # "datocms" | "supabase" + +# Supabase +SUPABASE_URL= +SUPABASE_ANON_KEY= +SUPABASE_SERVICE_ROLE_KEY= # 서버 전용 (마이그레이션 스크립트 등) + +# AWS S3 / CloudFront (이미지) +AWS_REGION= +S3_BUCKET= +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +CLOUDFRONT_DOMAIN= + +# DatoCMS (롤백용) +API_TOKEN= + +# Admin +ADMIN_PASSWORD= + +# Analytics +GTM_ID= +``` ### 캐싱 전략 -- ISR: `revalidate: 10초` -- 이미지 캐싱: Next.js Image 컴포넌트 활용 -- DatoCMS CDN 최적화 +- DatoCMS: ISR `revalidate: 10초` +- Supabase: React.cache()로 렌더링 사이클 내 중복 제거, Next.js 기본 캐싱 활용 +- 이미지 캐싱: Next.js Image 컴포넌트 + CloudFront CDN ## React Server Component 최적화 패턴 ### React.cache()를 이용한 요청 중복 제거 -- **위치**: `src/app/api/dato/*.ts` API 함수들 +- **위치**: `src/app/api/dato/*.ts` 및 `src/app/api/supabase/*.ts` API 함수들 - **패턴**: 내부 함수를 구현한 후 `React.cache()`로 래핑하여 export - **효과**: 동일 렌더링 사이클 내 중복 API 요청 자동 제거 ```typescript -// src/app/api/dato/getPostById.ts 예시 +// DatoCMS / Supabase 모두 동일 패턴 적용 import { cache } from "react"; const _getPostById = async ({ postId }: { postId: string }) => { @@ -197,7 +268,7 @@ export const getPostById = cache(_getPostById); ``` **적용 규칙**: -- 모든 DatoCMS API 함수는 `React.cache()` 적용 필수 +- 모든 API 함수 (DatoCMS, Supabase 모두)는 `React.cache()` 적용 필수 - 함수명: 내부 구현은 `_functionName`, export는 `functionName` - Server Component에서만 사용 (Client Component에서는 사용 불가) @@ -287,6 +358,8 @@ src/app/_components/HeadingIndexNav/ - WebP 포맷 우선 사용 - 적절한 사이즈 지정 - Lazy Loading 기본 적용 +- Supabase: S3 + CloudFront CDN 배포, blur placeholder 지원 +- DatoCMS (롤백용): `responsiveImage` GraphQL 필드 활용 ## 보안 및 접근성 diff --git a/package.json b/package.json index bc25b11..d059fb4 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "react-syntax-highlighter": "^15.5.0", "remark-gfm": "^4.0.0", "rss": "^1.2.2", + "sharp": "^0.34.5", "shiki": "^3.21.0", "web-vitals": "^5.1.0", "zustand": "^4.4.3" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 50b5f34..e37cbcc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -56,6 +56,9 @@ importers: rss: specifier: ^1.2.2 version: 1.2.2 + sharp: + specifier: ^0.34.5 + version: 0.34.5 shiki: specifier: ^3.21.0 version: 3.21.0 @@ -551,6 +554,9 @@ packages: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} + '@emnapi/runtime@1.8.1': + resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==} + '@esbuild/aix-ppc64@0.27.2': resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==} engines: {node: '>=18'} @@ -769,6 +775,143 @@ packages: resolution: {integrity: sha512-xeO57FpIu4p1Ri3Jq/EXq4ClRm86dVF2z/+kvFnyqVYRavTZmaFaUBbWCOuuTh0o/g7DSsk6kc2vrS4Vl5oPOQ==} engines: {node: '>=18.18'} + '@img/colour@1.0.0': + resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + cpu: [arm] + os: [linux] + + '@img/sharp-libvips-linux-ppc64@1.2.4': + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + cpu: [ppc64] + os: [linux] + + '@img/sharp-libvips-linux-riscv64@1.2.4': + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] + + '@img/sharp-libvips-linux-s390x@1.2.4': + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + cpu: [s390x] + os: [linux] + + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + cpu: [x64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + cpu: [x64] + os: [linux] + + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + + '@img/sharp-linux-ppc64@0.34.5': + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ppc64] + os: [linux] + + '@img/sharp-linux-riscv64@0.34.5': + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] + + '@img/sharp-linux-s390x@0.34.5': + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-wasm32@0.34.5': + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.34.5': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + '@istanbuljs/load-nyc-config@1.1.0': resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==} engines: {node: '>=8'} @@ -2071,6 +2214,10 @@ packages: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + detect-newline@3.1.0: resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} engines: {node: '>=8'} @@ -4322,6 +4469,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.7.3: + resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} + engines: {node: '>=10'} + hasBin: true + set-cookie-parser@2.6.0: resolution: {integrity: sha512-RVnVQxTXuerk653XfuliOxBP81Sf0+qfQE73LIYKcyMYHG94AuH0kgrQpRDuTZnSmjpysHmzxJXKNfa6PjFhyQ==} @@ -4345,6 +4497,10 @@ packages: resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} engines: {node: '>= 0.4'} + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -5839,6 +5995,11 @@ snapshots: dependencies: '@jridgewell/trace-mapping': 0.3.9 + '@emnapi/runtime@1.8.1': + dependencies: + tslib: 2.8.1 + optional: true + '@esbuild/aix-ppc64@0.27.2': optional: true @@ -5976,6 +6137,102 @@ snapshots: '@humanwhocodes/retry@0.4.2': {} + '@img/colour@1.0.0': {} + + '@img/sharp-darwin-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true + + '@img/sharp-darwin-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.2.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.2.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true + + '@img/sharp-linux-ppc64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 + optional: true + + '@img/sharp-linux-riscv64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 + optional: true + + '@img/sharp-linux-s390x@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 + optional: true + + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + + '@img/sharp-wasm32@0.34.5': + dependencies: + '@emnapi/runtime': 1.8.1 + optional: true + + '@img/sharp-win32-arm64@0.34.5': + optional: true + + '@img/sharp-win32-ia32@0.34.5': + optional: true + + '@img/sharp-win32-x64@0.34.5': + optional: true + '@istanbuljs/load-nyc-config@1.1.0': dependencies: camelcase: 5.3.1 @@ -7616,6 +7873,8 @@ snapshots: dequal@2.0.3: {} + detect-libc@2.1.2: {} + detect-newline@3.1.0: {} devlop@1.1.0: @@ -10665,6 +10924,8 @@ snapshots: dependencies: lru-cache: 6.0.0 + semver@7.7.3: {} + set-cookie-parser@2.6.0: {} set-function-length@1.1.1: @@ -10702,6 +10963,37 @@ snapshots: es-errors: 1.3.0 es-object-atoms: 1.1.1 + sharp@0.34.5: + dependencies: + '@img/colour': 1.0.0 + detect-libc: 2.1.2 + semver: 7.7.3 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 diff --git a/src/app/api/admin/auth.ts b/src/app/api/admin/auth.ts new file mode 100644 index 0000000..0ac2b2b --- /dev/null +++ b/src/app/api/admin/auth.ts @@ -0,0 +1,74 @@ +import { NextResponse } from "next/server"; +import { timingSafeEqual } from "crypto"; + +/** + * Validates admin authentication for API routes + * + * @param request - The incoming Request object + * @returns NextResponse with 401 error if unauthorized, null if valid + * + * @example + * ```typescript + * export async function POST(request: Request) { + * const unauthorized = validateAdmin(request); + * if (unauthorized) return unauthorized; + * + * // Proceed with authenticated logic + * } + * ``` + */ +export function validateAdmin(request: Request): NextResponse | null { + const authHeader = request.headers.get("Authorization"); + + // Check for Bearer token pattern + if (!authHeader || !authHeader.startsWith("Bearer ")) { + return NextResponse.json( + { error: "Unauthorized" }, + { status: 401 } + ); + } + + // Extract token from "Bearer " + const token = authHeader.substring(7); + + // Validate against environment variable + const adminPassword = process.env.ADMIN_PASSWORD; + + if (!adminPassword) { + return NextResponse.json( + { error: "Unauthorized" }, + { status: 401 } + ); + } + + // Use timing-safe comparison to prevent timing attacks + try { + const tokenBuffer = Buffer.from(token); + const passwordBuffer = Buffer.from(adminPassword); + + // If lengths differ, compare against dummy buffer to avoid length leakage + if (tokenBuffer.length !== passwordBuffer.length) { + const dummyBuffer = Buffer.alloc(passwordBuffer.length); + timingSafeEqual(passwordBuffer, dummyBuffer); + return NextResponse.json( + { error: "Unauthorized" }, + { status: 401 } + ); + } + + if (!timingSafeEqual(tokenBuffer, passwordBuffer)) { + return NextResponse.json( + { error: "Unauthorized" }, + { status: 401 } + ); + } + } catch { + return NextResponse.json( + { error: "Unauthorized" }, + { status: 401 } + ); + } + + // Authentication successful + return null; +} diff --git a/src/app/api/admin/categories/[id]/route.ts b/src/app/api/admin/categories/[id]/route.ts new file mode 100644 index 0000000..6690bea --- /dev/null +++ b/src/app/api/admin/categories/[id]/route.ts @@ -0,0 +1,66 @@ +import { NextResponse } from "next/server"; +import { createAdminClient } from "@/libs/supabase"; +import { validateAdmin } from "../../auth"; + +export async function PUT( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const unauthorized = validateAdmin(request); + if (unauthorized) return unauthorized; + + const { id } = await params; + const supabase = createAdminClient(); + const body = await request.json(); + + const { main_category, sub_category } = body; + + if (main_category !== undefined && (typeof main_category !== "string" || main_category.trim() === "")) { + return NextResponse.json({ error: "main_category must be a non-empty string" }, { status: 400 }); + } + if (sub_category !== undefined && (typeof sub_category !== "string" || sub_category.trim() === "")) { + return NextResponse.json({ error: "sub_category must be a non-empty string" }, { status: 400 }); + } + + const updates: Record = {}; + if (main_category !== undefined) updates.main_category = main_category; + if (sub_category !== undefined) updates.sub_category = sub_category; + + const { data, error } = await supabase + .from("categories") + .update(updates) + .eq("id", id) + .select() + .single(); + + if (error) { + if (error.code === "PGRST116") { + return NextResponse.json({ error: "Category not found" }, { status: 404 }); + } + return NextResponse.json({ error: error.message }, { status: 500 }); + } + + return NextResponse.json(data); +} + +export async function DELETE( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const unauthorized = validateAdmin(request); + if (unauthorized) return unauthorized; + + const { id } = await params; + const supabase = createAdminClient(); + + const { error } = await supabase + .from("categories") + .delete() + .eq("id", id); + + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } + + return NextResponse.json({ success: true }); +} diff --git a/src/app/api/admin/categories/route.ts b/src/app/api/admin/categories/route.ts new file mode 100644 index 0000000..c797f25 --- /dev/null +++ b/src/app/api/admin/categories/route.ts @@ -0,0 +1,54 @@ +import { NextResponse } from "next/server"; +import { createAdminClient } from "@/libs/supabase"; +import { validateAdmin } from "../auth"; + +export async function GET(request: Request) { + const unauthorized = validateAdmin(request); + if (unauthorized) return unauthorized; + + const supabase = createAdminClient(); + + const { data, error } = await supabase + .from("categories") + .select("*") + .order("main_category", { ascending: true }) + .order("sub_category", { ascending: true }); + + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } + + return NextResponse.json(data); +} + +export async function POST(request: Request) { + const unauthorized = validateAdmin(request); + if (unauthorized) return unauthorized; + + const supabase = createAdminClient(); + const body = await request.json(); + + const { main_category, sub_category } = body; + + if (!main_category || typeof main_category !== "string") { + return NextResponse.json({ error: "main_category must be a non-empty string" }, { status: 400 }); + } + if (!sub_category || typeof sub_category !== "string") { + return NextResponse.json({ error: "sub_category must be a non-empty string" }, { status: 400 }); + } + + const { data, error } = await supabase + .from("categories") + .insert({ + main_category, + sub_category, + }) + .select() + .single(); + + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } + + return NextResponse.json(data, { status: 201 }); +} diff --git a/src/app/api/admin/posts/[id]/route.ts b/src/app/api/admin/posts/[id]/route.ts new file mode 100644 index 0000000..9064604 --- /dev/null +++ b/src/app/api/admin/posts/[id]/route.ts @@ -0,0 +1,109 @@ +import { NextResponse } from "next/server"; +import { createAdminClient } from "@/libs/supabase"; +import { validateAdmin } from "../../auth"; + +export async function GET( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const unauthorized = validateAdmin(request); + if (unauthorized) return unauthorized; + + const { id } = await params; + const supabase = createAdminClient(); + + const { data, error } = await supabase + .from("posts") + .select( + ` + *, + category:categories(*), + thumbnail:images(*) + ` + ) + .eq("id", id) + .single(); + + if (error) { + if (error.code === "PGRST116") { + return NextResponse.json({ error: "Post not found" }, { status: 404 }); + } + return NextResponse.json({ error: error.message }, { status: 500 }); + } + + return NextResponse.json(data); +} + +export async function PUT( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const unauthorized = validateAdmin(request); + if (unauthorized) return unauthorized; + + const { id } = await params; + const supabase = createAdminClient(); + const body = await request.json(); + + const { title, description, markdown, category_id, thumbnail_id, is_public, datocms_id } = body; + + if (title !== undefined && (typeof title !== "string" || title.trim() === "")) { + return NextResponse.json({ error: "title must be a non-empty string" }, { status: 400 }); + } + if (is_public !== undefined && typeof is_public !== "boolean") { + return NextResponse.json({ error: "is_public must be a boolean" }, { status: 400 }); + } + + const updates: Record = {}; + if (title !== undefined) updates.title = title; + if (description !== undefined) updates.description = description; + if (markdown !== undefined) updates.markdown = markdown; + if (category_id !== undefined) updates.category_id = category_id; + if (thumbnail_id !== undefined) updates.thumbnail_id = thumbnail_id; + if (is_public !== undefined) updates.is_public = is_public; + if (datocms_id !== undefined) updates.datocms_id = datocms_id; + + const { data, error } = await supabase + .from("posts") + .update(updates) + .eq("id", id) + .select( + ` + *, + category:categories(*), + thumbnail:images(*) + ` + ) + .single(); + + if (error) { + if (error.code === "PGRST116") { + return NextResponse.json({ error: "Post not found" }, { status: 404 }); + } + return NextResponse.json({ error: error.message }, { status: 500 }); + } + + return NextResponse.json(data); +} + +export async function DELETE( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const unauthorized = validateAdmin(request); + if (unauthorized) return unauthorized; + + const { id } = await params; + const supabase = createAdminClient(); + + const { error } = await supabase + .from("posts") + .delete() + .eq("id", id); + + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } + + return NextResponse.json({ success: true }); +} diff --git a/src/app/api/admin/posts/route.ts b/src/app/api/admin/posts/route.ts new file mode 100644 index 0000000..286fd09 --- /dev/null +++ b/src/app/api/admin/posts/route.ts @@ -0,0 +1,77 @@ +import { NextResponse } from "next/server"; +import { createAdminClient } from "@/libs/supabase"; +import { validateAdmin } from "../auth"; + +export async function GET(request: Request) { + const unauthorized = validateAdmin(request); + if (unauthorized) return unauthorized; + + const supabase = createAdminClient(); + + const { data, error } = await supabase + .from("posts") + .select( + ` + *, + category:categories(*), + thumbnail:images(*) + ` + ) + .order("created_at", { ascending: false }); + + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } + + return NextResponse.json(data); +} + +export async function POST(request: Request) { + const unauthorized = validateAdmin(request); + if (unauthorized) return unauthorized; + + const supabase = createAdminClient(); + const body = await request.json(); + + const { title, description, markdown, category_id, thumbnail_id, is_public, datocms_id } = body; + + // Input validation + if (!title || typeof title !== "string") { + return NextResponse.json({ error: "title must be a non-empty string" }, { status: 400 }); + } + if (!markdown || typeof markdown !== "string") { + return NextResponse.json({ error: "markdown must be a non-empty string" }, { status: 400 }); + } + if (!category_id || typeof category_id !== "string") { + return NextResponse.json({ error: "category_id must be a non-empty string" }, { status: 400 }); + } + if (typeof is_public !== "boolean") { + return NextResponse.json({ error: "is_public must be a boolean" }, { status: 400 }); + } + + const { data, error } = await supabase + .from("posts") + .insert({ + title, + description, + markdown, + category_id, + thumbnail_id, + is_public, + datocms_id, + }) + .select( + ` + *, + category:categories(*), + thumbnail:images(*) + ` + ) + .single(); + + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } + + return NextResponse.json(data, { status: 201 }); +} diff --git a/src/app/api/admin/upload/route.ts b/src/app/api/admin/upload/route.ts new file mode 100644 index 0000000..e890436 --- /dev/null +++ b/src/app/api/admin/upload/route.ts @@ -0,0 +1,99 @@ +import { NextResponse } from "next/server"; +import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3"; +import { createAdminClient } from "@/libs/supabase"; +import { validateAdmin } from "../auth"; + +const s3Client = new S3Client({ + region: process.env.AWS_REGION!, + credentials: { + accessKeyId: process.env.AWS_ACCESS_KEY_ID!, + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!, + }, +}); + +const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp", "image/gif"]; +const MAX_FILE_SIZE = 10 * 1024 * 1024; + +export async function POST(request: Request) { + const unauthorized = validateAdmin(request); + if (unauthorized) return unauthorized; + + const supabase = createAdminClient(); + const formData = await request.formData(); + + const file = formData.get("file") as File | null; + const alt = formData.get("alt") as string | null; + const title = formData.get("title") as string | null; + const widthStr = formData.get("width") as string | null; + const heightStr = formData.get("height") as string | null; + + if (!file) { + return NextResponse.json({ error: "file is required" }, { status: 400 }); + } + + if (!ALLOWED_TYPES.includes(file.type)) { + return NextResponse.json({ error: "Invalid file type" }, { status: 400 }); + } + + if (file.size > MAX_FILE_SIZE) { + return NextResponse.json({ error: "File too large" }, { status: 400 }); + } + + const width = widthStr ? parseInt(widthStr, 10) : null; + const height = heightStr ? parseInt(heightStr, 10) : null; + + if ((widthStr && isNaN(width!)) || (heightStr && isNaN(height!))) { + return NextResponse.json({ error: "Invalid width or height" }, { status: 400 }); + } + + const fileBuffer = Buffer.from(await file.arrayBuffer()); + const fileExt = file.name.split(".").pop() || "jpg"; + const timestamp = Date.now(); + const random = Math.random().toString(36).substring(2, 8); + const s3Key = `uploads/${timestamp}-${random}.${fileExt}`; + + const uploadCommand = new PutObjectCommand({ + Bucket: process.env.S3_BUCKET!, + Key: s3Key, + Body: fileBuffer, + ContentType: file.type, + }); + + try { + await s3Client.send(uploadCommand); + } catch (error) { + console.error("S3 upload error:", error); + return NextResponse.json( + { error: "Upload failed" }, + { status: 500 } + ); + } + + const { data, error: dbError } = await supabase + .from("images") + .insert({ + s3_key: s3Key, + original_url: null, + alt: alt || null, + title: title || null, + width, + height, + blur_data_url: null, + }) + .select() + .single(); + + if (dbError) { + return NextResponse.json( + { error: dbError.message }, + { status: 500 } + ); + } + + const cloudfrontUrl = `https://${process.env.CLOUDFRONT_DOMAIN}/${s3Key}`; + + return NextResponse.json( + { image: data, url: cloudfrontUrl }, + { status: 201 } + ); +} diff --git a/src/libs/supabase/client.ts b/src/libs/supabase/client.ts index 1f7a1ab..499e79e 100644 --- a/src/libs/supabase/client.ts +++ b/src/libs/supabase/client.ts @@ -14,3 +14,13 @@ export const createServiceClient = () => { } return createClient(supabaseUrl, serviceRoleKey); }; + +// Untyped client for admin CRUD - Database generic's Insert/Update types +// are incompatible with @supabase/postgrest-js@2.91.1 (resolves to never) +export const createAdminClient = () => { + const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY; + if (!serviceRoleKey) { + throw new Error("SUPABASE_SERVICE_ROLE_KEY is not set"); + } + return createClient(supabaseUrl, serviceRoleKey); +}; diff --git a/src/libs/supabase/index.ts b/src/libs/supabase/index.ts index 7a1562d..0dc4258 100644 --- a/src/libs/supabase/index.ts +++ b/src/libs/supabase/index.ts @@ -1,3 +1,3 @@ -export { supabase, createServiceClient } from "./client"; +export { supabase, createServiceClient, createAdminClient } from "./client"; export * from "./types"; export * from "./converter"; diff --git a/todolist.md b/todolist.md new file mode 100644 index 0000000..1a08e91 --- /dev/null +++ b/todolist.md @@ -0,0 +1,77 @@ +# Admin CRUD API 구현 TODO + +## 개요 +Supabase 기반 어드민 CRUD API를 Next.js Route Handlers로 구현. +ADMIN_PASSWORD 기반 인증, 이미지 업로드(S3) 포함. + +## API 라우트 구조 +``` +src/app/api/admin/ +├── auth.ts # 인증 헬퍼 (validateAdmin) +├── posts/ +│ ├── route.ts # GET (목록), POST (생성) +│ └── [id]/ +│ └── route.ts # GET (상세), PUT (수정), DELETE (삭제) +├── categories/ +│ ├── route.ts # GET (목록), POST (생성) +│ └── [id]/ +│ └── route.ts # PUT (수정), DELETE (삭제) +└── upload/ + └── route.ts # POST (S3 이미지 업로드) +``` + +--- + +## TODO + +### 1. 인증 헬퍼 +- [x] `src/app/api/admin/auth.ts` 생성 + - `Authorization: Bearer ` 검증 함수 + - 실패 시 401 응답 반환 헬퍼 + +### 2. 포스트 API +- [x] `src/app/api/admin/posts/route.ts` + - [x] **GET** - 전체 포스트 목록 (is_public 무관, 어드민용) + - `createAdminClient()` 사용, posts + categories + images 조인 + - [x] **POST** - 포스트 생성 + - body: `{ title, description, markdown, category_id, thumbnail_id?, is_public }` + - posts 테이블 insert + +- [x] `src/app/api/admin/posts/[id]/route.ts` + - [x] **GET** - 단일 포스트 상세 (UUID로 조회) + - [x] **PUT** - 포스트 수정 (`Partial`) + - [x] **DELETE** - 포스트 삭제 (hard delete) + +### 3. 카테고리 API +- [x] `src/app/api/admin/categories/route.ts` + - [x] **GET** - 카테고리 목록 + - [x] **POST** - 카테고리 생성 (`{ main_category, sub_category }`) + +- [x] `src/app/api/admin/categories/[id]/route.ts` + - [x] **PUT** - 카테고리 수정 + - [x] **DELETE** - 카테고리 삭제 + +### 4. 이미지 업로드 API +- [x] `src/app/api/admin/upload/route.ts` + - [x] **POST** - 이미지 업로드 + - FormData로 파일 수신 + - `@aws-sdk/client-s3` PutObjectCommand로 S3 업로드 + - images 테이블에 레코드 insert (s3_key, alt, width, height) + - CloudFront URL + image record 반환 + +--- + +## 기술 사항 + +| 항목 | 결정 | +|------|------| +| 인증 | `ADMIN_PASSWORD` 환경변수, Bearer 토큰 방식 | +| DB 클라이언트 | `createServiceClient()` (service role key, RLS 우회) | +| 이미지 저장 | AWS S3 + CloudFront CDN | +| 라우트 방식 | Next.js App Router Route Handlers | +| 기존 코드 영향 | 없음 (기존 읽기 API와 독립적) | + +## 검증 방법 +- [x] `pnpm build` 빌드 오류 확인 +- [ ] curl로 각 엔드포인트 호출 테스트 +- [ ] 인증 실패 시 401 응답 확인