Files

270 lines
13 KiB
Markdown

# Our Recipe Atlas
Instagram 게시물과 YouTube 영상의 텍스트를 레시피로 구조화해 개인 MongoDB와 로컬 SSD에 보관하는 웹 애플리케이션입니다. Fastify가 REST API와 Vanilla JavaScript 화면을 함께 제공합니다.
## 현재 구현 범위
- Google Authorization Code/OIDC 로그인, PKCE `S256`, 이메일 allowlist
- HttpOnly 자체 인증 cookie와 그룹 멤버 간 recipe 공유
- Instagram caption 추출 및 YouTube 설명·자막·timestamp 추출
- 여러 URL을 영속 대기열에 등록하고 사용자가 요청할 때 서버에서 비동기 처리
- 추출한 본문을 최대 3개·10만 자 단위로 묶어 MiniMax Chat Completions API로 레시피 구조화
- 영어 등 외국어 원문의 recipe 필드를 자연스러운 한국어로 번역
- AI 결과를 schema로 검증해 자동 저장하고 실패 작업은 대기열에서 재시도
- Recipe CRUD, 그룹 멤버 조회와 소유자·원본별 중복 방지
- 외부 이미지를 최대 1280px WebP로 변환해 `IMAGE_ROOT`에 저장
- 반응형 Recipe 목록/상세 화면과 YouTube timestamp 링크
- nginx, systemd 운영 예제
영상 다운로드, STT, 브라우저 자동화 scraping, 회원가입, 결제, 소셜 기능은 포함하지 않습니다.
## 요구 사항
- Node.js 22 이상
- MongoDB
- Google OAuth 2.0 Web client
- MiniMax API/Token Plan key
- Instagram caption import를 사용할 경우 유효한 Instagram session cookie
YouTube.js와 insta-fetcher는 각 서비스의 비공식 API를 사용하므로 원본 서비스 변경에 따라 조정이 필요할 수 있습니다. 구현은 `src/services/extractors/`에 격리되어 있습니다.
## 개발 환경 실행
```bash
npm install
cp .env.example .env
npm start
```
Windows PowerShell에서는 다음처럼 복사할 수 있습니다.
```powershell
Copy-Item .env.example .env
npm start
```
기본 주소는 `http://localhost:3000`입니다. MongoDB가 실행 중이어야 서버가 시작됩니다. Google 설정이 비어 있으면 정적 로그인 화면은 열리지만 `/auth/google`은 설정 오류를 반환합니다.
개발 중 파일 변경을 감시하려면 `npm run dev`를 사용합니다.
### Windows 개발과 Linux 운영 환경 분리
실행 시 `process.platform`을 기준으로 프로필을 자동 선택합니다. 환경파일에 `NODE_ENV`, MongoDB URI, 이미지 경로를 넣어도 Windows/Linux 프로필 값이 우선합니다.
Windows에서 자동 적용되는 개발 프로필:
```text
NODE_ENV development
MONGO_URI mongodb://192.168.0.240:27017/our_recipe_atlas
MONGO_FALLBACK_URI mongodb://172.16.0.7:27017/our_recipe_atlas
IMAGE_ROOT ./data/images
```
개발과 운영 모두 `our_recipe_atlas` DB를 사용합니다. 개발 데이터는 `users_dev`, `groups_dev`, `import_jobs_dev`, `recipes_dev` 컬렉션으로 분리되며, 로그인 허용 계정은 공통 `allowed_google_emails` 컬렉션을 사용합니다. 프로젝트를 `D:\project\our_recipe_atlas`에서 실행하면 이미지는 `D:\project\our_recipe_atlas\data\images\recipes\...`에 저장됩니다.
Linux에서 자동 적용되는 운영 프로필:
```text
NODE_ENV production
MONGO_URI mongodb://192.168.0.240:27017/our_recipe_atlas
MONGO_FALLBACK_URI mongodb://172.16.0.7:27017/our_recipe_atlas
IMAGE_ROOT /mnt/recipe-ssd/our_recipe_atlas/images
```
운영 데이터는 같은 DB의 `users`, `groups`, `import_jobs`, `recipes` 컬렉션에 저장되고 이미지는 `/mnt/recipe-ssd/our_recipe_atlas/images/recipes/...`에 저장됩니다. 이미지의 DB 값은 두 OS 모두 `recipes/<recipe-id>/cover.webp` 형식입니다.
Windows에서 만든 `node_modules`는 운영 서버로 복사하지 말고, `sharp` 등 OS별 바이너리가 Linux용으로 설치되도록 운영 서버에서 `npm ci --omit=dev`를 실행해야 합니다.
## 환경변수
| 변수 | 설명 |
| --- | --- |
| `HOST`, `PORT` | Fastify listen 주소와 포트 |
| `PUBLIC_BASE_URL` | 브라우저가 접근하는 외부 기준 URL |
| `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET` | Google OAuth Web client 자격 증명 |
| `GOOGLE_CALLBACK_URL` | Google Console에 등록한 정확한 callback URL |
| `ALLOWED_GOOGLE_EMAILS` | DB가 비어 있을 때 최초 관리자로 등록할 이메일 목록 |
| `AUTH_JWT_SECRET` | 자체 session token 서명 비밀값. 운영에서 필수 |
| `AUTH_SESSION_TTL_HOURS` | 로그인 세션의 절대 만료시간(시간). 기본값 `24`, 범위 `1`~`720` |
| `MINIMAX_API_KEY` | MiniMax API 또는 Token Plan key |
| `MINIMAX_BASE_URL` | 기본값 `https://api.minimax.io/v1` |
| `MINIMAX_MODEL` | 기본값 `MiniMax-M2.7` |
| `INSTAGRAM_SESSION_COOKIE` | insta-fetcher에 전달할 session cookie |
MongoDB 주소와 이미지 루트는 위의 OS 프로필에 고정됩니다. 서버 시작 시 primary MongoDB를 먼저 시도하고, 3초 내 연결되지 않으면 VPN fallback으로 연결합니다. 실행 중인 연결이 끊겼을 때 주소를 자동 전환하는 기능은 아닙니다.
`.env`는 Git에 포함되지 않습니다. Google secret, MiniMax key, Instagram cookie를 브라우저 코드나 로그에 넣지 마세요.
안전한 `AUTH_JWT_SECRET` 예시는 Node로 생성할 수 있습니다.
```bash
node -e "console.log(require('node:crypto').randomBytes(48).toString('base64url'))"
```
## Google OAuth 설정
Google Cloud Console에서 OAuth 동의 화면과 OAuth 2.0 Web application client를 준비합니다.
개발용 Authorized redirect URI:
```text
http://localhost:3000/auth/google/callback
```
운영용 Authorized redirect URI:
```text
https://recipe.example.com/auth/google/callback
```
운영에서는 `http://192.168.0.250:3000/...` 같은 raw IP callback을 사용하지 않습니다. 실제 도메인과 HTTPS를 nginx 또는 Caddy에서 종료하고 내부 `192.168.0.250:3000`으로 proxy합니다. scope는 `openid email profile`이며 ID Token의 서명, audience, issuer, expiry를 `google-auth-library`로 검증합니다. 내부 사용자 키는 이메일이 아닌 Google `sub`입니다.
허용할 계정은 다음처럼 설정합니다.
```env
ALLOWED_GOOGLE_EMAILS=user1@gmail.com,user2@gmail.com
```
이 값은 `allowed_google_emails` 컬렉션이 비어 있을 때 한 번만 이관됩니다. 이후 로그인 허용 계정은 앱 하단의 **로그인 허용 계정**에서 이메일만 입력해 관리합니다. 최초 등록된 계정은 관리자이며, 관리자가 추가한 계정은 로그인만 허용되고 다른 계정을 초대할 수 없습니다.
## 외부 서비스 설정
### MiniMax
MiniMax의 OpenAI 호환 endpoint를 사용합니다. key와 model은 서버 환경변수로만 전달합니다.
```env
MINIMAX_API_KEY=...
MINIMAX_BASE_URL=https://api.minimax.io/v1
MINIMAX_MODEL=MiniMax-M2.7
```
URL별로 본문을 먼저 추출한 뒤 최대 3개·원문 합계 10만 자씩 한 요청에 넣습니다. 배치 전체 또는 일부 결과가 유효하지 않으면 해당 원문만 개별 요청으로 재시도합니다. 각 결과는 Zod schema로 검증하며, 원문에 없는 재료와 수량을 추측하지 않도록 system prompt에 제한을 둡니다.
### Instagram
`insta-fetcher`가 사용할 유효한 session cookie를 넣습니다.
```env
INSTAGRAM_SESSION_COOKIE=...
```
ID와 비밀번호로 서버에서 자동 로그인하지 않습니다. cookie는 만료될 수 있으며, caption 추출이 갑자기 실패하면 먼저 cookie 상태를 확인합니다.
### YouTube
`youtubei.js`로 title, author, description, thumbnail, transcript를 가져옵니다. 자막이 없더라도 description이 있으면 분석을 계속합니다. 영상 파일은 다운로드하지 않습니다.
## 데이터와 이미지
MongoDB는 `our_recipe_atlas` 하나만 사용하고 collection으로 환경을 분리합니다.
- 공통: `allowed_google_emails`
- 개발: `users_dev`, `groups_dev`, `import_jobs_dev`, `recipes_dev`
- 운영: `users`, `groups`, `import_jobs`, `recipes`
-`users*.googleSub`: unique index
-`groups*.memberGoogleSubs`: 조회 index
-`import_jobs*`: 사용자·원본별 unique index와 상태 조회 index
-`recipes*`: `ownerGoogleSub + source.platform + source.sourceId` unique index
- 원본 text와 YouTube transcript를 recipe source에 보존해 재분석에 사용할 수 있습니다.
그룹 문서는 recipe를 함께 볼 Google 계정의 `sub`를 보관합니다. 그룹이 없는 사용자는 자기 recipe만 볼 수 있고, 같은 그룹의 recipe는 함께 조회하되 수정과 삭제는 작성자만 할 수 있습니다.
```json
{
"name": "우리 가족",
"memberGoogleSubs": ["google-sub-1", "google-sub-2"]
}
```
이미지의 DB 값은 다음과 같은 상대 경로뿐입니다.
```text
recipes/<recipe-id>/cover.webp
```
실제 파일은 `IMAGE_ROOT` 아래에 있고 `/media/` 경로로 제공됩니다. 이미지 다운로드는 HTTPS만 허용하고 DNS가 사설/loopback 주소로 해석되면 차단합니다.
## API
모든 `/api/**` endpoint는 인증 cookie가 필요합니다. Recipe 목록과 상세는 같은 그룹 멤버에게 공유되고, 생성·수정·삭제 권한은 작성자에게 유지됩니다.
| Method | Path | 역할 |
| --- | --- | --- |
| `POST` | `/api/import/jobs` | URL 목록을 사용자 대기열에 등록 |
| `GET` | `/api/import/jobs` | 대기·처리·실패 작업 조회 |
| `POST` | `/api/import/jobs/process` | 대기 작업의 서버 처리를 요청하고 즉시 `202` 반환 |
| `POST` | `/api/import/jobs/:id/retry` | 실패 작업을 대기 상태로 되돌림 |
| `POST` | `/api/import/preview` | 이전 클라이언트 호환용 단일 URL 미리보기 |
| `GET` | `/api/recipes` | 내 그룹의 recipe 목록 |
| `GET` | `/api/recipes/:id` | 내 그룹의 recipe 상세 |
| `POST` | `/api/recipes` | 미리보기 확인 후 저장 |
| `PATCH` | `/api/recipes/:id` | recipe 필드 수정 |
| `DELETE` | `/api/recipes/:id` | recipe와 로컬 이미지 삭제 |
| `GET` | `/auth/me` | 현재 session 조회 |
| `POST` | `/auth/logout` | session cookie 삭제 |
| `GET` | `/api/allowed-emails` | 관리자용 허용 이메일 목록 |
| `POST` | `/api/allowed-emails` | 관리자용 허용 이메일 추가 |
| `DELETE` | `/api/allowed-emails/:email` | 관리자용 허용 이메일 삭제 |
## 테스트
외부 서비스와 MongoDB를 계속 호출하지 않도록 repository, extractor, parser, image storage를 모킹합니다.
```bash
npm test
npm run lint
```
테스트 범위에는 URL/ID 판별, MiniMax 본문 배치와 AI schema, 대기열 상태 전이, timestamp 정규화, allowlist, 인증 middleware, 그룹 공유와 소유자 쓰기 권한, CRUD, 중복 source 처리, 이미지 상대경로와 SSRF 차단이 포함됩니다.
실제 계정과 네트워크가 준비된 뒤에는 별도 smoke test로 다음을 확인합니다.
1. Instagram Reel/Post의 긴 caption 추출
2. 실제 YouTube 레시피 영상의 description, transcript, timestamp 추출
3. 여러 본문의 MiniMax 응답 품질과 자동 저장
4. 서버 재시작 후 MongoDB recipe와 SSD 이미지 표시
## 운영 배포 예시
권장 구성:
```text
Browser
→ HTTPS reverse proxy
→ 192.168.0.250:3000 Fastify + SSD image storage
→ 192.168.0.240:27017 MongoDB
```
`deploy/nginx.example.conf`의 도메인과 인증서 경로를 바꾸고, `deploy/our-recipe-atlas.service`의 사용자·설치 경로·`ReadWritePaths`를 실제 환경에 맞춥니다.
Linux 운영 환경파일은 `deploy/our-recipe-atlas.env.example`을 기준으로 `/etc/our-recipe-atlas.env`에 만들 수 있습니다. 서비스 시작 전에 이미지 디렉터리를 서비스 계정 소유로 준비합니다.
```bash
sudo install -d -o recipe-atlas -g recipe-atlas /mnt/recipe-ssd/our_recipe_atlas/images
sudo cp deploy/our-recipe-atlas.env.example /etc/our-recipe-atlas.env
sudo chmod 600 /etc/our-recipe-atlas.env
npm ci --omit=dev
```
환경파일의 도메인, Google 설정, 이메일 allowlist, JWT secret, MiniMax key를 실제 값으로 교체한 뒤 서비스를 재시작합니다.
운영 환경 예시:
```env
HOST=0.0.0.0
PORT=3000
PUBLIC_BASE_URL=https://recipe.example.com
GOOGLE_CALLBACK_URL=https://recipe.example.com/auth/google/callback
```
MongoDB 포트는 인터넷에 공개하지 말고 API 서버에서만 접근 가능하게 제한합니다.
## 참고 문서
- [Fastify OAuth2](https://github.com/fastify/fastify-oauth2)
- [Google ID Token 검증](https://developers.google.com/identity/gsi/web/guides/verify-google-id-token)
- [YouTube.js](https://github.com/LuanRT/YouTube.js)
- [insta-fetcher](https://github.com/Gimenz/insta-fetcher)
- [MiniMax OpenAI 호환 Text Chat](https://platform.minimax.io/docs/api-reference/text-chat-openai)