commit cee36bf12dd50c67358cdd6fe728ec12e10c027f Author: Horoli Date: Thu Aug 13 17:37:03 2026 +0900 first commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8960664 --- /dev/null +++ b/.env.example @@ -0,0 +1,19 @@ +# Windows는 development, Linux는 production 프로필을 자동 사용합니다. +HOST=0.0.0.0 +PORT=3000 +LOG_LEVEL=info + +PUBLIC_BASE_URL=http://localhost:3000 +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +GOOGLE_CALLBACK_URL=http://localhost:3000/auth/google/callback +# DB가 비어 있을 때 최초 접근 관리자 계정으로 한 번만 등록됩니다. +ALLOWED_GOOGLE_EMAILS= +AUTH_JWT_SECRET= +AUTH_SESSION_TTL_HOURS=24 + +MINIMAX_API_KEY= +MINIMAX_BASE_URL=https://api.minimax.io/v1 +MINIMAX_MODEL=MiniMax-M2.7 + +INSTAGRAM_SESSION_COOKIE= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b32c1a5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +.env +*.log +.DS_Store +data/images/* +!data/images/.gitkeep +coverage/ +package-lock.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..16e9870 --- /dev/null +++ b/README.md @@ -0,0 +1,252 @@ +# Our Recipe Atlas + +Instagram 게시물과 YouTube 영상의 텍스트를 레시피로 구조화해 개인 MongoDB와 로컬 SSD에 보관하는 웹 애플리케이션입니다. Fastify가 REST API와 Vanilla JavaScript 화면을 함께 제공합니다. + +## 현재 구현 범위 + +- Google Authorization Code/OIDC 로그인, PKCE `S256`, 이메일 allowlist +- HttpOnly 자체 인증 cookie와 소유자별 API 접근 제어 +- Instagram caption 추출 및 YouTube 설명·자막·timestamp 추출 +- MiniMax OpenAI 호환 Chat Completions API를 이용한 레시피 구조화 +- AI 결과 미리보기/편집 후 저장 +- 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`, `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`, `recipes` 컬렉션에 저장되고 이미지는 `/mnt/recipe-ssd/our_recipe_atlas/images/recipes/...`에 저장됩니다. 이미지의 DB 값은 두 OS 모두 `recipes//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 +``` + +파서는 최대 한 번만 repair 요청을 수행하며, 결과를 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`, `recipes_dev` +- 운영: `users`, `recipes` +- 각 `users*.googleSub`: unique index +- 각 `recipes*`: `ownerGoogleSub + source.platform + source.sourceId` unique index +- 원본 text와 YouTube transcript를 recipe source에 보존해 재분석에 사용할 수 있습니다. + +이미지의 DB 값은 다음과 같은 상대 경로뿐입니다. + +```text +recipes//cover.webp +``` + +실제 파일은 `IMAGE_ROOT` 아래에 있고 `/media/` 경로로 제공됩니다. 이미지 다운로드는 HTTPS만 허용하고 DNS가 사설/loopback 주소로 해석되면 차단합니다. + +## API + +모든 `/api/**` endpoint는 인증 cookie가 필요하며 로그인한 사용자의 데이터만 반환합니다. + +| Method | Path | 역할 | +| --- | --- | --- | +| `POST` | `/api/import/preview` | URL 추출 및 AI recipe 미리보기 | +| `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 판별, 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) diff --git a/data/images/.gitkeep b/data/images/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/data/images/.gitkeep @@ -0,0 +1 @@ + diff --git a/deploy/nginx.example.conf b/deploy/nginx.example.conf new file mode 100644 index 0000000..950b596 --- /dev/null +++ b/deploy/nginx.example.conf @@ -0,0 +1,36 @@ +server { + listen 80; + server_name recipe.example.com; + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + return 301 https://$host$request_uri; + } +} + +server { + listen 443 ssl http2; + server_name recipe.example.com; + + # 실제 인증서 경로로 교체한다. Let's Encrypt/Caddy를 사용해도 된다. + ssl_certificate /etc/letsencrypt/live/recipe.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/recipe.example.com/privkey.pem; + + add_header X-Content-Type-Options nosniff always; + add_header Referrer-Policy strict-origin-when-cross-origin always; + client_max_body_size 2m; + + location / { + proxy_pass http://127.0.0.1:3000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 120s; + } +} + diff --git a/deploy/our-recipe-atlas.env.example b/deploy/our-recipe-atlas.env.example new file mode 100644 index 0000000..bfbc077 --- /dev/null +++ b/deploy/our-recipe-atlas.env.example @@ -0,0 +1,19 @@ +# Linux에서는 production 프로필을 자동 사용합니다. +HOST=127.0.0.1 +PORT=3000 +LOG_LEVEL=info + +PUBLIC_BASE_URL=https://recipe.example.com +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +GOOGLE_CALLBACK_URL=https://recipe.example.com/auth/google/callback +# DB가 비어 있을 때 최초 접근 관리자 계정으로 한 번만 등록됩니다. +ALLOWED_GOOGLE_EMAILS= +AUTH_JWT_SECRET=replace-with-a-long-random-secret +AUTH_SESSION_TTL_HOURS=24 + +MINIMAX_API_KEY= +MINIMAX_BASE_URL=https://api.minimax.io/v1 +MINIMAX_MODEL=MiniMax-M2.7 + +INSTAGRAM_SESSION_COOKIE= diff --git a/deploy/our-recipe-atlas.service b/deploy/our-recipe-atlas.service new file mode 100644 index 0000000..91a13bb --- /dev/null +++ b/deploy/our-recipe-atlas.service @@ -0,0 +1,24 @@ +[Unit] +Description=Our Recipe Atlas Fastify service +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=recipe-atlas +Group=recipe-atlas +WorkingDirectory=/opt/our_recipe_atlas +EnvironmentFile=/etc/our-recipe-atlas.env +ExecStart=/usr/bin/node /opt/our_recipe_atlas/src/server.js +Restart=on-failure +RestartSec=5 + +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=/mnt/recipe-ssd/our_recipe_atlas/images + +[Install] +WantedBy=multi-user.target + diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..e580f78 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,26 @@ +import js from '@eslint/js'; +import globals from 'globals'; + +export default [ + { + ignores: ['node_modules/**', 'data/**'], + }, + js.configs.recommended, + { + files: ['src/**/*.js', 'tests/**/*.js', 'eslint.config.js'], + languageOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + globals: globals.node, + }, + }, + { + files: ['public/**/*.js'], + languageOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + globals: globals.browser, + }, + }, +]; + diff --git a/package.json b/package.json new file mode 100644 index 0000000..6a5b5f2 --- /dev/null +++ b/package.json @@ -0,0 +1,37 @@ +{ + "name": "our_recipe_atlas", + "version": "0.1.0", + "private": true, + "description": "A private recipe archive for importing Instagram and YouTube recipes.", + "type": "module", + "engines": { + "node": ">=22" + }, + "scripts": { + "dev": "node --watch src/server.js", + "start": "node src/server.js", + "test": "node --test", + "lint": "eslint ." + }, + "dependencies": { + "@fastify/cookie": "^11.1.2", + "@fastify/jwt": "^10.2.1", + "@fastify/oauth2": "^8.2.1", + "@fastify/static": "^10.1.3", + "dotenv": "^17.4.2", + "fastify": "^5.11.3", + "fastify-plugin": "^6.0.0", + "google-auth-library": "^11.0.1", + "insta-fetcher": "^1.4.0", + "mongodb": "^7.5.0", + "openai": "^7.4.0", + "sharp": "^0.35.3", + "youtubei.js": "^17.2.0", + "zod": "^4.4.3" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "eslint": "^10.8.1", + "globals": "^17.10.0" + } +} diff --git a/public/css/app.css b/public/css/app.css new file mode 100644 index 0000000..151bcd3 --- /dev/null +++ b/public/css/app.css @@ -0,0 +1,329 @@ +:root { + --ink: #20221d; + --paper: #f5f1e8; + --paper-deep: #e8e0d1; + --green: #3f5b45; + --green-dark: #263d2c; + --orange: #d7693d; + --line: rgba(32, 34, 29, 0.18); + --muted: #77786f; + --white: #fffdf8; + --serif: Georgia, 'Times New Roman', serif; + --sans: 'Noto Sans KR', system-ui, sans-serif; +} + +* { box-sizing: border-box; } + +html { scroll-behavior: smooth; } + +body { + margin: 0; + color: var(--ink); + background: var(--paper); + font-family: var(--sans); + line-height: 1.55; +} + +button, input, textarea { font: inherit; } +button, a { -webkit-tap-highlight-color: transparent; } +a { color: inherit; } +img { display: block; max-width: 100%; } + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.site-header { + position: sticky; + z-index: 20; + top: 0; + display: flex; + align-items: center; + justify-content: space-between; + min-height: 76px; + padding: 12px clamp(20px, 5vw, 72px); + border-bottom: 1px solid var(--line); + background: rgba(245, 241, 232, 0.93); + backdrop-filter: blur(14px); +} + +.brand { + display: inline-flex; + align-items: center; + gap: 12px; + font-family: var(--serif); + font-size: 1.25rem; + text-decoration: none; +} + +.brand-mark { + display: grid; + width: 76px; + height: 76px; + place-items: center; + border-radius: 50%; + color: var(--paper); + background: var(--green); + font-family: var(--serif); + font-size: 1.2rem; + letter-spacing: 0.05em; +} + +.brand-mark-small { width: 44px; height: 44px; font-size: 0.75rem; } + +.profile { display: flex; align-items: center; gap: 12px; font-size: 0.86rem; } +.avatar { width: 32px; height: 32px; border-radius: 50%; object-fit: cover; } +.menu-button { display: grid; width: 42px; height: 42px; padding: 10px; place-content: center; gap: 4px; border: 1px solid var(--line); border-radius: 50%; cursor: pointer; background: transparent; } +.menu-button[hidden] { display: none; } +.menu-button span { display: block; width: 18px; height: 2px; border-radius: 2px; background: var(--ink); } +.menu-button:hover { background: var(--paper-deep); } + +.button { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 44px; + padding: 10px 18px; + border: 1px solid transparent; + border-radius: 999px; + cursor: pointer; + font-weight: 600; + text-decoration: none; + transition: transform 150ms ease, background 150ms ease, opacity 150ms ease; +} + +.button:hover { transform: translateY(-1px); } +.button:disabled { cursor: wait; opacity: 0.55; transform: none; } +.button-primary { color: var(--white); background: var(--green-dark); } +.button-primary:hover { background: var(--green); } +.button-secondary { border-color: var(--ink); background: transparent; } +.button-quiet { min-height: 38px; padding: 7px 14px; border-color: var(--line); background: transparent; } +.button-danger { border-color: #a43928; color: #8d3022; background: transparent; } +.google-login { width: 100%; gap: 12px; } +.google-login span { display: grid; width: 24px; height: 24px; place-items: center; border-radius: 50%; color: var(--green-dark); background: white; } + +.page-shell { width: min(1240px, calc(100% - 40px)); margin: 0 auto; padding-bottom: 100px; } + +.hero { padding: clamp(70px, 10vw, 140px) 0 clamp(60px, 9vw, 120px); } +.hero h1 { + margin: 10px 0 24px; + font-family: var(--serif); + font-size: clamp(3rem, 7vw, 6.8rem); + font-weight: 400; + letter-spacing: -0.035em; + line-height: 0.98; +} +.hero h1 span { display: block; white-space: nowrap; } +.hero > p:last-child { max-width: 630px; margin: 0; color: var(--muted); font-size: 1.05rem; } +.eyebrow, .section-number { margin: 0; color: var(--orange); font-size: 0.72rem; font-weight: 700; letter-spacing: 0.16em; } + +.import-panel, .preview-panel { + padding: clamp(24px, 4vw, 52px); + border-radius: 28px; + background: var(--green); + color: var(--white); +} + +.import-panel { display: grid; grid-template-columns: minmax(180px, 0.7fr) 2fr; gap: 30px 54px; align-items: center; } +.import-panel h2, .preview-panel h2, .library h2 { margin: 4px 0 0; font-family: var(--serif); font-size: clamp(2rem, 4vw, 3.4rem); font-weight: 400; } +.import-form { display: flex; gap: 10px; padding: 8px; border-radius: 999px; background: var(--white); } +.import-form input { flex: 1; min-width: 0; padding: 10px 18px; border: 0; outline: 0; color: var(--ink); background: transparent; } +.status { grid-column: 2; padding: 12px 18px; border-radius: 12px; background: rgba(255, 255, 255, 0.1); } +.status.success { color: #d8efc9; } +.status.error { color: #ffd0c4; } + +.preview-panel { margin-top: 28px; color: var(--ink); background: var(--white); border: 1px solid var(--line); } +.section-heading, .editor-section-heading, .editor-group-heading { display: flex; align-items: center; justify-content: space-between; gap: 20px; } +.preview-layout { display: grid; grid-template-columns: minmax(240px, 0.8fr) 1.2fr; gap: 38px; margin-top: 34px; } +.preview-image, .detail-image, .recipe-card-image { + position: relative; + display: grid; + overflow: hidden; + place-items: center; + color: rgba(255, 255, 255, 0.7); + background: var(--green-dark); +} +.preview-image { min-height: 330px; border-radius: 20px; } +.preview-image img, .detail-image img, .recipe-card-image img { width: 100%; height: 100%; object-fit: cover; } +.preview-image .platform-label { position: absolute; bottom: 16px; left: 16px; } +.platform-label { display: inline-flex; padding: 5px 10px; border-radius: 999px; color: var(--white); background: var(--orange); font-size: 0.67rem; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; } + +.editor-fields { display: grid; gap: 18px; } +.editor-fields.two-column { grid-template-columns: 1fr 1fr; margin-top: 28px; } +.editor-fields label { display: grid; gap: 7px; color: var(--muted); font-size: 0.78rem; font-weight: 700; } +.editor-fields input, .editor-fields textarea, .editor-group input, .ingredient-row input, .step-row textarea, .step-row input { + width: 100%; + padding: 12px 13px; + border: 1px solid var(--line); + border-radius: 10px; + outline: none; + color: var(--ink); + background: #fff; +} +.editor-fields input:focus, .editor-fields textarea:focus, .editor-group input:focus, .ingredient-row input:focus, .step-row textarea:focus, .step-row input:focus { border-color: var(--green); box-shadow: 0 0 0 3px rgba(63, 91, 69, 0.12); } +.editor-fields #recipe-title { font-family: var(--serif); font-size: 2rem; } +.editor-section { margin-top: 36px; padding-top: 30px; border-top: 1px solid var(--line); } +.editor-section h3 { margin: 0; font-family: var(--serif); font-size: 1.7rem; font-weight: 400; } +.editor-group { margin: 18px 0 0; padding: 18px; border: 1px solid var(--line); border-radius: 14px; } +.editor-group-heading > input { max-width: 300px; font-weight: 700; } +.ingredient-list { display: grid; gap: 8px; margin: 14px 0; } +.ingredient-row { display: grid; grid-template-columns: 1fr 0.75fr 38px; gap: 8px; } +.text-button, .icon-button { border: 0; cursor: pointer; color: var(--green); background: transparent; font-weight: 700; } +.text-button.danger { color: #9a3c2c; } +.icon-button { font-size: 1.25rem; } +.step-row { display: grid; grid-template-columns: 34px 1fr 90px 34px; gap: 10px; align-items: start; margin-top: 12px; } +.step-row > span { display: grid; width: 32px; height: 32px; place-items: center; border-radius: 50%; color: var(--white); background: var(--green); font-size: 0.75rem; } +.form-actions { display: flex; justify-content: flex-end; margin-top: 30px; } + +.library { padding-top: clamp(70px, 10vw, 120px); } +.count-label { color: var(--muted); font-size: 0.72rem; letter-spacing: 0.12em; } +.recipe-search { display: flex; max-width: 660px; gap: 10px; margin-top: 30px; padding: 8px 14px 8px 20px; border: 1px solid var(--line); border-radius: 999px; background: var(--white); } +.recipe-search:focus-within { border-color: var(--green); box-shadow: 0 0 0 3px rgba(63, 91, 69, 0.12); } +.recipe-search input { flex: 1; min-width: 0; padding: 6px 0; border: 0; outline: 0; color: var(--ink); background: transparent; } +.tag-filter { margin-top: 30px; padding: 18px 20px; border: 1px solid var(--line); border-radius: 16px; background: rgba(255, 253, 248, 0.65); } +.tag-filter-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; font-size: 0.82rem; font-weight: 700; } +.tag-filter-heading small { margin-left: 8px; color: var(--muted); font-weight: 400; } +.tag-filter-actions { display: flex; align-items: center; gap: 12px; } +.tag-filter-options { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 13px; } +.tag-filter-button { padding: 7px 12px; border: 1px solid var(--line); border-radius: 999px; cursor: pointer; color: var(--green); background: var(--white); font-size: 0.78rem; } +.tag-filter-button[aria-pressed="true"] { border-color: var(--green); color: var(--white); background: var(--green); } +.recipe-results { min-height: 430px; } +.recipe-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 24px; margin-top: 38px; } +.recipe-card { overflow: hidden; border: 1px solid var(--line); border-radius: 20px; background: var(--white); text-decoration: none; transition: transform 180ms ease, box-shadow 180ms ease; } +.recipe-card:hover { transform: translateY(-4px); box-shadow: 0 18px 50px rgba(38, 61, 44, 0.12); } +.recipe-card-image { aspect-ratio: 4 / 3; } +.recipe-card-body { padding: 22px; } +.recipe-card-body h3 { margin: 13px 0 7px; font-family: var(--serif); font-size: 1.65rem; font-weight: 400; line-height: 1.12; } +.recipe-card-body p { margin: 0; color: var(--muted); font-size: 0.85rem; } +.recipe-card-body .recipe-card-match { margin-top: 12px; color: var(--orange); font-weight: 700; } +.recipe-card-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 14px; } +.recipe-card-tags .tag { margin: 0; } +.empty-state { padding: 50px; text-align: center; color: var(--muted); } + +.drawer-open { overflow: hidden; } +.drawer-backdrop { position: fixed; z-index: 39; inset: 0; border: 0; cursor: default; background: rgba(32, 34, 29, 0.42); } +.access-drawer { position: fixed; z-index: 40; top: 0; right: 0; width: min(440px, 100%); height: 100dvh; overflow-y: auto; padding: 34px; border-left: 1px solid var(--line); background: var(--white); box-shadow: -20px 0 70px rgba(32, 34, 29, 0.2); } +.drawer-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; } +.drawer-heading h2 { margin: 4px 0 0; font-family: var(--serif); font-size: 2.4rem; font-weight: 400; line-height: 1.05; } +.drawer-close { display: grid; width: 42px; height: 42px; flex: 0 0 auto; padding: 0; place-items: center; border: 1px solid var(--line); border-radius: 50%; cursor: pointer; color: var(--ink); background: transparent; font-size: 1.8rem; line-height: 1; } +.drawer-close:hover { background: var(--paper); } +.drawer-account { display: grid; gap: 2px; margin-top: 34px; padding: 18px; border-radius: 14px; background: var(--paper); } +.drawer-account strong { font-family: var(--serif); font-size: 1.25rem; font-weight: 400; } +.drawer-account span { overflow-wrap: anywhere; color: var(--muted); font-size: 0.78rem; } +.drawer-actions { display: grid; gap: 10px; margin-top: 28px; } +.drawer-action { display: flex; align-items: center; justify-content: space-between; width: 100%; min-height: 52px; padding: 12px 16px; border: 1px solid var(--line); border-radius: 12px; cursor: pointer; color: var(--ink); background: transparent; font-weight: 700; text-align: left; } +.drawer-action:hover { background: var(--paper); } +.drawer-action small { color: var(--orange); font-size: 0.68rem; } +.drawer-action.danger { color: #8d3022; } +.drawer-action[hidden] { display: none; } +.access-overlay-backdrop { position: fixed; z-index: 59; inset: 0; border: 0; cursor: default; background: rgba(32, 34, 29, 0.55); } +.access-overlay { position: fixed; z-index: 60; top: 50%; left: 50%; width: min(560px, calc(100% - 40px)); max-height: min(720px, calc(100dvh - 40px)); overflow-y: auto; padding: 34px; border: 1px solid var(--line); border-radius: 24px; background: var(--white); box-shadow: 0 28px 90px rgba(32, 34, 29, 0.28); transform: translate(-50%, -50%); } +.access-copy { margin: 14px 0 20px; color: var(--muted); } +.access-form { display: grid; gap: 10px; } +.access-form input { width: 100%; min-width: 0; padding: 12px 16px; border: 1px solid var(--line); border-radius: 12px; outline: none; } +.access-form input:focus { border-color: var(--green); box-shadow: 0 0 0 3px rgba(63, 91, 69, 0.12); } +.allowed-email-list { display: grid; gap: 8px; margin-top: 22px; } +.allowed-email-row { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 12px 16px; border-radius: 12px; background: var(--paper); } +.allowed-email-row small { color: var(--muted); } + +.login-page { min-height: 100vh; display: grid; place-items: center; padding: 24px; background: radial-gradient(circle at 75% 20%, #e5b79e 0, transparent 28%), var(--paper); } +.login-card { width: min(440px, 100%); padding: clamp(34px, 7vw, 62px); border: 1px solid var(--line); border-radius: 30px; background: rgba(255, 253, 248, 0.88); box-shadow: 0 30px 100px rgba(38, 61, 44, 0.14); text-align: center; backdrop-filter: blur(10px); } +.login-card .brand-mark { margin: 0 auto 28px; } +.login-card h1 { margin: 8px 0 16px; font-family: var(--serif); font-size: 3rem; font-weight: 400; line-height: 1; } +.login-copy { margin: 0 0 32px; color: var(--muted); } +.fine-print { margin: 18px 0 0; color: var(--muted); font-size: 0.72rem; } + +.detail-shell { width: min(1160px, calc(100% - 40px)); margin: 0 auto; padding: 50px 0 100px; } +.back-link { display: inline-block; margin-bottom: 28px; color: var(--muted); text-decoration: none; } +.recipe-detail { overflow: hidden; border: 1px solid var(--line); border-radius: 28px; background: var(--white); } +.detail-hero { display: grid; grid-template-columns: 1.05fr 0.95fr; min-height: 480px; } +.detail-image { min-height: 420px; } +.detail-intro { display: flex; flex-direction: column; align-items: flex-start; justify-content: center; padding: clamp(34px, 6vw, 74px); } +.detail-intro h1 { margin: 18px 0; font-family: var(--serif); font-size: clamp(2.7rem, 5vw, 5rem); font-weight: 400; line-height: 0.98; } +.detail-intro > p { color: var(--muted); } +.servings { margin: 12px 0 28px; color: var(--ink) !important; font-size: 0.82rem; font-weight: 700; } +.detail-columns { display: grid; grid-template-columns: 0.8fr 1.2fr; gap: clamp(36px, 7vw, 90px); padding: clamp(38px, 7vw, 86px); border-top: 1px solid var(--line); } +.detail-columns h2 { margin: 5px 0 28px; font-family: var(--serif); font-size: 2.5rem; font-weight: 400; } +.ingredient-detail { margin-bottom: 28px; } +.ingredient-detail h3 { margin: 0 0 10px; font-size: 0.86rem; } +.ingredient-detail ul, .tips ul { margin: 0; padding: 0; list-style: none; } +.ingredient-detail li { display: flex; justify-content: space-between; gap: 20px; padding: 9px 0; border-bottom: 1px dashed var(--line); font-size: 0.9rem; } +.ingredient-detail li strong { font-weight: 500; color: var(--muted); } +.method-list { display: grid; gap: 22px; margin: 0; padding: 0; list-style: none; } +.method-list li { display: grid; grid-template-columns: 46px 1fr; gap: 18px; align-items: start; } +.method-list li > strong { color: var(--orange); font-family: var(--serif); font-size: 1.6rem; font-weight: 400; } +.method-list a { display: grid; color: inherit; text-decoration: none; } +.method-list small { margin-top: 4px; color: var(--orange); } +.tips { margin-top: 42px; padding: 24px; border-radius: 16px; background: var(--paper); } +.tips h3 { margin-top: 0; font-family: var(--serif); font-size: 1.5rem; font-weight: 400; } +.tips li { margin-top: 8px; } +.detail-footer { display: flex; align-items: center; justify-content: space-between; gap: 24px; padding: 24px clamp(38px, 7vw, 86px); border-top: 1px solid var(--line); } +.detail-actions { display: flex; flex-wrap: wrap; gap: 10px; } +.recipe-edit-panel { color: var(--ink); background: var(--white); } +.tag { margin-right: 12px; color: var(--green); font-size: 0.78rem; } +.scroll-to-top { position: fixed; z-index: 30; right: 24px; bottom: 24px; display: grid; width: 52px; height: 52px; padding: 0; place-items: center; border: 0; border-radius: 50%; cursor: pointer; color: var(--white); background: var(--green-dark); box-shadow: 0 12px 32px rgba(38, 61, 44, 0.28); font-size: 1.35rem; transition: transform 150ms ease, background 150ms ease; } +.scroll-to-top:hover { transform: translateY(-2px); background: var(--green); } +.scroll-to-top:focus-visible { outline: 3px solid var(--orange); outline-offset: 3px; } +.scroll-to-top[hidden] { display: none; } + +.toast { position: fixed; z-index: 50; right: 24px; bottom: 24px; max-width: min(420px, calc(100% - 48px)); padding: 15px 20px; border-radius: 12px; color: var(--white); background: var(--green-dark); box-shadow: 0 14px 40px rgba(0, 0, 0, 0.2); } + +@media (max-width: 860px) { + .import-panel, .preview-layout, .detail-hero, .detail-columns { grid-template-columns: 1fr; } + .status { grid-column: 1; } + .recipe-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .detail-intro { min-height: 380px; } +} + +@media (max-width: 600px) { + .site-header { min-height: 66px; gap: 8px; padding-inline: 12px; } + .brand { min-width: 0; gap: 8px; font-size: clamp(0.86rem, 3.8vw, 1rem); } + .brand-mark-small { width: 36px; height: 36px; flex: 0 0 auto; font-size: 0.62rem; } + .brand > span:last-child { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .profile { min-width: 0; gap: 6px; } + .profile #profile-name { display: block; overflow: hidden; max-width: clamp(44px, 15vw, 90px); font-size: 0.72rem; text-overflow: ellipsis; white-space: nowrap; } + .profile .avatar { display: block; width: 28px; height: 28px; flex: 0 0 auto; } + .profile .menu-button { width: 38px; height: 38px; flex: 0 0 auto; padding: 8px; } + .profile .button-quiet { min-height: 36px; padding: 6px 9px; font-size: 0.72rem; } + .page-shell, .detail-shell { width: min(100% - 24px, 1240px); } + .hero { padding-top: 70px; } + .hero h1 { font-size: clamp(1.45rem, 8.5vw, 4.5rem); } + .import-panel, .preview-panel { padding: 24px 18px; border-radius: 20px; } + .import-form { flex-direction: column; border-radius: 18px; } + .import-form .button { width: 100%; } + .recipe-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; } + .recipe-card { border-radius: 14px; } + .recipe-card-body { padding: 14px; } + .recipe-card-body h3 { display: -webkit-box; overflow: hidden; margin: 10px 0 6px; font-size: 1.15rem; -webkit-box-orient: vertical; -webkit-line-clamp: 3; } + .recipe-card-body > p:not(.recipe-card-match) { display: -webkit-box; overflow: hidden; font-size: 0.75rem; -webkit-box-orient: vertical; -webkit-line-clamp: 2; } + .recipe-card-tags { gap: 4px; margin-top: 10px; } + .recipe-card-tags .tag { font-size: 0.68rem; } + .tag-filter-heading { align-items: flex-start; flex-direction: column; } + .tag-filter-heading small { display: block; margin: 2px 0 0; } + .recipe-results { min-height: 340px; } + .recipe-search { border-radius: 18px; } + .access-drawer { padding: 26px 20px; } + .access-overlay { width: calc(100% - 24px); max-height: calc(100dvh - 24px); padding: 26px 20px; border-radius: 20px; } + .editor-fields.two-column { grid-template-columns: 1fr; } + .ingredient-row { grid-template-columns: 1fr 0.7fr 34px; } + .step-row { grid-template-columns: 32px 1fr 30px; } + .step-row [data-step-time] { grid-column: 2; } + .detail-shell { padding-top: 28px; } + .detail-hero { min-height: 0; } + .detail-image { min-height: 300px; } + .detail-columns { padding: 34px 22px; } + .detail-footer { align-items: flex-start; flex-direction: column; padding: 24px 22px; } + .scroll-to-top { right: 16px; bottom: 16px; width: 48px; height: 48px; } +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { scroll-behavior: auto !important; transition: none !important; } +} diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..31f276c --- /dev/null +++ b/public/index.html @@ -0,0 +1,150 @@ + + + + + + + Our Recipe Atlas + + + + + +
+
+

COLLECT · REFINE · COOK

+

+ 오늘 발견한 + 레시피를 + 나만의 + 아틀라스에. +

+

Instagram Reel 또는 YouTube 영상 링크를 붙여 넣으면 재료와 조리법을 정리합니다.

+
+ +
+
+

01

+

새 레시피 가져오기

+
+
+ + + +
+ +
+ + + +
+
+
+

03

+

Recipe Library

+
+ +
+ + +
+
+ +
+
+ +
+ + + + + + + + + + + + diff --git a/public/js/api.js b/public/js/api.js new file mode 100644 index 0000000..5ed5028 --- /dev/null +++ b/public/js/api.js @@ -0,0 +1,21 @@ +export async function apiRequest(path, options = {}) { + const response = await fetch(path, { + ...options, + credentials: 'same-origin', + headers: { + ...(options.body ? { 'content-type': 'application/json' } : {}), + ...options.headers, + }, + }); + + if (response.status === 401) { + window.location.assign('/login'); + throw new Error('로그인이 필요합니다.'); + } + + if (response.status === 204) return null; + const data = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(data.error?.message ?? '요청을 처리하지 못했습니다.'); + return data; +} + diff --git a/public/js/app.js b/public/js/app.js new file mode 100644 index 0000000..153ee8b --- /dev/null +++ b/public/js/app.js @@ -0,0 +1,515 @@ +import { apiRequest } from './api.js'; +import { bindLogout, loadSession } from './auth.js'; +import { searchRecipes } from './recipe-search.js'; +import { bindScrollToTop } from './scroll-to-top.js'; + +const elements = { + importForm: document.querySelector('#import-form'), + importStatus: document.querySelector('#import-status'), + previewSection: document.querySelector('#preview-section'), + previewForm: document.querySelector('#preview-form'), + recipeGrid: document.querySelector('#recipe-grid'), + emptyLibrary: document.querySelector('#empty-library'), + recipeCount: document.querySelector('#recipe-count'), + recipeSearchInput: document.querySelector('#recipe-search-input'), + clearRecipeSearch: document.querySelector('#clear-recipe-search'), + tagFilter: document.querySelector('#tag-filter'), + tagFilterOptions: document.querySelector('#tag-filter-options'), + toggleTagFilter: document.querySelector('#toggle-tag-filter'), + clearTagFilter: document.querySelector('#clear-tag-filter'), + accessMenuButton: document.querySelector('#access-menu-button'), + accessDrawer: document.querySelector('#access-drawer'), + closeAccessDrawer: document.querySelector('#close-access-drawer'), + drawerBackdrop: document.querySelector('#drawer-backdrop'), + drawerUserName: document.querySelector('#drawer-user-name'), + drawerUserEmail: document.querySelector('#drawer-user-email'), + openAccessOverlay: document.querySelector('#open-access-overlay'), + accessOverlay: document.querySelector('#access-overlay'), + closeAccessOverlay: document.querySelector('#close-access-overlay'), + accessOverlayBackdrop: document.querySelector('#access-overlay-backdrop'), + accessForm: document.querySelector('#access-form'), + accessEmail: document.querySelector('#access-email'), + allowedEmailList: document.querySelector('#allowed-email-list'), + scrollToTop: document.querySelector('#scroll-to-top'), + toast: document.querySelector('#toast'), +}; + +let previewData = null; +let savedRecipes = []; +let currentUser = null; +let searchQuery = ''; +let searchTimer = null; +let searchIsComposing = false; +let showAllTags = false; +const selectedTags = new Set(); + +const SEARCH_DEBOUNCE_MS = 180; +const VISIBLE_TAG_LIMIT = 6; + +function escapeHtml(value) { + return String(value ?? '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +function safeImageUrl(value) { + if (!value) return ''; + try { + const url = new URL(value, window.location.origin); + if (url.protocol === 'http:' || url.protocol === 'https:') return url.href; + } catch { + return ''; + } + return ''; +} + +function showToast(message) { + elements.toast.textContent = message; + elements.toast.hidden = false; + window.setTimeout(() => { elements.toast.hidden = true; }, 3200); +} + +function setStatus(message, kind = '') { + elements.importStatus.textContent = message; + elements.importStatus.className = `status ${kind}`.trim(); + elements.importStatus.hidden = false; +} + +function recipeImage(recipe) { + return recipe.imagePath ? `/media/${recipe.imagePath}` : ''; +} + +function normalizeTag(value) { + return String(value ?? '').trim().replace(/^#+\s*/, '').replace(/\s+/g, ' '); +} + +function tagKey(value) { + return normalizeTag(value).toLocaleLowerCase(); +} + +function tagsForRecipe(recipe) { + const tags = new Map(); + for (const value of recipe.tags ?? []) { + const tag = normalizeTag(value); + if (tag) tags.set(tagKey(tag), tag); + } + return tags; +} + +function availableTags() { + const tags = new Map(); + for (const recipe of savedRecipes) { + for (const [key, label] of tagsForRecipe(recipe)) { + const existing = tags.get(key); + tags.set(key, { label: existing?.label ?? label, count: (existing?.count ?? 0) + 1 }); + } + } + return [...tags.entries()].sort(([, a], [, b]) => ( + b.count - a.count || a.label.localeCompare(b.label, 'ko') + )); +} + +function renderTagFilter() { + const tags = availableTags(); + const selectedExtras = tags + .slice(VISIBLE_TAG_LIMIT) + .filter(([key]) => selectedTags.has(key)); + const visibleTags = showAllTags + ? tags + : [...tags.slice(0, VISIBLE_TAG_LIMIT), ...selectedExtras]; + const hiddenTagCount = tags.length - visibleTags.length; + + elements.tagFilter.hidden = tags.length === 0; + elements.clearTagFilter.hidden = selectedTags.size === 0; + elements.toggleTagFilter.hidden = showAllTags + ? tags.length <= VISIBLE_TAG_LIMIT + : hiddenTagCount === 0; + elements.toggleTagFilter.textContent = showAllTags + ? '접기' + : `+ ${hiddenTagCount}개 더보기`; + elements.toggleTagFilter.setAttribute('aria-expanded', String(showAllTags)); + elements.tagFilterOptions.innerHTML = visibleTags.map(([key, { label, count }]) => ` + + `).join(''); +} + +function renderRecipes() { + const tagFilteredRecipes = selectedTags.size === 0 + ? savedRecipes + : savedRecipes.filter((recipe) => { + const recipeTags = tagsForRecipe(recipe); + return [...selectedTags].every((tag) => recipeTags.has(tag)); + }); + const matches = searchRecipes(tagFilteredRecipes, searchQuery); + const hasFilters = selectedTags.size > 0 || searchQuery.length > 0; + + elements.recipeCount.textContent = hasFilters + ? `${matches.length} / ${savedRecipes.length} RECIPES` + : `${matches.length} RECIPES`; + elements.emptyLibrary.hidden = matches.length !== 0; + elements.emptyLibrary.innerHTML = savedRecipes.length === 0 + ? '아직 저장한 레시피가 없습니다.
첫 링크를 가져와 보세요.' + : '검색 조건에 맞는 레시피가 없습니다.'; + elements.recipeGrid.innerHTML = matches.map(({ recipe, matchedIngredients }) => { + const image = safeImageUrl(recipeImage(recipe)); + const tags = [...tagsForRecipe(recipe).values()]; + return ` +
+ ${image ? `` : 'NO IMAGE'} +
+
+ ${escapeHtml(recipe.source.platform)} +

${escapeHtml(recipe.title)}

+

${escapeHtml(recipe.summary || recipe.source.author || '')}

+ ${matchedIngredients.length + ? `

일치 재료: ${matchedIngredients.map(escapeHtml).join(', ')}

` + : ''} + ${tags.length ? `
${tags.map((tag) => `#${escapeHtml(tag)}`).join('')}
` : ''} +
+
`; + }).join(''); +} + +async function loadRecipes() { + const { recipes } = await apiRequest('/api/recipes'); + savedRecipes = recipes; + const currentTagKeys = new Set(availableTags().map(([key]) => key)); + for (const tag of selectedTags) { + if (!currentTagKeys.has(tag)) selectedTags.delete(tag); + } + renderTagFilter(); + renderRecipes(); +} + +async function loadAllowedEmails() { + const { emails } = await apiRequest('/api/allowed-emails'); + elements.allowedEmailList.innerHTML = emails.map((email) => { + const isCurrent = email === currentUser.email.toLowerCase(); + return `
+ ${escapeHtml(email)} + ${isCurrent + ? '현재 관리자' + : ``} +
`; + }).join(''); +} + +function updatePageLock() { + document.body.classList.toggle( + 'drawer-open', + !elements.accessDrawer.hidden || !elements.accessOverlay.hidden, + ); +} + +function setAccessDrawerOpen(open) { + elements.accessMenuButton.setAttribute('aria-expanded', String(open)); + elements.accessDrawer.setAttribute('aria-hidden', String(!open)); + elements.accessDrawer.hidden = !open; + elements.drawerBackdrop.hidden = !open; + updatePageLock(); + if (open) elements.closeAccessDrawer.focus(); + else elements.accessMenuButton.focus(); +} + +function setAccessOverlayOpen(open) { + elements.accessOverlay.setAttribute('aria-hidden', String(!open)); + elements.accessOverlay.hidden = !open; + elements.accessOverlayBackdrop.hidden = !open; + updatePageLock(); + if (open) elements.accessEmail.focus(); + else elements.accessMenuButton.focus(); +} + +function ingredientGroupTemplate(group, groupIndex) { + return `
+
+ + +
+
+ ${group.items.map((item, itemIndex) => `
+ + + +
`).join('')} +
+ +
`; +} + +function renderPreview() { + const { recipe, imagePreviewUrl, source } = previewData; + const image = safeImageUrl(imagePreviewUrl); + elements.previewForm.innerHTML = ` +
+
+ ${image ? `가져온 레시피 미리보기` : 'NO IMAGE'} + ${escapeHtml(source.platform)} +
+
+ + + +
+
+
+

재료

+
${recipe.ingredientGroups.map(ingredientGroupTemplate).join('')}
+
+
+

조리 순서

+
${recipe.steps.map((step, index) => `
+ ${index + 1} + + + +
`).join('')}
+
+
+ + +
+
+ +
`; + elements.previewSection.hidden = false; + elements.previewSection.scrollIntoView({ behavior: 'smooth', block: 'start' }); +} + +function readPreviewForm() { + const ingredientGroups = [...elements.previewForm.querySelectorAll('[data-ingredient-group]')].map((group) => ({ + name: group.querySelector('[data-group-name]').value.trim(), + items: [...group.querySelectorAll('[data-ingredient-item]')].map((item) => ({ + name: item.querySelector('[data-item-name]').value.trim(), + amount: item.querySelector('[data-item-amount]').value.trim() || null, + })), + })); + const steps = [...elements.previewForm.querySelectorAll('[data-step]')].map((step, index) => { + const timestamp = step.querySelector('[data-step-time]').value; + return { + order: index + 1, + text: step.querySelector('[data-step-text]').value.trim(), + timestampSec: timestamp === '' ? null : Number(timestamp), + }; + }); + + return { + title: document.querySelector('#recipe-title').value.trim(), + summary: document.querySelector('#recipe-summary').value.trim() || null, + servings: document.querySelector('#recipe-servings').value.trim() || null, + ingredientGroups, + steps, + tips: document.querySelector('#recipe-tips').value.split(/\r?\n/).map((item) => item.trim()).filter(Boolean), + tags: document.querySelector('#recipe-tags').value.split(',').map((item) => item.trim()).filter(Boolean), + }; +} + +function mutatePreview(action, button) { + previewData.recipe = readPreviewForm(); + const groupIndex = Number(button.dataset.group); + const itemIndex = Number(button.dataset.item); + const stepIndex = Number(button.dataset.stepIndex); + + if (action === 'add-group') previewData.recipe.ingredientGroups.push({ name: '재료', items: [] }); + if (action === 'remove-group') previewData.recipe.ingredientGroups.splice(groupIndex, 1); + if (action === 'add-item') previewData.recipe.ingredientGroups[groupIndex].items.push({ name: '', amount: null }); + if (action === 'remove-item') previewData.recipe.ingredientGroups[groupIndex].items.splice(itemIndex, 1); + if (action === 'add-step') previewData.recipe.steps.push({ order: previewData.recipe.steps.length + 1, text: '', timestampSec: null }); + if (action === 'remove-step') previewData.recipe.steps.splice(stepIndex, 1); + previewData.recipe.steps.forEach((step, index) => { step.order = index + 1; }); + renderPreview(); +} + +elements.importForm.addEventListener('submit', async (event) => { + event.preventDefault(); + const button = elements.importForm.querySelector('button'); + button.disabled = true; + const timers = [ + window.setTimeout(() => setStatus('콘텐츠 가져오는 중...'), 500), + window.setTimeout(() => setStatus('레시피 분석 중...'), 1600), + ]; + setStatus('링크 확인 중...'); + + try { + const url = new FormData(elements.importForm).get('url'); + previewData = await apiRequest('/api/import/preview', { + method: 'POST', + body: JSON.stringify({ url }), + }); + timers.forEach(window.clearTimeout); + setStatus('분석 완료', 'success'); + renderPreview(); + } catch (error) { + timers.forEach(window.clearTimeout); + setStatus(error.message, 'error'); + } finally { + button.disabled = false; + } +}); + +elements.previewForm.addEventListener('click', (event) => { + const button = event.target.closest('[data-action]'); + if (button) mutatePreview(button.dataset.action, button); +}); + +elements.previewForm.addEventListener('submit', async (event) => { + event.preventDefault(); + const button = elements.previewForm.querySelector('[type="submit"]'); + button.disabled = true; + try { + const { recipe } = await apiRequest('/api/recipes', { + method: 'POST', + body: JSON.stringify({ + recipe: readPreviewForm(), + source: previewData.source, + imagePreviewUrl: previewData.imagePreviewUrl, + }), + }); + previewData = null; + elements.previewSection.hidden = true; + elements.importForm.reset(); + showToast(`${recipe.title} 레시피를 저장했습니다.`); + await loadRecipes(); + } catch (error) { + showToast(error.message); + } finally { + button.disabled = false; + } +}); + +document.querySelector('#cancel-preview').addEventListener('click', () => { + previewData = null; + elements.previewSection.hidden = true; +}); + +elements.tagFilterOptions.addEventListener('click', (event) => { + const button = event.target.closest('[data-tag]'); + if (!button) return; + const key = button.dataset.tag; + if (selectedTags.has(key)) selectedTags.delete(key); + else selectedTags.add(key); + renderTagFilter(); + renderRecipes(); +}); + +elements.clearTagFilter.addEventListener('click', () => { + selectedTags.clear(); + showAllTags = false; + renderTagFilter(); + renderRecipes(); +}); + +elements.toggleTagFilter.addEventListener('click', () => { + showAllTags = !showAllTags; + renderTagFilter(); +}); + +function scheduleRecipeSearch() { + const nextQuery = elements.recipeSearchInput.value.trim(); + elements.clearRecipeSearch.hidden = nextQuery.length === 0; + window.clearTimeout(searchTimer); + searchTimer = window.setTimeout(() => { + searchQuery = nextQuery; + searchTimer = null; + renderRecipes(); + }, SEARCH_DEBOUNCE_MS); +} + +elements.recipeSearchInput.addEventListener('compositionstart', () => { + searchIsComposing = true; +}); + +elements.recipeSearchInput.addEventListener('compositionend', () => { + searchIsComposing = false; + scheduleRecipeSearch(); +}); + +elements.recipeSearchInput.addEventListener('input', (event) => { + elements.clearRecipeSearch.hidden = elements.recipeSearchInput.value.trim().length === 0; + if (searchIsComposing || event.isComposing) return; + scheduleRecipeSearch(); +}); + +elements.clearRecipeSearch.addEventListener('click', () => { + window.clearTimeout(searchTimer); + searchTimer = null; + elements.recipeSearchInput.value = ''; + searchQuery = ''; + elements.clearRecipeSearch.hidden = true; + elements.recipeSearchInput.focus(); + renderRecipes(); +}); + +elements.accessMenuButton.addEventListener('click', () => { + setAccessDrawerOpen(true); +}); + +elements.openAccessOverlay.addEventListener('click', async () => { + setAccessDrawerOpen(false); + setAccessOverlayOpen(true); + try { + await loadAllowedEmails(); + } catch (error) { + showToast(error.message); + } +}); + +elements.closeAccessDrawer.addEventListener('click', () => setAccessDrawerOpen(false)); +elements.drawerBackdrop.addEventListener('click', () => setAccessDrawerOpen(false)); +elements.closeAccessOverlay.addEventListener('click', () => setAccessOverlayOpen(false)); +elements.accessOverlayBackdrop.addEventListener('click', () => setAccessOverlayOpen(false)); +document.addEventListener('keydown', (event) => { + if (event.key !== 'Escape') return; + if (!elements.accessOverlay.hidden) setAccessOverlayOpen(false); + else if (!elements.accessDrawer.hidden) setAccessDrawerOpen(false); +}); + +elements.accessForm.addEventListener('submit', async (event) => { + event.preventDefault(); + const button = elements.accessForm.querySelector('button'); + button.disabled = true; + try { + const email = new FormData(elements.accessForm).get('email'); + await apiRequest('/api/allowed-emails', { + method: 'POST', + body: JSON.stringify({ email }), + }); + elements.accessForm.reset(); + showToast(`${email} 계정을 추가했습니다.`); + await loadAllowedEmails(); + } catch (error) { + showToast(error.message); + } finally { + button.disabled = false; + } +}); + +elements.allowedEmailList.addEventListener('click', async (event) => { + const button = event.target.closest('[data-remove-email]'); + if (!button) return; + button.disabled = true; + try { + await apiRequest(`/api/allowed-emails/${encodeURIComponent(button.dataset.removeEmail)}`, { + method: 'DELETE', + }); + showToast(`${button.dataset.removeEmail} 계정을 삭제했습니다.`); + await loadAllowedEmails(); + } catch (error) { + showToast(error.message); + button.disabled = false; + } +}); + +bindLogout(); +bindScrollToTop(elements.scrollToTop); +currentUser = await loadSession(); +elements.drawerUserName.textContent = currentUser.name || currentUser.email; +elements.drawerUserEmail.textContent = currentUser.email; +await loadRecipes(); +if (currentUser.canManageAccess) { + elements.openAccessOverlay.hidden = false; +} diff --git a/public/js/auth.js b/public/js/auth.js new file mode 100644 index 0000000..a8d6d7d --- /dev/null +++ b/public/js/auth.js @@ -0,0 +1,24 @@ +import { apiRequest } from './api.js'; + +export async function loadSession() { + const { user } = await apiRequest('/auth/me'); + const name = document.querySelector('#profile-name'); + if (name) name.textContent = user.name || user.email; + + const image = document.querySelector('#profile-image'); + if (image && user.picture) { + image.src = user.picture; + image.alt = `${user.name || user.email} 프로필`; + image.hidden = false; + } + return user; +} + +export function bindLogout() { + document.querySelectorAll('[data-logout]').forEach((button) => { + button.addEventListener('click', async () => { + await apiRequest('/auth/logout', { method: 'POST' }); + window.location.assign('/login'); + }); + }); +} diff --git a/public/js/recipe-search.js b/public/js/recipe-search.js new file mode 100644 index 0000000..96ddfa7 --- /dev/null +++ b/public/js/recipe-search.js @@ -0,0 +1,49 @@ +function normalizeSearchText(value) { + return String(value ?? '').trim().toLocaleLowerCase().replace(/\s+/g, ' '); +} + +function ingredientNames(recipe) { + return (recipe.ingredientGroups ?? []) + .flatMap((group) => group.items ?? []) + .map((item) => item.name) + .filter(Boolean); +} + +export function rankRecipeMatch(recipe, query) { + const normalizedQuery = normalizeSearchText(query); + if (!normalizedQuery) return { score: 0, matchedIngredients: [] }; + + const ingredients = ingredientNames(recipe); + const matchedIngredients = ingredients.filter((name) => ( + normalizeSearchText(name).includes(normalizedQuery) + )); + const hasExactIngredient = matchedIngredients.some((name) => ( + normalizeSearchText(name) === normalizedQuery + )); + const title = normalizeSearchText(recipe.title); + const tags = (recipe.tags ?? []).map(normalizeSearchText); + const summary = normalizeSearchText(recipe.summary); + + let score = 0; + if (hasExactIngredient) score = 500; + else if (matchedIngredients.length > 0) score = 400; + else if (title === normalizedQuery) score = 350; + else if (title.includes(normalizedQuery)) score = 300; + else if (tags.includes(normalizedQuery)) score = 250; + else if (tags.some((tag) => tag.includes(normalizedQuery))) score = 200; + else if (summary.includes(normalizedQuery)) score = 100; + + return { score, matchedIngredients }; +} + +export function searchRecipes(recipes, query) { + if (!normalizeSearchText(query)) { + return recipes.map((recipe) => ({ recipe, score: 0, matchedIngredients: [] })); + } + + return recipes + .map((recipe, index) => ({ recipe, index, ...rankRecipeMatch(recipe, query) })) + .filter(({ score }) => score > 0) + .sort((a, b) => b.score - a.score || a.index - b.index) + .map(({ recipe, score, matchedIngredients }) => ({ recipe, score, matchedIngredients })); +} diff --git a/public/js/recipe.js b/public/js/recipe.js new file mode 100644 index 0000000..d5cb136 --- /dev/null +++ b/public/js/recipe.js @@ -0,0 +1,233 @@ +import { apiRequest } from './api.js'; +import { bindLogout, loadSession } from './auth.js'; +import { bindScrollToTop } from './scroll-to-top.js'; + +const detail = document.querySelector('#recipe-detail'); +const toast = document.querySelector('#toast'); +const scrollToTop = document.querySelector('#scroll-to-top'); +let currentRecipe = null; +let toastTimer; + +bindScrollToTop(scrollToTop); + +function escapeHtml(value) { + return String(value ?? '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +function youtubeTimestampUrl(sourceUrl, seconds) { + try { + const url = new URL(sourceUrl); + url.searchParams.set('t', `${Math.round(seconds)}s`); + return url.href; + } catch { + return sourceUrl; + } +} + +function showToast(message) { + window.clearTimeout(toastTimer); + toast.textContent = message; + toast.hidden = false; + toastTimer = window.setTimeout(() => { toast.hidden = true; }, 3200); +} + +function ingredientGroupTemplate(group, groupIndex) { + return `
+
+ + +
+
+ ${group.items.map((item, itemIndex) => `
+ + + +
`).join('')} +
+ +
`; +} + +function readRecipeForm(form) { + const ingredientGroups = [...form.querySelectorAll('[data-ingredient-group]')].map((group) => ({ + name: group.querySelector('[data-group-name]').value.trim(), + items: [...group.querySelectorAll('[data-ingredient-item]')].map((item) => ({ + name: item.querySelector('[data-item-name]').value.trim(), + amount: item.querySelector('[data-item-amount]').value.trim() || null, + })), + })); + const steps = [...form.querySelectorAll('[data-step]')].map((step, index) => { + const timestamp = step.querySelector('[data-step-time]').value; + return { + order: index + 1, + text: step.querySelector('[data-step-text]').value.trim(), + timestampSec: timestamp === '' ? null : Number(timestamp), + }; + }); + + return { + title: form.querySelector('#recipe-title').value.trim(), + summary: form.querySelector('#recipe-summary').value.trim() || null, + servings: form.querySelector('#recipe-servings').value.trim() || null, + ingredientGroups, + steps, + tips: form.querySelector('#recipe-tips').value.split(/\r?\n/).map((item) => item.trim()).filter(Boolean), + tags: form.querySelector('#recipe-tags').value.split(',').map((item) => item.trim()).filter(Boolean), + }; +} + +function renderRecipeEditor(recipe) { + const image = recipe.imagePath ? `/media/${recipe.imagePath}` : null; + detail.innerHTML = ` + ← Recipe Library +
+
+

EDIT RECIPE

레시피 수정

+ +
+
+
+ ${image ? `` : 'NO IMAGE'} + ${escapeHtml(recipe.source.platform)} +
+
+ + + +
+
+
+

재료

+
${recipe.ingredientGroups.map(ingredientGroupTemplate).join('')}
+
+
+

조리 순서

+
${recipe.steps.map((step, index) => `
+ ${index + 1} + + + +
`).join('')}
+
+
+ + +
+
+
`; + + const form = document.querySelector('#recipe-edit-form'); + document.querySelector('#cancel-edit').addEventListener('click', () => renderRecipe(currentRecipe)); + + form.addEventListener('click', (event) => { + const button = event.target.closest('[data-action]'); + if (!button) return; + const draft = { ...recipe, ...readRecipeForm(form) }; + const groupIndex = Number(button.dataset.group); + const itemIndex = Number(button.dataset.item); + const stepIndex = Number(button.dataset.stepIndex); + + if (button.dataset.action === 'add-group') draft.ingredientGroups.push({ name: '재료', items: [] }); + if (button.dataset.action === 'remove-group') draft.ingredientGroups.splice(groupIndex, 1); + if (button.dataset.action === 'add-item') draft.ingredientGroups[groupIndex].items.push({ name: '', amount: null }); + if (button.dataset.action === 'remove-item') draft.ingredientGroups[groupIndex].items.splice(itemIndex, 1); + if (button.dataset.action === 'add-step') draft.steps.push({ order: draft.steps.length + 1, text: '', timestampSec: null }); + if (button.dataset.action === 'remove-step') draft.steps.splice(stepIndex, 1); + draft.steps.forEach((step, index) => { step.order = index + 1; }); + renderRecipeEditor(draft); + }); + + form.addEventListener('submit', async (event) => { + event.preventDefault(); + const button = form.querySelector('[type="submit"]'); + button.disabled = true; + try { + const { recipe: updatedRecipe } = await apiRequest(`/api/recipes/${encodeURIComponent(recipe._id)}`, { + method: 'PATCH', + body: JSON.stringify(readRecipeForm(form)), + }); + currentRecipe = updatedRecipe; + renderRecipe(currentRecipe); + showToast('레시피를 수정했습니다.'); + } catch (error) { + showToast(error.message); + button.disabled = false; + } + }); +} + +function renderRecipe(recipe) { + document.title = `${recipe.title} · Our Recipe Atlas`; + const image = recipe.imagePath ? `/media/${recipe.imagePath}` : null; + detail.innerHTML = ` + ← Recipe Library +
+
+
${image ? `` : 'NO IMAGE'}
+
+ ${escapeHtml(recipe.source.platform)} +

${escapeHtml(recipe.title)}

+ ${recipe.summary ? `

${escapeHtml(recipe.summary)}

` : ''} + ${recipe.servings ? `

분량 · ${escapeHtml(recipe.servings)}

` : ''} +
+ + 원본 보기 ↗ +
+
+
+
+
+

INGREDIENTS

+

재료

+ ${recipe.ingredientGroups.map((group) => `
+

${escapeHtml(group.name)}

+
    ${group.items.map((item) => `
  • ${escapeHtml(item.name)}${escapeHtml(item.amount || '')}
  • `).join('')}
+
`).join('')} +
+
+

METHOD

+

조리 순서

+
    ${recipe.steps.map((step) => { + const body = `${escapeHtml(step.text)}`; + return `
  1. ${String(step.order).padStart(2, '0')}${step.timestampSec != null && recipe.source.platform === 'youtube' + ? `${body}${Math.floor(step.timestampSec / 60)}:${String(Math.round(step.timestampSec) % 60).padStart(2, '0')} ↗` + : body}
  2. `; + }).join('')}
+ ${recipe.tips.length ? `

Tips

    ${recipe.tips.map((tip) => `
  • ${escapeHtml(tip)}
  • `).join('')}
` : ''} +
+
+
+
${recipe.tags.map((tag) => `#${escapeHtml(tag)}`).join('')}
+ +
+
`; + + document.querySelector('#edit-recipe').addEventListener('click', () => renderRecipeEditor(recipe)); + + document.querySelector('#delete-recipe').addEventListener('click', async () => { + if (!window.confirm(`'${recipe.title}' 레시피를 삭제할까요?`)) return; + try { + await apiRequest(`/api/recipes/${encodeURIComponent(recipe._id)}`, { method: 'DELETE' }); + window.location.assign('/'); + } catch (error) { + showToast(error.message); + } + }); +} + +bindLogout(); +await loadSession(); + +try { + const recipeId = decodeURIComponent(window.location.pathname.split('/').filter(Boolean).pop()); + const { recipe } = await apiRequest(`/api/recipes/${encodeURIComponent(recipeId)}`); + currentRecipe = recipe; + renderRecipe(currentRecipe); +} catch (error) { + detail.innerHTML = `

${escapeHtml(error.message)}

목록으로 돌아가기`; +} diff --git a/public/js/scroll-to-top.js b/public/js/scroll-to-top.js new file mode 100644 index 0000000..a230115 --- /dev/null +++ b/public/js/scroll-to-top.js @@ -0,0 +1,14 @@ +export function bindScrollToTop(button) { + if (!button) return; + + function updateVisibility() { + button.hidden = window.scrollY <= 480; + } + + window.addEventListener('scroll', updateVisibility, { passive: true }); + button.addEventListener('click', () => { + const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + window.scrollTo({ top: 0, behavior: reduceMotion ? 'auto' : 'smooth' }); + }); + updateVisibility(); +} diff --git a/public/login.html b/public/login.html new file mode 100644 index 0000000..5e7c3af --- /dev/null +++ b/public/login.html @@ -0,0 +1,24 @@ + + + + + + + 로그인 · Our Recipe Atlas + + + +
+ +

PRIVATE RECIPE ARCHIVE

+

Our Recipe Atlas

+ + +

허용 목록에 등록된 Google 계정만 로그인할 수 있습니다.

+
+ + + diff --git a/public/recipe.html b/public/recipe.html new file mode 100644 index 0000000..bcc3068 --- /dev/null +++ b/public/recipe.html @@ -0,0 +1,29 @@ + + + + + + Recipe · Our Recipe Atlas + + + + +
+

레시피를 불러오는 중입니다.

+
+ + + + + diff --git a/src/app.js b/src/app.js new file mode 100644 index 0000000..1ea194f --- /dev/null +++ b/src/app.js @@ -0,0 +1,92 @@ +import Fastify from 'fastify'; +import { ZodError } from 'zod'; +import { loadConfig } from './config/env.js'; +import authPlugin from './plugins/auth.js'; +import mongoPlugin from './plugins/mongo.js'; +import staticPlugin from './plugins/static.js'; +import accessRoutes from './routes/access.routes.js'; +import importRoutes from './routes/import.routes.js'; +import recipeRoutes from './routes/recipe.routes.js'; +import { createSourceExtractor } from './services/extractors/index.js'; +import { ImageStorageService } from './services/image-storage.service.js'; +import { RecipeParserService } from './services/recipe-parser.service.js'; +import { AppError } from './utils/errors.js'; + +function registerErrorHandling(app) { + app.setErrorHandler((error, request, reply) => { + if (error instanceof ZodError) { + return reply.code(400).send({ + error: { + code: 'VALIDATION_ERROR', + message: error.issues[0]?.message ?? '입력값을 확인해 주세요.', + details: error.issues, + }, + }); + } + + if (error instanceof AppError) { + if (error.statusCode >= 500) request.log.error({ error }, error.message); + return reply.code(error.statusCode).send({ + error: { code: error.code, message: error.message }, + }); + } + + request.log.error({ error }, '처리되지 않은 서버 오류'); + return reply.code(500).send({ + error: { + code: 'INTERNAL_ERROR', + message: '요청을 처리하지 못했습니다.', + }, + }); + }); + + app.setNotFoundHandler((_request, reply) => reply.code(404).send({ + error: { code: 'NOT_FOUND', message: '요청한 경로를 찾을 수 없습니다.' }, + })); +} + +export async function buildApp({ config = loadConfig(), dependencies = {} } = {}) { + const app = Fastify({ + logger: config.nodeEnv === 'test' ? false : { level: config.logLevel }, + trustProxy: config.nodeEnv === 'production', + }); + app.decorate('config', config); + registerErrorHandling(app); + + if ( + dependencies.allowedEmailRepository + && dependencies.recipeRepository + && dependencies.userRepository + ) { + app.decorate('allowedEmailRepository', dependencies.allowedEmailRepository); + app.decorate('recipeRepository', dependencies.recipeRepository); + app.decorate('userRepository', dependencies.userRepository); + } else { + await app.register(mongoPlugin, { config }); + } + + await app.register(authPlugin, { + config, + googleClient: dependencies.googleClient, + }); + + app.decorate( + 'sourceExtractor', + dependencies.sourceExtractor ?? createSourceExtractor({ config }), + ); + app.decorate( + 'recipeParser', + dependencies.recipeParser ?? new RecipeParserService(config.minimax), + ); + app.decorate( + 'imageStorage', + dependencies.imageStorage ?? new ImageStorageService({ root: config.imageRoot }), + ); + + await app.register(accessRoutes); + await app.register(importRoutes); + await app.register(recipeRoutes); + await app.register(staticPlugin, { config }); + await app.ready(); + return app; +} diff --git a/src/config/env.js b/src/config/env.js new file mode 100644 index 0000000..7a2a513 --- /dev/null +++ b/src/config/env.js @@ -0,0 +1,175 @@ +import path from 'node:path'; +import { config as loadDotEnv } from 'dotenv'; +import { z } from 'zod'; + +const blankToUndefined = (value) => { + if (typeof value !== 'string') return value; + const trimmed = value.trim(); + return trimmed === '' ? undefined : trimmed; +}; + +const optionalText = z.preprocess(blankToUndefined, z.string().min(1).optional()); + +const WINDOWS_DEVELOPMENT_PROFILE = Object.freeze({ + nodeEnv: 'development', + mongoUri: 'mongodb://192.168.0.240:27017/our_recipe_atlas', + mongoFallbackUri: 'mongodb://172.16.0.7:27017/our_recipe_atlas', + imageRoot: './data/images', +}); + +const LINUX_PRODUCTION_PROFILE = Object.freeze({ + nodeEnv: 'production', + mongoUri: 'mongodb://192.168.0.240:27017/our_recipe_atlas', + mongoFallbackUri: 'mongodb://172.16.0.7:27017/our_recipe_atlas', + imageRoot: '/mnt/recipe-ssd/our_recipe_atlas/images', +}); + +export function runtimeProfileForPlatform(platform, requestedNodeEnv) { + if (platform === 'linux') return LINUX_PRODUCTION_PROFILE; + if (platform === 'win32') return WINDOWS_DEVELOPMENT_PROFILE; + return Object.freeze({ + ...WINDOWS_DEVELOPMENT_PROFILE, + nodeEnv: requestedNodeEnv ?? 'development', + }); +} + +export function mongoCollectionNames(nodeEnv) { + const suffix = nodeEnv === 'development' ? '_dev' : nodeEnv === 'test' ? '_test' : ''; + return Object.freeze({ + allowedEmails: 'allowed_google_emails', + recipes: `recipes${suffix}`, + users: `users${suffix}`, + }); +} + +function mongoDatabaseName(uri) { + const schemeEnd = uri.indexOf('://'); + const pathStart = uri.indexOf('/', schemeEnd + 3); + if (schemeEnd < 0 || pathStart < 0) return ''; + return uri.slice(pathStart + 1).split(/[/?#]/, 1)[0]; +} + +function isAbsoluteOnSupportedOs(value) { + return path.win32.isAbsolute(value) || path.posix.isAbsolute(value); +} + +const envSchema = z + .object({ + NODE_ENV: z.enum(['development', 'test', 'production']), + HOST: z.string().min(1).default('0.0.0.0'), + PORT: z.coerce.number().int().min(1).max(65535).default(3000), + LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace', 'silent']).default('info'), + PUBLIC_BASE_URL: z.url().default('http://localhost:3000'), + MONGO_URI: z.string().min(1), + MONGO_FALLBACK_URI: optionalText, + IMAGE_ROOT: z.string().min(1), + GOOGLE_CLIENT_ID: optionalText, + GOOGLE_CLIENT_SECRET: optionalText, + GOOGLE_CALLBACK_URL: z.url().default('http://localhost:3000/auth/google/callback'), + ALLOWED_GOOGLE_EMAILS: z.string().default(''), + AUTH_JWT_SECRET: optionalText, + AUTH_SESSION_TTL_HOURS: z.coerce.number().int().min(1).max(720).default(24), + MINIMAX_API_KEY: optionalText, + MINIMAX_BASE_URL: z.url().default('https://api.minimax.io/v1'), + MINIMAX_MODEL: z.string().min(1).default('MiniMax-M2.7'), + INSTAGRAM_SESSION_COOKIE: optionalText, + }) + .superRefine((value, context) => { + const mongoUris = [value.MONGO_URI, value.MONGO_FALLBACK_URI].filter(Boolean); + const databaseNames = mongoUris.map(mongoDatabaseName); + + if (databaseNames.some((name) => !name)) { + context.addIssue({ + code: 'custom', + message: 'MongoDB URI에는 database 이름이 필요합니다.', + path: ['MONGO_URI'], + }); + } else if (new Set(databaseNames).size > 1) { + context.addIssue({ + code: 'custom', + message: 'MONGO_URI와 MONGO_FALLBACK_URI는 같은 database를 가리켜야 합니다.', + path: ['MONGO_FALLBACK_URI'], + }); + } + + if (value.NODE_ENV === 'production' && !isAbsoluteOnSupportedOs(value.IMAGE_ROOT)) { + context.addIssue({ + code: 'custom', + message: '운영 환경의 IMAGE_ROOT는 절대 경로여야 합니다.', + path: ['IMAGE_ROOT'], + }); + } + + if (Boolean(value.GOOGLE_CLIENT_ID) !== Boolean(value.GOOGLE_CLIENT_SECRET)) { + context.addIssue({ + code: 'custom', + message: 'GOOGLE_CLIENT_ID와 GOOGLE_CLIENT_SECRET은 함께 설정해야 합니다.', + path: ['GOOGLE_CLIENT_ID'], + }); + } + + if (value.NODE_ENV === 'production' && !value.AUTH_JWT_SECRET) { + context.addIssue({ + code: 'custom', + message: '운영 환경에서는 AUTH_JWT_SECRET이 필요합니다.', + path: ['AUTH_JWT_SECRET'], + }); + } + }); + +export function loadConfig( + source = process.env, + { loadEnvFile = false, platform = process.platform } = {}, +) { + if (loadEnvFile) loadDotEnv({ quiet: true }); + + const runtimeProfile = runtimeProfileForPlatform(platform, source.NODE_ENV); + const parsed = envSchema.safeParse({ + ...source, + NODE_ENV: runtimeProfile.nodeEnv, + MONGO_URI: runtimeProfile.mongoUri, + MONGO_FALLBACK_URI: runtimeProfile.mongoFallbackUri, + IMAGE_ROOT: runtimeProfile.imageRoot, + }); + if (!parsed.success) { + const details = parsed.error.issues + .map((issue) => `${issue.path.join('.') || '환경변수'}: ${issue.message}`) + .join('\n'); + throw new Error(`환경변수 설정이 올바르지 않습니다.\n${details}`); + } + + const env = parsed.data; + const allowedGoogleEmails = new Set( + env.ALLOWED_GOOGLE_EMAILS.split(',') + .map((email) => email.trim().toLowerCase()) + .filter(Boolean), + ); + + const pathApi = platform === 'win32' ? path.win32 : path.posix; + + return Object.freeze({ + nodeEnv: env.NODE_ENV, + host: env.HOST, + port: env.PORT, + logLevel: env.LOG_LEVEL, + publicBaseUrl: env.PUBLIC_BASE_URL.replace(/\/$/, ''), + mongoUri: env.MONGO_URI, + mongoFallbackUri: env.MONGO_FALLBACK_URI, + mongoCollections: mongoCollectionNames(env.NODE_ENV), + imageRoot: pathApi.resolve(process.cwd(), env.IMAGE_ROOT), + google: Object.freeze({ + clientId: env.GOOGLE_CLIENT_ID, + clientSecret: env.GOOGLE_CLIENT_SECRET, + callbackUrl: env.GOOGLE_CALLBACK_URL, + allowedEmails: allowedGoogleEmails, + }), + authJwtSecret: env.AUTH_JWT_SECRET ?? 'development-only-change-this-secret', + authSessionTtlHours: env.AUTH_SESSION_TTL_HOURS, + minimax: Object.freeze({ + apiKey: env.MINIMAX_API_KEY, + baseUrl: env.MINIMAX_BASE_URL.replace(/\/$/, ''), + model: env.MINIMAX_MODEL, + }), + instagramSessionCookie: env.INSTAGRAM_SESSION_COOKIE, + }); +} diff --git a/src/constants/recipe-tags.js b/src/constants/recipe-tags.js new file mode 100644 index 0000000..451b1aa --- /dev/null +++ b/src/constants/recipe-tags.js @@ -0,0 +1,48 @@ +export const STANDARD_RECIPE_TAGS = Object.freeze([ + '한식', '중식', '일식', '양식', '동남아', '멕시칸', + '밥', '면', '국물', '반찬', '샐러드', '고기', '해산물', '간식', '디저트', '음료', + '간단요리', '다이어트', '고단백', '채식', '매운맛', '술안주', '도시락', '에어프라이어', +]); + +const standardTagByKey = new Map( + STANDARD_RECIPE_TAGS.map((tag) => [tag.toLocaleLowerCase(), tag]), +); + +const TAG_ALIASES = new Map([ + ['quick meal', '간단요리'], + ['간단', '간단요리'], + ['간편', '간단요리'], + ['간편요리', '간단요리'], + ['초간단', '간단요리'], + ['중화요리', '중식'], + ['식단', '다이어트'], + ['다이어트식', '다이어트'], + ['찌개', '국물'], + ['탕', '국물'], + ['수프', '국물'], + ['와인안주', '술안주'], +]); + +function tagKey(value) { + return String(value ?? '') + .trim() + .replace(/^#+\s*/, '') + .replace(/\s+/g, ' ') + .toLocaleLowerCase(); +} + +export function normalizeRecipeTags(tags) { + const normalized = []; + const seen = new Set(); + + for (const value of tags) { + const key = tagKey(value); + const tag = standardTagByKey.get(key) ?? TAG_ALIASES.get(key); + if (!tag || seen.has(tag)) continue; + normalized.push(tag); + seen.add(tag); + if (normalized.length === 3) break; + } + + return normalized; +} diff --git a/src/plugins/auth.js b/src/plugins/auth.js new file mode 100644 index 0000000..a9e20af --- /dev/null +++ b/src/plugins/auth.js @@ -0,0 +1,187 @@ +import cookie from '@fastify/cookie'; +import jwt from '@fastify/jwt'; +import oauthPlugin from '@fastify/oauth2'; +import fastifyPlugin from 'fastify-plugin'; +import { OAuth2Client } from 'google-auth-library'; +import { AppError } from '../utils/errors.js'; + +export const SESSION_COOKIE_NAME = 'ora_session'; + +async function authPlugin(fastify, { config, googleClient } = {}) { + const sessionTtlSeconds = config.authSessionTtlHours * 60 * 60; + + await fastify.register(cookie); + await fastify.register(jwt, { + secret: config.authJwtSecret, + cookie: { + cookieName: SESSION_COOKIE_NAME, + signed: false, + }, + }); + fastify.decorateRequest('allowedAccount', null); + + async function verifySession(request) { + const user = await request.jwtVerify({ onlyCookie: true }); + const issuedAt = Number(user.iat); + const ageSeconds = Math.floor(Date.now() / 1000) - issuedAt; + if (!Number.isSafeInteger(issuedAt) || ageSeconds >= sessionTtlSeconds) { + throw new Error('Session expired'); + } + const allowedAccount = await fastify.allowedEmailRepository.findByEmail(user.email); + if (!allowedAccount) throw new Error('Account access revoked'); + request.allowedAccount = allowedAccount; + } + + fastify.decorate('authenticate', async function authenticate(request) { + try { + await verifySession(request); + } catch { + throw new AppError('로그인이 필요합니다.', { + statusCode: 401, + code: 'AUTH_REQUIRED', + }); + } + }); + + fastify.decorate('authenticatePage', async function authenticatePage(request, reply) { + try { + await verifySession(request); + } catch { + return reply.redirect('/login'); + } + }); + + fastify.decorate( + 'authenticateAccessManager', + async function authenticateAccessManager(request) { + try { + await verifySession(request); + } catch { + throw new AppError('로그인이 필요합니다.', { + statusCode: 401, + code: 'AUTH_REQUIRED', + }); + } + if (!request.allowedAccount.canManageAccess) { + throw new AppError('허용 계정을 관리할 권한이 없습니다.', { + statusCode: 403, + code: 'ACCESS_MANAGEMENT_FORBIDDEN', + }); + } + }, + ); + + const googleConfigured = Boolean(config.google.clientId && config.google.clientSecret); + if (googleConfigured) { + await fastify.register(oauthPlugin, { + name: 'googleOAuth2', + scope: ['openid', 'email', 'profile'], + credentials: { + client: { + id: config.google.clientId, + secret: config.google.clientSecret, + }, + auth: oauthPlugin.GOOGLE_CONFIGURATION, + }, + startRedirectPath: '/auth/google', + callbackUri: config.google.callbackUrl, + pkce: 'S256', + cookie: { + httpOnly: true, + sameSite: 'lax', + secure: config.nodeEnv === 'production', + path: '/', + }, + }); + } else { + fastify.get('/auth/google', async () => { + throw new AppError('Google OAuth가 설정되지 않았습니다.', { + statusCode: 503, + code: 'GOOGLE_OAUTH_NOT_CONFIGURED', + }); + }); + } + + fastify.get('/auth/google/callback', async (request, reply) => { + if (!googleConfigured) { + throw new AppError('Google OAuth가 설정되지 않았습니다.', { + statusCode: 503, + code: 'GOOGLE_OAUTH_NOT_CONFIGURED', + }); + } + + const accessToken = await fastify.googleOAuth2.getAccessTokenFromAuthorizationCodeFlow(request, reply); + const idToken = accessToken.token.id_token; + if (!idToken) { + throw new AppError('Google ID Token을 받지 못했습니다.', { + statusCode: 502, + code: 'GOOGLE_ID_TOKEN_MISSING', + }); + } + + const verifier = googleClient ?? new OAuth2Client(config.google.clientId); + const ticket = await verifier.verifyIdToken({ + idToken, + audience: config.google.clientId, + }); + const payload = ticket.getPayload(); + if (!payload?.sub || !payload.email || payload.email_verified !== true) { + throw new AppError('Google 계정 정보를 검증하지 못했습니다.', { + statusCode: 403, + code: 'GOOGLE_ACCOUNT_INVALID', + }); + } + const allowedAccount = await fastify.allowedEmailRepository.findByEmail(payload.email); + if (!allowedAccount) { + throw new AppError('허용되지 않은 Google 계정입니다.', { + statusCode: 403, + code: 'GOOGLE_EMAIL_NOT_ALLOWED', + }); + } + + const user = await fastify.userRepository.upsertGoogleUser({ + googleSub: payload.sub, + email: payload.email.toLowerCase(), + name: payload.name ?? payload.email, + picture: payload.picture ?? null, + }); + const session = fastify.jwt.sign( + { + sub: user.googleSub, + email: user.email, + name: user.name, + picture: user.picture, + }, + { expiresIn: `${config.authSessionTtlHours}h` }, + ); + + reply.setCookie(SESSION_COOKIE_NAME, session, { + path: '/', + httpOnly: true, + sameSite: 'lax', + secure: config.nodeEnv === 'production', + maxAge: sessionTtlSeconds, + }); + return reply.redirect('/'); + }); + + fastify.get('/auth/me', { preHandler: fastify.authenticate }, async (request) => ({ + user: { + googleSub: request.user.sub, + email: request.user.email, + name: request.user.name, + picture: request.user.picture ?? null, + canManageAccess: Boolean(request.allowedAccount?.canManageAccess), + }, + })); + + fastify.post('/auth/logout', async (_request, reply) => { + reply.clearCookie(SESSION_COOKIE_NAME, { path: '/' }); + return reply.code(204).send(); + }); +} + +export default fastifyPlugin(authPlugin, { + name: 'auth', + dependencies: [], +}); diff --git a/src/plugins/mongo.js b/src/plugins/mongo.js new file mode 100644 index 0000000..36e5c50 --- /dev/null +++ b/src/plugins/mongo.js @@ -0,0 +1,76 @@ +import { MongoClient } from 'mongodb'; +import fastifyPlugin from 'fastify-plugin'; +import { AllowedEmailRepository } from '../repositories/allowed-email.repository.js'; +import { RecipeRepository } from '../repositories/recipe.repository.js'; +import { UserRepository } from '../repositories/user.repository.js'; + +const SERVER_SELECTION_TIMEOUT_MS = 3000; + +export async function connectFirstAvailableMongo( + uris, + { + createClient = (uri) => new MongoClient(uri, { + serverSelectionTimeoutMS: SERVER_SELECTION_TIMEOUT_MS, + }), + onFailure = () => {}, + } = {}, +) { + let lastError; + + for (const [index, uri] of uris.entries()) { + const client = createClient(uri); + try { + await client.connect(); + return { client, connectionIndex: index }; + } catch (error) { + lastError = error; + await client.close().catch(() => {}); + onFailure(index, error); + } + } + + throw lastError; +} + +async function mongoPlugin(fastify, { config }) { + const mongoUris = [config.mongoUri, config.mongoFallbackUri].filter(Boolean); + const { client, connectionIndex } = await connectFirstAvailableMongo(mongoUris, { + onFailure(index) { + fastify.log.warn( + { mongoConnection: index === 0 ? 'primary' : 'fallback' }, + 'MongoDB 연결 실패', + ); + }, + }); + fastify.log.info( + { mongoConnection: connectionIndex === 0 ? 'primary' : 'fallback' }, + 'MongoDB 연결 완료', + ); + const db = client.db(); + const collections = config.mongoCollections; + const allowedEmailRepository = new AllowedEmailRepository(db, collections.allowedEmails); + const recipeRepository = new RecipeRepository(db, collections.recipes); + const userRepository = new UserRepository(db, collections.users); + + await Promise.all([ + allowedEmailRepository.ensureIndexes(), + recipeRepository.ensureIndexes(), + userRepository.ensureIndexes(), + ]); + const seededEmailCount = await allowedEmailRepository.seedIfEmpty(config.google.allowedEmails); + if (seededEmailCount > 0) { + fastify.log.info({ seededEmailCount }, 'Google 로그인 허용 이메일 초기화 완료'); + } + + fastify.decorate('mongoClient', client); + fastify.decorate('db', db); + fastify.decorate('allowedEmailRepository', allowedEmailRepository); + fastify.decorate('recipeRepository', recipeRepository); + fastify.decorate('userRepository', userRepository); + + fastify.addHook('onClose', async () => { + await client.close(); + }); +} + +export default fastifyPlugin(mongoPlugin, { name: 'mongo' }); diff --git a/src/plugins/static.js b/src/plugins/static.js new file mode 100644 index 0000000..4cd6039 --- /dev/null +++ b/src/plugins/static.js @@ -0,0 +1,30 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import fastifyStatic from '@fastify/static'; +import fastifyPlugin from 'fastify-plugin'; + +const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const publicRoot = path.join(projectRoot, 'public'); + +async function staticPlugin(fastify, { config }) { + await fastify.register(fastifyStatic, { + root: config.imageRoot, + prefix: '/media/', + }); + await fastify.register(fastifyStatic, { + root: publicRoot, + prefix: '/', + decorateReply: false, + }); + + fastify.get('/login', async (_request, reply) => reply.sendFile('login.html', publicRoot)); + fastify.get('/', { preHandler: fastify.authenticatePage }, async (_request, reply) => ( + reply.sendFile('index.html', publicRoot) + )); + fastify.get('/recipe/:id', { preHandler: fastify.authenticatePage }, async (_request, reply) => ( + reply.sendFile('recipe.html', publicRoot) + )); +} + +export default fastifyPlugin(staticPlugin, { name: 'static-pages' }); + diff --git a/src/repositories/allowed-email.repository.js b/src/repositories/allowed-email.repository.js new file mode 100644 index 0000000..ffd0a2e --- /dev/null +++ b/src/repositories/allowed-email.repository.js @@ -0,0 +1,63 @@ +export function normalizeAllowedEmail(email) { + return email.trim().toLowerCase(); +} + +export class AllowedEmailRepository { + constructor(db, collectionName = 'allowed_google_emails') { + this.collection = db.collection(collectionName); + } + + async ensureIndexes() { + await this.collection.createIndex({ email: 1 }, { unique: true }); + } + + async seedIfEmpty(emails) { + if (await this.collection.findOne({}, { projection: { _id: 1 } })) return 0; + + const normalizedEmails = [...new Set([...emails].map(normalizeAllowedEmail).filter(Boolean))]; + if (normalizedEmails.length === 0) return 0; + + const now = new Date(); + await this.collection.insertMany(normalizedEmails.map((email) => ({ + email, + canManageAccess: true, + addedBy: 'bootstrap', + createdAt: now, + }))); + return normalizedEmails.length; + } + + async findByEmail(email) { + if (typeof email !== 'string') return null; + return this.collection.findOne({ email: normalizeAllowedEmail(email) }); + } + + async list() { + return this.collection + .find({}, { projection: { _id: 0, email: 1 } }) + .sort({ email: 1 }) + .toArray(); + } + + async add(email, addedBy) { + const normalizedEmail = normalizeAllowedEmail(email); + const now = new Date(); + return this.collection.findOneAndUpdate( + { email: normalizedEmail }, + { + $setOnInsert: { + email: normalizedEmail, + canManageAccess: false, + addedBy, + createdAt: now, + }, + }, + { upsert: true, returnDocument: 'after', includeResultMetadata: false }, + ); + } + + async remove(email) { + const result = await this.collection.deleteOne({ email: normalizeAllowedEmail(email) }); + return result.deletedCount === 1; + } +} diff --git a/src/repositories/recipe.repository.js b/src/repositories/recipe.repository.js new file mode 100644 index 0000000..8dd1997 --- /dev/null +++ b/src/repositories/recipe.repository.js @@ -0,0 +1,64 @@ +export class RecipeRepository { + constructor(db, collectionName = 'recipes') { + this.collection = db.collection(collectionName); + } + + async ensureIndexes() { + await this.collection.createIndex( + { + ownerGoogleSub: 1, + 'source.platform': 1, + 'source.sourceId': 1, + }, + { unique: true }, + ); + await this.collection.createIndex({ ownerGoogleSub: 1, createdAt: -1 }); + } + + async listByOwner(ownerGoogleSub) { + return this.collection + .find( + { ownerGoogleSub }, + { + projection: { + 'source.rawText': 0, + 'source.metadata.transcript': 0, + }, + }, + ) + .sort({ createdAt: -1 }) + .toArray(); + } + + async findById(ownerGoogleSub, recipeId) { + return this.collection.findOne({ _id: recipeId, ownerGoogleSub }); + } + + async findBySource(ownerGoogleSub, platform, sourceId) { + return this.collection.findOne({ + ownerGoogleSub, + 'source.platform': platform, + 'source.sourceId': sourceId, + }); + } + + async create(document) { + await this.collection.insertOne(document); + return document; + } + + async update(ownerGoogleSub, recipeId, changes) { + return this.collection.findOneAndUpdate( + { _id: recipeId, ownerGoogleSub }, + { $set: { ...changes, updatedAt: new Date() } }, + { returnDocument: 'after', includeResultMetadata: false }, + ); + } + + async delete(ownerGoogleSub, recipeId) { + const recipe = await this.findById(ownerGoogleSub, recipeId); + if (!recipe) return null; + await this.collection.deleteOne({ _id: recipeId, ownerGoogleSub }); + return recipe; + } +} diff --git a/src/repositories/user.repository.js b/src/repositories/user.repository.js new file mode 100644 index 0000000..aa38d42 --- /dev/null +++ b/src/repositories/user.repository.js @@ -0,0 +1,30 @@ +export class UserRepository { + constructor(db, collectionName = 'users') { + this.collection = db.collection(collectionName); + } + + async ensureIndexes() { + await this.collection.createIndex({ googleSub: 1 }, { unique: true }); + } + + async upsertGoogleUser(profile) { + const now = new Date(); + const result = await this.collection.findOneAndUpdate( + { googleSub: profile.googleSub }, + { + $set: { + email: profile.email, + name: profile.name, + picture: profile.picture, + lastLoginAt: now, + }, + $setOnInsert: { + googleSub: profile.googleSub, + createdAt: now, + }, + }, + { upsert: true, returnDocument: 'after', includeResultMetadata: false }, + ); + return result; + } +} diff --git a/src/routes/access.routes.js b/src/routes/access.routes.js new file mode 100644 index 0000000..abf8f65 --- /dev/null +++ b/src/routes/access.routes.js @@ -0,0 +1,41 @@ +import { z } from 'zod'; +import { NotFoundError, ValidationError } from '../utils/errors.js'; + +const emailSchema = z.string().trim().toLowerCase().email('올바른 이메일 주소를 입력해 주세요.'); +const addAllowedEmailSchema = z.object({ email: emailSchema }); +const allowedEmailParamsSchema = z.object({ email: emailSchema }); + +export default async function accessRoutes(fastify) { + fastify.get( + '/api/allowed-emails', + { preHandler: fastify.authenticateAccessManager }, + async () => ({ + emails: (await fastify.allowedEmailRepository.list()).map(({ email }) => email), + }), + ); + + fastify.post( + '/api/allowed-emails', + { preHandler: fastify.authenticateAccessManager }, + async (request, reply) => { + const { email } = addAllowedEmailSchema.parse(request.body); + await fastify.allowedEmailRepository.add(email, request.user.email); + return reply.code(201).send({ email }); + }, + ); + + fastify.delete( + '/api/allowed-emails/:email', + { preHandler: fastify.authenticateAccessManager }, + async (request, reply) => { + const { email } = allowedEmailParamsSchema.parse(request.params); + if (email === request.user.email.toLowerCase()) { + throw new ValidationError('현재 로그인한 관리자 계정은 삭제할 수 없습니다.'); + } + if (!await fastify.allowedEmailRepository.remove(email)) { + throw new NotFoundError('허용 이메일을 찾을 수 없습니다.'); + } + return reply.code(204).send(); + }, + ); +} diff --git a/src/routes/import.routes.js b/src/routes/import.routes.js new file mode 100644 index 0000000..c72c8e9 --- /dev/null +++ b/src/routes/import.routes.js @@ -0,0 +1,16 @@ +import { importPreviewRequestSchema, sourceSchema } from '../schemas/recipe.schema.js'; + +export default async function importRoutes(fastify) { + fastify.post('/api/import/preview', { preHandler: fastify.authenticate }, async (request) => { + const { url } = importPreviewRequestSchema.parse(request.body); + const source = sourceSchema.parse(await fastify.sourceExtractor.extract(url)); + const recipe = await fastify.recipeParser.parse(source); + + return { + source, + recipe, + imagePreviewUrl: source.thumbnailUrl, + }; + }); +} + diff --git a/src/routes/recipe.routes.js b/src/routes/recipe.routes.js new file mode 100644 index 0000000..919e901 --- /dev/null +++ b/src/routes/recipe.routes.js @@ -0,0 +1,104 @@ +import { randomUUID } from 'node:crypto'; +import { recipePatchSchema, saveRecipeRequestSchema } from '../schemas/recipe.schema.js'; +import { ConflictError, NotFoundError, ValidationError } from '../utils/errors.js'; +import { + detectPlatform, + extractInstagramShortcode, + extractYouTubeId, +} from '../utils/url.js'; + +function toStoredSource(source) { + const { sourceUrl, ...rest } = source; + return { ...rest, url: sourceUrl }; +} + +function isDuplicateKeyError(error) { + return error?.code === 11000; +} + +function verifiedSource(source) { + const platform = detectPlatform(source.sourceUrl); + const sourceId = platform === 'youtube' + ? extractYouTubeId(source.sourceUrl) + : platform === 'instagram' + ? extractInstagramShortcode(source.sourceUrl) + : null; + if (platform !== source.platform || sourceId !== source.sourceId) { + throw new ValidationError('미리보기 원본 정보가 URL과 일치하지 않습니다.'); + } + return source; +} + +export default async function recipeRoutes(fastify) { + fastify.get('/api/recipes', { preHandler: fastify.authenticate }, async (request) => ({ + recipes: await fastify.recipeRepository.listByOwner(request.user.sub), + })); + + fastify.get('/api/recipes/:id', { preHandler: fastify.authenticate }, async (request) => { + const recipe = await fastify.recipeRepository.findById(request.user.sub, request.params.id); + if (!recipe) throw new NotFoundError('레시피를 찾을 수 없습니다.'); + return { recipe }; + }); + + fastify.post('/api/recipes', { preHandler: fastify.authenticate }, async (request, reply) => { + const input = saveRecipeRequestSchema.parse(request.body); + verifiedSource(input.source); + const ownerGoogleSub = request.user.sub; + const duplicate = await fastify.recipeRepository.findBySource( + ownerGoogleSub, + input.source.platform, + input.source.sourceId, + ); + if (duplicate) throw new ConflictError(); + + const recipeId = randomUUID(); + let imagePath = null; + try { + const imageUrl = input.imagePreviewUrl ?? input.source.thumbnailUrl; + if (imageUrl) imagePath = await fastify.imageStorage.saveFromUrl(recipeId, imageUrl); + + const now = new Date(); + const document = { + _id: recipeId, + ownerGoogleSub, + ...input.recipe, + source: toStoredSource(input.source), + imagePath, + ai: { + provider: 'minimax', + model: fastify.config.minimax.model, + }, + createdAt: now, + updatedAt: now, + }; + const recipe = await fastify.recipeRepository.create(document); + return reply.code(201).send({ recipe }); + } catch (error) { + if (imagePath) await fastify.imageStorage.removeRecipe(recipeId).catch(() => {}); + if (isDuplicateKeyError(error)) throw new ConflictError(); + throw error; + } + }); + + fastify.patch('/api/recipes/:id', { preHandler: fastify.authenticate }, async (request) => { + const changes = recipePatchSchema.parse(request.body); + const recipe = await fastify.recipeRepository.update( + request.user.sub, + request.params.id, + changes, + ); + if (!recipe) throw new NotFoundError('레시피를 찾을 수 없습니다.'); + return { recipe }; + }); + + fastify.delete('/api/recipes/:id', { preHandler: fastify.authenticate }, async (request, reply) => { + const recipe = await fastify.recipeRepository.delete(request.user.sub, request.params.id); + if (!recipe) throw new NotFoundError('레시피를 찾을 수 없습니다.'); + if (recipe.imagePath) { + await fastify.imageStorage.removeRecipe(request.params.id).catch((error) => { + request.log.warn({ error, recipeId: request.params.id }, '레시피 이미지 삭제 실패'); + }); + } + return reply.code(204).send(); + }); +} diff --git a/src/schemas/recipe.schema.js b/src/schemas/recipe.schema.js new file mode 100644 index 0000000..69b7d39 --- /dev/null +++ b/src/schemas/recipe.schema.js @@ -0,0 +1,72 @@ +import { z } from 'zod'; +import { normalizeRecipeTags } from '../constants/recipe-tags.js'; + +const nullableText = z.string().trim().min(1).nullable(); + +const tagsSchema = z.array(z.string()).transform(normalizeRecipeTags); + +export const ingredientItemSchema = z.object({ + name: z.string().trim().min(1, '재료 이름이 필요합니다.'), + amount: nullableText.default(null), +}); + +export const ingredientGroupSchema = z.object({ + name: z.string().trim().min(1, '재료 그룹 이름이 필요합니다.'), + items: z.array(ingredientItemSchema), +}); + +export const recipeStepSchema = z.object({ + order: z.number().int().positive(), + text: z.string().trim().min(1, '조리 단계 설명이 필요합니다.'), + timestampSec: z.number().nonnegative().nullable().default(null), +}); + +export const recipeDraftSchema = z.object({ + title: z.string().trim().min(1, '레시피 제목이 필요합니다.'), + summary: nullableText.default(null), + servings: nullableText.default(null), + ingredientGroups: z.array(ingredientGroupSchema), + steps: z.array(recipeStepSchema), + tips: z.array(z.string().trim().min(1)).default([]), + tags: tagsSchema.default([]), +}); + +export const sourceSchema = z.object({ + platform: z.enum(['instagram', 'youtube']), + sourceUrl: z.url().refine((value) => new URL(value).protocol === 'https:', '원본 URL은 HTTPS여야 합니다.'), + sourceId: z.string().trim().min(1), + title: nullableText.default(null), + author: nullableText.default(null), + thumbnailUrl: z.url() + .refine((value) => new URL(value).protocol === 'https:', '이미지 URL은 HTTPS여야 합니다.') + .nullable() + .default(null), + rawText: z.string().trim().min(1, '원본 텍스트가 비어 있습니다.'), + metadata: z.record(z.string(), z.unknown()).default({}), +}); + +export const importPreviewRequestSchema = z.object({ + url: z.url(), +}); + +export const saveRecipeRequestSchema = z.object({ + recipe: recipeDraftSchema, + source: sourceSchema, + imagePreviewUrl: z.url() + .refine((value) => new URL(value).protocol === 'https:', '이미지 URL은 HTTPS여야 합니다.') + .nullable() + .optional(), +}); + +export const recipePatchSchema = z.object({ + title: z.string().trim().min(1, '레시피 제목이 필요합니다.').optional(), + summary: nullableText.optional(), + servings: nullableText.optional(), + ingredientGroups: z.array(ingredientGroupSchema).optional(), + steps: z.array(recipeStepSchema).optional(), + tips: z.array(z.string().trim().min(1)).optional(), + tags: tagsSchema.optional(), +}).refine( + (value) => Object.keys(value).length > 0, + '수정할 내용을 하나 이상 입력해 주세요.', +); diff --git a/src/server.js b/src/server.js new file mode 100644 index 0000000..dd19410 --- /dev/null +++ b/src/server.js @@ -0,0 +1,13 @@ +import { buildApp } from './app.js'; +import { loadConfig } from './config/env.js'; + +const config = loadConfig(process.env, { loadEnvFile: true }); +const app = await buildApp({ config }); + +try { + await app.listen({ host: config.host, port: config.port }); +} catch (error) { + app.log.error(error); + process.exitCode = 1; +} + diff --git a/src/services/extractors/index.js b/src/services/extractors/index.js new file mode 100644 index 0000000..302f8e3 --- /dev/null +++ b/src/services/extractors/index.js @@ -0,0 +1,24 @@ +import { detectPlatform } from '../../utils/url.js'; +import { ValidationError } from '../../utils/errors.js'; +import { InstagramExtractor } from './instagram.extractor.js'; +import { YouTubeExtractor } from './youtube.extractor.js'; + +export function createSourceExtractor({ config, youtubeExtractor, instagramExtractor } = {}) { + const extractors = { + youtube: youtubeExtractor ?? new YouTubeExtractor(), + instagram: instagramExtractor ?? new InstagramExtractor({ + sessionCookie: config?.instagramSessionCookie, + }), + }; + + return { + async extract(sourceUrl) { + const platform = detectPlatform(sourceUrl); + if (platform === 'unsupported') { + throw new ValidationError('지원하지 않는 URL입니다.'); + } + return extractors[platform].extract(sourceUrl); + }, + }; +} + diff --git a/src/services/extractors/instagram.extractor.js b/src/services/extractors/instagram.extractor.js new file mode 100644 index 0000000..6ee3786 --- /dev/null +++ b/src/services/extractors/instagram.extractor.js @@ -0,0 +1,71 @@ +import { igApi } from 'insta-fetcher'; +import { AppError } from '../../utils/errors.js'; +import { canonicalSourceUrl, extractInstagramShortcode } from '../../utils/url.js'; + +export function normalizeInstagramSessionCookie(value) { + const cookie = value?.trim(); + if (!cookie) return cookie; + + const normalized = cookie.includes('=') ? cookie : `sessionid=${cookie}`; + return normalized.endsWith(';') ? normalized : `${normalized};`; +} + +export class InstagramExtractor { + constructor({ sessionCookie, clientFactory } = {}) { + this.sessionCookie = normalizeInstagramSessionCookie(sessionCookie); + this.clientFactory = clientFactory ?? ((cookie) => new igApi(cookie)); + } + + async extract(sourceUrl) { + if (!this.sessionCookie) { + throw new AppError('Instagram session cookie가 설정되지 않았습니다.', { + statusCode: 503, + code: 'INSTAGRAM_NOT_CONFIGURED', + }); + } + + const sourceId = extractInstagramShortcode(sourceUrl); + let post; + let thumbnailUrl; + try { + const client = await this.clientFactory(this.sessionCookie); + post = await client.fetchPost(canonicalSourceUrl(sourceUrl)); + thumbnailUrl = post.links?.find((link) => link.type === 'image')?.url ?? null; + + if (!thumbnailUrl && post.media_id && typeof client.fetchPostByMediaId === 'function') { + const metadata = await client.fetchPostByMediaId(post.media_id).catch(() => null); + thumbnailUrl = metadata?.items?.[0]?.image_versions2?.candidates?.[0]?.url ?? null; + } + } catch (error) { + throw new AppError('Instagram 게시물 정보를 가져오지 못했습니다.', { + statusCode: 502, + code: 'INSTAGRAM_EXTRACTION_FAILED', + cause: error, + }); + } + + const caption = post?.caption?.trim(); + if (!caption) { + throw new AppError('Instagram 게시물의 caption을 찾을 수 없습니다.', { + statusCode: 422, + code: 'INSTAGRAM_CAPTION_NOT_FOUND', + }); + } + + const firstLine = caption.split(/\r?\n/).find((line) => line.trim())?.trim() ?? null; + + return { + platform: 'instagram', + sourceUrl: canonicalSourceUrl(sourceUrl), + sourceId: post.shortcode || sourceId, + title: firstLine, + author: post.username || null, + thumbnailUrl, + rawText: caption, + metadata: { + postType: post.postType ?? null, + takenAt: post.taken_at_timestamp ?? null, + }, + }; + } +} diff --git a/src/services/extractors/youtube.extractor.js b/src/services/extractors/youtube.extractor.js new file mode 100644 index 0000000..4478dab --- /dev/null +++ b/src/services/extractors/youtube.extractor.js @@ -0,0 +1,88 @@ +import { Innertube } from 'youtubei.js'; +import { AppError } from '../../utils/errors.js'; +import { canonicalSourceUrl, extractYouTubeId } from '../../utils/url.js'; + +function textValue(value) { + if (value == null) return ''; + return typeof value === 'string' ? value : value.toString(); +} + +export function readTranscriptSegments(transcriptInfo) { + const segments = transcriptInfo?.transcript?.content?.body?.initial_segments ?? []; + return segments + .filter((segment) => segment?.snippet && segment?.start_ms != null) + .map((segment) => ({ + startSec: Number(segment.start_ms) / 1000, + endSec: Number(segment.end_ms) / 1000, + text: textValue(segment.snippet).trim(), + })) + .filter((segment) => Number.isFinite(segment.startSec) && segment.text); +} + +export class YouTubeExtractor { + constructor({ clientFactory } = {}) { + this.clientFactory = clientFactory ?? (() => Innertube.create({ + lang: 'ko', + location: 'KR', + retrieve_player: false, + })); + } + + async extract(sourceUrl) { + const sourceId = extractYouTubeId(sourceUrl); + let info; + + try { + const client = await this.clientFactory(); + info = await client.getInfo(sourceId); + } catch (error) { + throw new AppError('YouTube 정보를 가져오지 못했습니다.', { + statusCode: 502, + code: 'YOUTUBE_EXTRACTION_FAILED', + cause: error, + }); + } + + const basic = info.basic_info ?? {}; + const description = basic.short_description?.trim() ?? ''; + let transcriptSegments = []; + + try { + transcriptSegments = readTranscriptSegments(await info.getTranscript()); + } catch { + // 자막이 비활성화된 영상은 설명만으로 계속 분석한다. + } + + if (!description && transcriptSegments.length === 0) { + throw new AppError('YouTube 자막과 설명을 찾을 수 없습니다.', { + statusCode: 422, + code: 'YOUTUBE_TEXT_NOT_FOUND', + }); + } + + const transcriptText = transcriptSegments + .map((segment) => `[${segment.startSec}] ${segment.text}`) + .join('\n'); + const rawText = [ + description && `[DESCRIPTION]\n${description}`, + transcriptText && `[TRANSCRIPT]\n${transcriptText}`, + ].filter(Boolean).join('\n\n'); + const thumbnails = basic.thumbnail ?? []; + const thumbnail = thumbnails.length > 0 ? thumbnails[thumbnails.length - 1] : null; + + return { + platform: 'youtube', + sourceUrl: canonicalSourceUrl(sourceUrl), + sourceId, + title: basic.title?.trim() || null, + author: basic.author?.trim() || basic.channel?.name?.trim() || null, + thumbnailUrl: thumbnail?.url ?? null, + rawText, + metadata: { + durationSec: basic.duration ?? null, + transcript: transcriptSegments, + }, + }; + } +} + diff --git a/src/services/image-storage.service.js b/src/services/image-storage.service.js new file mode 100644 index 0000000..b37e8d5 --- /dev/null +++ b/src/services/image-storage.service.js @@ -0,0 +1,135 @@ +import dns from 'node:dns/promises'; +import fs from 'node:fs/promises'; +import net from 'node:net'; +import path from 'node:path'; +import sharp from 'sharp'; +import { AppError, ValidationError } from '../utils/errors.js'; + +const MAX_IMAGE_BYTES = 12 * 1024 * 1024; +const SAFE_RECIPE_ID = /^[A-Za-z0-9-]+$/; + +function isPrivateIpv4(address) { + const parts = address.split('.').map(Number); + const [a, b] = parts; + return a === 0 + || a === 10 + || a === 127 + || (a === 100 && b >= 64 && b <= 127) + || (a === 169 && b === 254) + || (a === 172 && b >= 16 && b <= 31) + || (a === 192 && (b === 0 || b === 168)) + || (a === 198 && (b === 18 || b === 19 || b === 51)) + || (a === 203 && b === 0) + || a >= 224; +} + +export function isPrivateAddress(address) { + const normalized = address.toLowerCase(); + if (normalized.startsWith('::ffff:')) return isPrivateAddress(normalized.slice(7)); + const version = net.isIP(normalized); + if (version === 4) return isPrivateIpv4(normalized); + if (version === 6) { + return normalized === '::' + || normalized === '::1' + || normalized.startsWith('fc') + || normalized.startsWith('fd') + || /^fe[89ab]/.test(normalized); + } + return true; +} + +export function buildRelativeImagePath(recipeId) { + if (!SAFE_RECIPE_ID.test(recipeId)) { + throw new ValidationError('올바르지 않은 recipe ID입니다.'); + } + return path.posix.join('recipes', recipeId, 'cover.webp'); +} + +export function resolveImagePath(root, relativePath, pathApi = path) { + return pathApi.resolve(root, ...relativePath.split('/')); +} + +async function assertPublicUrl(value, lookup) { + const url = new URL(value); + if (url.protocol !== 'https:' || url.username || url.password) { + throw new ValidationError('이미지 URL은 인증 정보가 없는 HTTPS URL이어야 합니다.'); + } + if (url.hostname.toLowerCase() === 'localhost') { + throw new ValidationError('내부 네트워크의 이미지는 가져올 수 없습니다.'); + } + + const directIpVersion = net.isIP(url.hostname); + const addresses = directIpVersion + ? [{ address: url.hostname }] + : await lookup(url.hostname, { all: true, verbatim: true }); + if (addresses.length === 0 || addresses.some(({ address }) => isPrivateAddress(address))) { + throw new ValidationError('내부 네트워크의 이미지는 가져올 수 없습니다.'); + } + return url; +} + +export class ImageStorageService { + constructor({ root, fetchImpl = fetch, lookup = dns.lookup } = {}) { + this.root = root; + this.fetchImpl = fetchImpl; + this.lookup = lookup; + } + + async fetchImage(sourceUrl) { + let currentUrl = await assertPublicUrl(sourceUrl, this.lookup); + + for (let redirects = 0; redirects <= 3; redirects += 1) { + const response = await this.fetchImpl(currentUrl, { redirect: 'manual' }); + if ([301, 302, 303, 307, 308].includes(response.status)) { + const location = response.headers.get('location'); + if (!location || redirects === 3) break; + currentUrl = await assertPublicUrl(new URL(location, currentUrl).toString(), this.lookup); + continue; + } + + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const contentType = response.headers.get('content-type') ?? ''; + if (!contentType.startsWith('image/')) throw new Error('응답이 이미지가 아닙니다.'); + const declaredSize = Number(response.headers.get('content-length') ?? 0); + if (declaredSize > MAX_IMAGE_BYTES) throw new Error('이미지가 너무 큽니다.'); + const buffer = Buffer.from(await response.arrayBuffer()); + if (buffer.length > MAX_IMAGE_BYTES) throw new Error('이미지가 너무 큽니다.'); + return buffer; + } + + throw new Error('이미지 redirect를 처리하지 못했습니다.'); + } + + async saveFromUrl(recipeId, sourceUrl) { + const relativePath = buildRelativeImagePath(recipeId); + const outputPath = resolveImagePath(this.root, relativePath); + const recipeDirectory = path.dirname(outputPath); + const temporaryPath = path.join(recipeDirectory, 'cover.tmp.webp'); + + try { + const input = await this.fetchImage(sourceUrl); + await fs.mkdir(recipeDirectory, { recursive: true }); + await sharp(input) + .rotate() + .resize({ width: 1280, withoutEnlargement: true }) + .webp({ quality: 80 }) + .toFile(temporaryPath); + await fs.rm(outputPath, { force: true }); + await fs.rename(temporaryPath, outputPath); + return relativePath; + } catch (error) { + await fs.rm(temporaryPath, { force: true }).catch(() => {}); + throw new AppError('레시피 이미지를 저장하지 못했습니다.', { + statusCode: 502, + code: 'IMAGE_STORAGE_FAILED', + cause: error, + }); + } + } + + async removeRecipe(recipeId) { + const relativePath = buildRelativeImagePath(recipeId); + const directory = path.dirname(resolveImagePath(this.root, relativePath)); + await fs.rm(directory, { recursive: true, force: true }); + } +} diff --git a/src/services/recipe-parser.service.js b/src/services/recipe-parser.service.js new file mode 100644 index 0000000..1dfde6d --- /dev/null +++ b/src/services/recipe-parser.service.js @@ -0,0 +1,124 @@ +import OpenAI from 'openai'; +import { STANDARD_RECIPE_TAGS } from '../constants/recipe-tags.js'; +import { recipeDraftSchema } from '../schemas/recipe.schema.js'; +import { AppError } from '../utils/errors.js'; + +const STANDARD_TAGS = STANDARD_RECIPE_TAGS.join(', '); + +const SYSTEM_PROMPT = `당신은 원문에서 레시피를 구조화하는 데이터 변환기입니다. +반드시 JSON 객체만 출력하세요. + +원칙: +- 원문에 없는 재료, 수량, 조리 순서를 추측하거나 추가하지 않습니다. +- 수량이 명확하지 않으면 null 또는 원문 표현을 유지합니다. +- g, ml, 큰술, 작은술, 장, 개 등의 원문 단위를 유지합니다. +- 광고, 비즈니스 문의, SNS 링크, 해시태그 등 레시피와 무관한 내용을 제거합니다. +- YouTube timestamp는 원문 transcript에서 확인되는 경우에만 초 단위 숫자로 기록합니다. +- Instagram 단계의 timestampSec은 null입니다. +- 원문에 재료 그룹 이름이 없으면 "재료"를 사용합니다. ingredientGroups[].name은 null이 될 수 없습니다. +- 이 작업은 정보 추출이므로 깊은 분석은 필요하지 않습니다. +- tags는 다음 표준 태그에서만 최대 3개를 선택합니다: ${STANDARD_TAGS} +- 재료명, 요리 고유명사, 식사 시간대는 tags에 넣지 않습니다. + +출력 형식: +{ + "title": "string", + "summary": "string|null", + "servings": "string|null", + "ingredientGroups": [{"name":"string","items":[{"name":"string","amount":"string|null"}]}], + "steps": [{"order":1,"text":"string","timestampSec":null}], + "tips": ["string"], + "tags": ["string"] +}`; + +export function normalizeModelJson(content) { + if (typeof content !== 'string' || !content.trim()) { + throw new Error('AI 응답이 비어 있습니다.'); + } + + const withoutThinking = content.replace(/[\s\S]*?<\/think>/gi, '').trim(); + const withoutFence = withoutThinking + .replace(/^```(?:json)?\s*/i, '') + .replace(/\s*```$/i, '') + .trim(); + const start = withoutFence.indexOf('{'); + const end = withoutFence.lastIndexOf('}'); + if (start < 0 || end <= start) throw new Error('JSON 객체를 찾을 수 없습니다.'); + return withoutFence.slice(start, end + 1); +} + +export function normalizeRecipePayload(value) { + if (!value || typeof value !== 'object' || !Array.isArray(value.ingredientGroups)) { + return value; + } + + return { + ...value, + ingredientGroups: value.ingredientGroups.map((group) => { + if (!group || typeof group !== 'object') return group; + const name = typeof group.name === 'string' ? group.name.trim() : ''; + return { ...group, name: name || '재료' }; + }), + }; +} + +function buildUserPrompt(source) { + const lines = [ + `SOURCE_PLATFORM: ${source.platform}`, + source.title ? `TITLE:\n${source.title}` : null, + `SOURCE_TEXT:\n${source.rawText}`, + ]; + return lines.filter(Boolean).join('\n\n'); +} + +export class RecipeParserService { + constructor({ apiKey, baseUrl, model, client } = {}) { + this.model = model; + this.client = client ?? (apiKey ? new OpenAI({ apiKey, baseURL: baseUrl }) : null); + } + + async parse(source) { + if (!this.client) { + throw new AppError('MiniMax API가 설정되지 않았습니다.', { + statusCode: 503, + code: 'MINIMAX_NOT_CONFIGURED', + }); + } + + let lastError; + let previousOutput = null; + + for (let attempt = 0; attempt < 2; attempt += 1) { + try { + const response = await this.client.chat.completions.create({ + model: this.model, + messages: [ + { role: 'system', content: SYSTEM_PROMPT }, + { + role: 'user', + content: attempt === 0 + ? buildUserPrompt(source) + : `${buildUserPrompt(source)}\n\n이전 출력은 유효한 JSON 스키마가 아니었습니다. 수정해서 JSON 객체만 다시 출력하세요.\n이전 출력:\n${previousOutput}`, + }, + ], + reasoning_split: true, + max_completion_tokens: 8192, + temperature: 0.2, + stream: false, + }); + + previousOutput = response.choices?.[0]?.message?.content ?? ''; + const parsed = JSON.parse(normalizeModelJson(previousOutput)); + return recipeDraftSchema.parse(normalizeRecipePayload(parsed)); + } catch (error) { + lastError = error; + } + } + + throw new AppError('AI 응답을 처리하지 못했습니다.', { + statusCode: 502, + code: 'AI_RESPONSE_INVALID', + cause: lastError, + }); + } +} diff --git a/src/utils/errors.js b/src/utils/errors.js new file mode 100644 index 0000000..21382f1 --- /dev/null +++ b/src/utils/errors.js @@ -0,0 +1,27 @@ +export class AppError extends Error { + constructor(message, { statusCode = 500, code = 'INTERNAL_ERROR', cause } = {}) { + super(message, { cause }); + this.name = 'AppError'; + this.statusCode = statusCode; + this.code = code; + } +} + +export class ValidationError extends AppError { + constructor(message, options = {}) { + super(message, { ...options, statusCode: 400, code: options.code ?? 'VALIDATION_ERROR' }); + } +} + +export class NotFoundError extends AppError { + constructor(message = '요청한 항목을 찾을 수 없습니다.') { + super(message, { statusCode: 404, code: 'NOT_FOUND' }); + } +} + +export class ConflictError extends AppError { + constructor(message = '이미 등록된 레시피입니다.') { + super(message, { statusCode: 409, code: 'DUPLICATE_RECIPE' }); + } +} + diff --git a/src/utils/url.js b/src/utils/url.js new file mode 100644 index 0000000..073ffcb --- /dev/null +++ b/src/utils/url.js @@ -0,0 +1,94 @@ +import { ValidationError } from './errors.js'; + +const YOUTUBE_HOSTS = new Set([ + 'youtube.com', + 'www.youtube.com', + 'm.youtube.com', + 'music.youtube.com', + 'youtu.be', + 'www.youtu.be', +]); + +const INSTAGRAM_HOSTS = new Set([ + 'instagram.com', + 'www.instagram.com', + 'm.instagram.com', +]); + +const YOUTUBE_ID_PATTERN = /^[A-Za-z0-9_-]{11}$/; +const INSTAGRAM_SHORTCODE_PATTERN = /^[A-Za-z0-9_-]+$/; + +export function parseSourceUrl(value) { + let url; + try { + url = new URL(value); + } catch { + throw new ValidationError('올바른 Instagram 또는 YouTube URL을 입력해 주세요.'); + } + + if (url.protocol !== 'https:') { + throw new ValidationError('가져오기 URL은 HTTPS여야 합니다.'); + } + + url.hash = ''; + return url; +} + +export function detectPlatform(value) { + const url = parseSourceUrl(value); + if (YOUTUBE_HOSTS.has(url.hostname.toLowerCase())) return 'youtube'; + if (INSTAGRAM_HOSTS.has(url.hostname.toLowerCase())) return 'instagram'; + return 'unsupported'; +} + +export function extractYouTubeId(value) { + const url = parseSourceUrl(value); + if (!YOUTUBE_HOSTS.has(url.hostname.toLowerCase())) { + throw new ValidationError('지원하지 않는 YouTube URL입니다.'); + } + + const pathParts = url.pathname.split('/').filter(Boolean); + let videoId = null; + + if (url.hostname.toLowerCase().endsWith('youtu.be')) { + [videoId] = pathParts; + } else if (url.pathname === '/watch') { + videoId = url.searchParams.get('v'); + } else if (['shorts', 'embed', 'live'].includes(pathParts[0])) { + videoId = pathParts[1]; + } + + if (!videoId || !YOUTUBE_ID_PATTERN.test(videoId)) { + throw new ValidationError('YouTube 영상 ID를 확인할 수 없습니다.'); + } + + return videoId; +} + +export function extractInstagramShortcode(value) { + const url = parseSourceUrl(value); + if (!INSTAGRAM_HOSTS.has(url.hostname.toLowerCase())) { + throw new ValidationError('지원하지 않는 Instagram URL입니다.'); + } + + const [kind, shortcode] = url.pathname.split('/').filter(Boolean); + if (!['p', 'reel', 'reels', 'tv'].includes(kind) || !INSTAGRAM_SHORTCODE_PATTERN.test(shortcode ?? '')) { + throw new ValidationError('Instagram 게시물 shortcode를 확인할 수 없습니다.'); + } + + return shortcode; +} + +export function canonicalSourceUrl(value) { + const platform = detectPlatform(value); + if (platform === 'youtube') { + return `https://www.youtube.com/watch?v=${extractYouTubeId(value)}`; + } + if (platform === 'instagram') { + const url = parseSourceUrl(value); + const [kind] = url.pathname.split('/').filter(Boolean); + const normalizedKind = kind === 'reels' ? 'reel' : kind; + return `https://www.instagram.com/${normalizedKind}/${extractInstagramShortcode(value)}/`; + } + throw new ValidationError('지원하지 않는 URL입니다.'); +} diff --git a/tests/app.test.js b/tests/app.test.js new file mode 100644 index 0000000..d185d5b --- /dev/null +++ b/tests/app.test.js @@ -0,0 +1,283 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { buildApp } from '../src/app.js'; +import { loadConfig } from '../src/config/env.js'; +import { SESSION_COOKIE_NAME } from '../src/plugins/auth.js'; +import { + createTestConfig, + FakeAllowedEmailRepository, + FakeRecipeRepository, + fakeUserRepository, + recipeDraft, + source, +} from './helpers/fakes.js'; + +function createDependencies(recipeRepository) { + return { + allowedEmailRepository: new FakeAllowedEmailRepository(), + recipeRepository, + userRepository: fakeUserRepository, + sourceExtractor: { extract: async () => source }, + recipeParser: { parse: async () => recipeDraft }, + imageStorage: { + saveFromUrl: async (id) => `recipes/${id}/cover.webp`, + removeRecipe: async () => {}, + }, + }; +} + +function authCookie(app, sub = 'google-user-1') { + const token = app.jwt.sign({ + sub, + email: `${sub}@example.com`, + name: sub, + picture: null, + }); + return `${SESSION_COOKIE_NAME}=${token}`; +} + +test('로그인 세션 만료시간은 기본 24시간이며 환경변수로 변경할 수 있다', () => { + assert.equal(loadConfig({}, { platform: 'win32' }).authSessionTtlHours, 24); + assert.equal( + loadConfig({ AUTH_SESSION_TTL_HOURS: '8' }, { platform: 'win32' }).authSessionTtlHours, + 8, + ); +}); + +test('관리자는 이메일만으로 Google 로그인 허용 계정을 관리한다', async (t) => { + const dependencies = createDependencies(new FakeRecipeRepository()); + const app = await buildApp({ config: createTestConfig(), dependencies }); + t.after(() => app.close()); + const adminCookie = authCookie(app); + + const sessionResponse = await app.inject({ + method: 'GET', + url: '/auth/me', + headers: { cookie: adminCookie }, + }); + assert.equal(sessionResponse.json().user.canManageAccess, true); + + const memberSessionResponse = await app.inject({ + method: 'GET', + url: '/auth/me', + headers: { cookie: authCookie(app, 'google-user-2') }, + }); + assert.equal(memberSessionResponse.json().user.canManageAccess, false); + + const pageResponse = await app.inject({ + method: 'GET', + url: '/', + headers: { cookie: adminCookie }, + }); + assert.match(pageResponse.body, /id="access-menu-button"/); + assert.match(pageResponse.body, /id="access-drawer"/); + assert.match(pageResponse.body, /id="drawer-logout"/); + assert.match(pageResponse.body, /id="access-overlay"/); + assert.match(pageResponse.body, /id="scroll-to-top"/); + + const memberPageResponse = await app.inject({ + method: 'GET', + url: '/', + headers: { cookie: authCookie(app, 'google-user-2') }, + }); + assert.match(memberPageResponse.body, /aria-label="메뉴 열기"/); + + const addResponse = await app.inject({ + method: 'POST', + url: '/api/allowed-emails', + headers: { cookie: adminCookie }, + payload: { email: ' New.User@Example.com ' }, + }); + assert.equal(addResponse.statusCode, 201); + assert.equal(addResponse.json().email, 'new.user@example.com'); + + const listResponse = await app.inject({ + method: 'GET', + url: '/api/allowed-emails', + headers: { cookie: adminCookie }, + }); + assert.ok(listResponse.json().emails.includes('new.user@example.com')); + + const invitedSessionResponse = await app.inject({ + method: 'GET', + url: '/api/recipes', + headers: { cookie: authCookie(app, 'new.user') }, + }); + assert.equal(invitedSessionResponse.statusCode, 200); + + const memberResponse = await app.inject({ + method: 'POST', + url: '/api/allowed-emails', + headers: { cookie: authCookie(app, 'google-user-2') }, + payload: { email: 'other@example.com' }, + }); + assert.equal(memberResponse.statusCode, 403); + + const selfDeleteResponse = await app.inject({ + method: 'DELETE', + url: '/api/allowed-emails/google-user-1%40example.com', + headers: { cookie: adminCookie }, + }); + assert.equal(selfDeleteResponse.statusCode, 400); + + const deleteResponse = await app.inject({ + method: 'DELETE', + url: '/api/allowed-emails/new.user%40example.com', + headers: { cookie: adminCookie }, + }); + assert.equal(deleteResponse.statusCode, 204); + + const revokedSessionResponse = await app.inject({ + method: 'GET', + url: '/api/recipes', + headers: { cookie: authCookie(app, 'new.user') }, + }); + assert.equal(revokedSessionResponse.statusCode, 401); +}); + +test('인증 cookie가 없으면 API를 거부하고 페이지는 로그인으로 보낸다', async (t) => { + const app = await buildApp({ + config: createTestConfig(), + dependencies: createDependencies(new FakeRecipeRepository()), + }); + t.after(() => app.close()); + + const apiResponse = await app.inject({ method: 'GET', url: '/api/recipes' }); + assert.equal(apiResponse.statusCode, 401); + + const pageResponse = await app.inject({ method: 'GET', url: '/' }); + assert.equal(pageResponse.statusCode, 302); + assert.equal(pageResponse.headers.location, '/login'); + + const loginResponse = await app.inject({ method: 'GET', url: '/login' }); + assert.equal(loginResponse.statusCode, 200); + assert.match(loginResponse.body, /Our Recipe Atlas/); + + const cssResponse = await app.inject({ method: 'GET', url: '/css/app.css' }); + assert.equal(cssResponse.statusCode, 200); +}); + +test('설정된 절대 만료시간보다 오래된 로그인 세션을 거부한다', async (t) => { + const app = await buildApp({ + config: createTestConfig({ authSessionTtlHours: 1 }), + dependencies: createDependencies(new FakeRecipeRepository()), + }); + t.after(() => app.close()); + + const token = app.jwt.sign({ + sub: 'google-user-1', + email: 'google-user-1@example.com', + name: 'google-user-1', + picture: null, + iat: Math.floor(Date.now() / 1000) - (60 * 60) - 1, + }, { expiresIn: '7d' }); + const cookie = `${SESSION_COOKIE_NAME}=${token}`; + + const apiResponse = await app.inject({ + method: 'GET', + url: '/api/recipes', + headers: { cookie }, + }); + assert.equal(apiResponse.statusCode, 401); + + const pageResponse = await app.inject({ + method: 'GET', + url: '/', + headers: { cookie }, + }); + assert.equal(pageResponse.statusCode, 302); + assert.equal(pageResponse.headers.location, '/login'); +}); + +test('Google 로그인 시작 경로는 PKCE S256 authorization 요청을 만든다', async (t) => { + const app = await buildApp({ + config: createTestConfig({ + google: { + clientId: 'google-client-id', + clientSecret: 'google-client-secret', + callbackUrl: 'http://localhost:3000/auth/google/callback', + allowedEmails: new Set(['allowed@example.com']), + }, + }), + dependencies: createDependencies(new FakeRecipeRepository()), + }); + t.after(() => app.close()); + + const response = await app.inject({ method: 'GET', url: '/auth/google' }); + assert.equal(response.statusCode, 302); + const location = new URL(response.headers.location); + assert.equal(location.hostname, 'accounts.google.com'); + assert.equal(location.searchParams.get('code_challenge_method'), 'S256'); + assert.equal(location.searchParams.get('scope'), 'openid email profile'); +}); + +test('import preview와 소유자별 Recipe CRUD가 이어진다', async (t) => { + const recipeRepository = new FakeRecipeRepository(); + const app = await buildApp({ + config: createTestConfig(), + dependencies: createDependencies(recipeRepository), + }); + t.after(() => app.close()); + const cookie = authCookie(app); + + const previewResponse = await app.inject({ + method: 'POST', + url: '/api/import/preview', + headers: { cookie }, + payload: { url: source.sourceUrl }, + }); + assert.equal(previewResponse.statusCode, 200); + assert.equal(previewResponse.json().recipe.title, recipeDraft.title); + + const createResponse = await app.inject({ + method: 'POST', + url: '/api/recipes', + headers: { cookie }, + payload: { + recipe: recipeDraft, + source, + imagePreviewUrl: source.thumbnailUrl, + }, + }); + assert.equal(createResponse.statusCode, 201); + const created = createResponse.json().recipe; + assert.equal(created.source.url, source.sourceUrl); + assert.match(created.imagePath, /^recipes\/.+\/cover\.webp$/); + + const listResponse = await app.inject({ + method: 'GET', + url: '/api/recipes', + headers: { cookie }, + }); + assert.equal(listResponse.json().recipes.length, 1); + + const otherOwnerResponse = await app.inject({ + method: 'GET', + url: `/api/recipes/${created._id}`, + headers: { cookie: authCookie(app, 'google-user-2') }, + }); + assert.equal(otherOwnerResponse.statusCode, 404); + + const updateResponse = await app.inject({ + method: 'PATCH', + url: `/api/recipes/${created._id}`, + headers: { cookie }, + payload: { title: '수정한 김치찌개' }, + }); + assert.equal(updateResponse.json().recipe.title, '수정한 김치찌개'); + + const duplicateResponse = await app.inject({ + method: 'POST', + url: '/api/recipes', + headers: { cookie }, + payload: { recipe: recipeDraft, source }, + }); + assert.equal(duplicateResponse.statusCode, 409); + + const deleteResponse = await app.inject({ + method: 'DELETE', + url: `/api/recipes/${created._id}`, + headers: { cookie }, + }); + assert.equal(deleteResponse.statusCode, 204); +}); diff --git a/tests/extractor.test.js b/tests/extractor.test.js new file mode 100644 index 0000000..b959990 --- /dev/null +++ b/tests/extractor.test.js @@ -0,0 +1,69 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + InstagramExtractor, + normalizeInstagramSessionCookie, +} from '../src/services/extractors/instagram.extractor.js'; +import { readTranscriptSegments } from '../src/services/extractors/youtube.extractor.js'; + +test('Instagram session ID를 Cookie 헤더 형식으로 정규화한다', () => { + assert.equal( + normalizeInstagramSessionCookie('123456%3Aexample'), + 'sessionid=123456%3Aexample;', + ); + assert.equal( + normalizeInstagramSessionCookie('sessionid=123456%3Aexample;'), + 'sessionid=123456%3Aexample;', + ); +}); + +test('Instagram 릴스의 커버 이미지를 썸네일로 사용한다', async () => { + let requestedMediaId; + const extractor = new InstagramExtractor({ + sessionCookie: '123456%3Aexample', + clientFactory: () => ({ + fetchPost: async () => ({ + caption: '테스트 레시피', + media_id: 'media-1', + shortcode: 'Db137BuzUJe', + postType: 'reel', + links: [{ type: 'video', url: 'https://video.example.com/reel.mp4' }], + }), + fetchPostByMediaId: async (mediaId) => { + requestedMediaId = mediaId; + return { + items: [{ + image_versions2: { + candidates: [{ url: 'https://images.example.com/reel-cover.jpg' }], + }, + }], + }; + }, + }), + }); + + const source = await extractor.extract('https://www.instagram.com/reel/Db137BuzUJe/'); + + assert.equal(requestedMediaId, 'media-1'); + assert.equal(source.thumbnailUrl, 'https://images.example.com/reel-cover.jpg'); +}); + +test('youtubei.js transcript 구조에서 timestamp를 보존한다', () => { + const result = readTranscriptSegments({ + transcript: { + content: { + body: { + initial_segments: [ + { start_ms: '12400', end_ms: '18000', snippet: { toString: () => '김치를 볶는다' } }, + { start_ms: '18000', end_ms: '24000', snippet: { toString: () => '물을 넣는다' } }, + ], + }, + }, + }, + }); + + assert.deepEqual(result, [ + { startSec: 12.4, endSec: 18, text: '김치를 볶는다' }, + { startSec: 18, endSec: 24, text: '물을 넣는다' }, + ]); +}); diff --git a/tests/helpers/fakes.js b/tests/helpers/fakes.js new file mode 100644 index 0000000..8c742ec --- /dev/null +++ b/tests/helpers/fakes.js @@ -0,0 +1,151 @@ +export class FakeRecipeRepository { + constructor() { + this.recipes = new Map(); + } + + async listByOwner(ownerGoogleSub) { + return [...this.recipes.values()].filter((recipe) => recipe.ownerGoogleSub === ownerGoogleSub); + } + + async findById(ownerGoogleSub, recipeId) { + const recipe = this.recipes.get(recipeId); + return recipe?.ownerGoogleSub === ownerGoogleSub ? recipe : null; + } + + async findBySource(ownerGoogleSub, platform, sourceId) { + return [...this.recipes.values()].find((recipe) => ( + recipe.ownerGoogleSub === ownerGoogleSub + && recipe.source.platform === platform + && recipe.source.sourceId === sourceId + )) ?? null; + } + + async create(document) { + if (await this.findBySource( + document.ownerGoogleSub, + document.source.platform, + document.source.sourceId, + )) { + const error = new Error('duplicate'); + error.code = 11000; + throw error; + } + this.recipes.set(document._id, document); + return document; + } + + async update(ownerGoogleSub, recipeId, changes) { + const recipe = await this.findById(ownerGoogleSub, recipeId); + if (!recipe) return null; + const updated = { ...recipe, ...changes, updatedAt: new Date() }; + this.recipes.set(recipeId, updated); + return updated; + } + + async delete(ownerGoogleSub, recipeId) { + const recipe = await this.findById(ownerGoogleSub, recipeId); + if (!recipe) return null; + this.recipes.delete(recipeId); + return recipe; + } +} + +export class FakeAllowedEmailRepository { + constructor(accounts = [ + { email: 'google-user-1@example.com', canManageAccess: true }, + { email: 'google-user-2@example.com', canManageAccess: false }, + ]) { + this.accounts = new Map(accounts.map((account) => [account.email, { ...account }])); + } + + async findByEmail(email) { + return this.accounts.get(email?.trim().toLowerCase()) ?? null; + } + + async list() { + return [...this.accounts.values()] + .map(({ email }) => ({ email })) + .sort((a, b) => a.email.localeCompare(b.email)); + } + + async add(email, addedBy) { + const normalizedEmail = email.trim().toLowerCase(); + const account = this.accounts.get(normalizedEmail) ?? { + email: normalizedEmail, + canManageAccess: false, + addedBy, + }; + this.accounts.set(normalizedEmail, account); + return account; + } + + async remove(email) { + return this.accounts.delete(email.trim().toLowerCase()); + } +} + +export const fakeUserRepository = { + async upsertGoogleUser(profile) { + return profile; + }, +}; + +export function createTestConfig(overrides = {}) { + return { + nodeEnv: 'test', + host: '127.0.0.1', + port: 3000, + logLevel: 'silent', + publicBaseUrl: 'http://localhost:3000', + mongoUri: 'mongodb://localhost:27017/our_recipe_atlas_test', + mongoFallbackUri: undefined, + imageRoot: new URL('../../data/images', import.meta.url).pathname, + google: { + clientId: undefined, + clientSecret: undefined, + callbackUrl: 'http://localhost:3000/auth/google/callback', + allowedEmails: new Set(['allowed@example.com']), + }, + authJwtSecret: 'test-secret-that-is-long-enough-for-tests', + authSessionTtlHours: 24, + minimax: { + apiKey: undefined, + baseUrl: 'https://api.minimax.io/v1', + model: 'MiniMax-M2.7', + }, + instagramSessionCookie: undefined, + ...overrides, + }; +} + +export const recipeDraft = { + title: '김치찌개', + summary: '간단한 김치찌개', + servings: '2인분', + ingredientGroups: [ + { + name: '재료', + items: [ + { name: '김치', amount: '200g' }, + { name: '물', amount: '500ml' }, + ], + }, + ], + steps: [ + { order: 1, text: '김치를 볶는다.', timestampSec: null }, + { order: 2, text: '물을 넣고 끓인다.', timestampSec: null }, + ], + tips: ['신김치를 사용한다.'], + tags: ['한식'], +}; + +export const source = { + platform: 'instagram', + sourceUrl: 'https://www.instagram.com/reel/Db137BuzUJe/', + sourceId: 'Db137BuzUJe', + title: '김치찌개', + author: 'recipe_author', + thumbnailUrl: 'https://images.example.com/cover.jpg', + rawText: '김치 200g과 물 500ml를 넣고 끓입니다.', + metadata: {}, +}; diff --git a/tests/image-storage.test.js b/tests/image-storage.test.js new file mode 100644 index 0000000..40adaca --- /dev/null +++ b/tests/image-storage.test.js @@ -0,0 +1,36 @@ +import assert from 'node:assert/strict'; +import path from 'node:path'; +import test from 'node:test'; +import { + buildRelativeImagePath, + isPrivateAddress, + resolveImagePath, +} from '../src/services/image-storage.service.js'; + +test('이미지는 recipe ID 기준 상대 경로로만 저장한다', () => { + assert.equal( + buildRelativeImagePath('0879bf0f-2b64-49fc-a13f-b11b56031993'), + 'recipes/0879bf0f-2b64-49fc-a13f-b11b56031993/cover.webp', + ); + assert.throws(() => buildRelativeImagePath('../outside'), /recipe ID/); +}); + +test('동일한 이미지 상대경로를 Windows와 Linux 파일 경로로 해석한다', () => { + const relativePath = 'recipes/recipe-1/cover.webp'; + + assert.equal( + resolveImagePath('D:\\project\\our_recipe_atlas\\data\\images', relativePath, path.win32), + 'D:\\project\\our_recipe_atlas\\data\\images\\recipes\\recipe-1\\cover.webp', + ); + assert.equal( + resolveImagePath('/mnt/recipe-ssd/our_recipe_atlas/images', relativePath, path.posix), + '/mnt/recipe-ssd/our_recipe_atlas/images/recipes/recipe-1/cover.webp', + ); +}); + +test('SSRF에 사용될 수 있는 사설 및 loopback 주소를 판별한다', () => { + assert.equal(isPrivateAddress('127.0.0.1'), true); + assert.equal(isPrivateAddress('192.168.0.250'), true); + assert.equal(isPrivateAddress('::1'), true); + assert.equal(isPrivateAddress('8.8.8.8'), false); +}); diff --git a/tests/mongo.test.js b/tests/mongo.test.js new file mode 100644 index 0000000..481130e --- /dev/null +++ b/tests/mongo.test.js @@ -0,0 +1,131 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { loadConfig } from '../src/config/env.js'; +import { connectFirstAvailableMongo } from '../src/plugins/mongo.js'; + +function fakeClient(error = null) { + return { + closed: false, + async connect() { + if (error) throw error; + }, + async close() { + this.closed = true; + }, + }; +} + +test('Windows에서 개발용 MongoDB와 로컬 이미지 프로필을 자동 사용한다', () => { + const config = loadConfig({}, { platform: 'win32' }); + + assert.equal(config.nodeEnv, 'development'); + assert.equal(config.mongoUri, 'mongodb://192.168.0.240:27017/our_recipe_atlas'); + assert.equal(config.mongoFallbackUri, 'mongodb://172.16.0.7:27017/our_recipe_atlas'); + assert.deepEqual(config.mongoCollections, { + allowedEmails: 'allowed_google_emails', + recipes: 'recipes_dev', + users: 'users_dev', + }); + assert.match(config.imageRoot, /data\\images$/); +}); + +test('OS 프로필이 환경파일의 개발·운영 선택값보다 우선한다', () => { + const linuxConfig = loadConfig( + { + NODE_ENV: 'development', + MONGO_URI: 'mongodb://192.168.0.240:27017/our_recipe_atlas_dev', + IMAGE_ROOT: './data/images', + AUTH_JWT_SECRET: 'production-secret', + }, + { platform: 'linux' }, + ); + assert.equal(linuxConfig.nodeEnv, 'production'); + assert.equal(linuxConfig.mongoUri, 'mongodb://192.168.0.240:27017/our_recipe_atlas'); + assert.deepEqual(linuxConfig.mongoCollections, { + allowedEmails: 'allowed_google_emails', + recipes: 'recipes', + users: 'users', + }); + assert.equal(linuxConfig.imageRoot, '/mnt/recipe-ssd/our_recipe_atlas/images'); + + const windowsConfig = loadConfig( + { + NODE_ENV: 'production', + MONGO_URI: 'mongodb://192.168.0.240:27017/our_recipe_atlas', + IMAGE_ROOT: '/mnt/recipe-ssd/our_recipe_atlas/images', + }, + { platform: 'win32' }, + ); + assert.equal(windowsConfig.nodeEnv, 'development'); + assert.equal(windowsConfig.mongoUri, 'mongodb://192.168.0.240:27017/our_recipe_atlas'); + assert.equal(windowsConfig.mongoCollections.recipes, 'recipes_dev'); + assert.match(windowsConfig.imageRoot, /data\\images$/); +}); + +test('Linux 운영용 MongoDB와 절대 이미지 경로 설정을 허용한다', () => { + const config = loadConfig( + { AUTH_JWT_SECRET: 'production-secret' }, + { platform: 'linux' }, + ); + + assert.equal(config.nodeEnv, 'production'); + assert.equal(config.mongoUri, 'mongodb://192.168.0.240:27017/our_recipe_atlas'); + assert.equal(config.mongoFallbackUri, 'mongodb://172.16.0.7:27017/our_recipe_atlas'); + assert.equal(config.imageRoot, '/mnt/recipe-ssd/our_recipe_atlas/images'); +}); + +test('primary MongoDB 연결에 성공하면 fallback을 시도하지 않는다', async () => { + const createdUris = []; + const primary = fakeClient(); + + const result = await connectFirstAvailableMongo(['primary', 'fallback'], { + createClient(uri) { + createdUris.push(uri); + return primary; + }, + }); + + assert.equal(result.client, primary); + assert.equal(result.connectionIndex, 0); + assert.deepEqual(createdUris, ['primary']); +}); + +test('primary MongoDB 연결 실패 시 정리한 뒤 fallback으로 연결한다', async () => { + const primaryError = new Error('primary unavailable'); + const primary = fakeClient(primaryError); + const fallback = fakeClient(); + const failures = []; + + const result = await connectFirstAvailableMongo(['primary', 'fallback'], { + createClient(uri) { + return uri === 'primary' ? primary : fallback; + }, + onFailure(index, error) { + failures.push({ index, error }); + }, + }); + + assert.equal(primary.closed, true); + assert.equal(fallback.closed, false); + assert.equal(result.client, fallback); + assert.equal(result.connectionIndex, 1); + assert.deepEqual(failures, [{ index: 0, error: primaryError }]); +}); + +test('모든 MongoDB 연결 실패 시 마지막 오류를 반환하고 클라이언트를 정리한다', async () => { + const primary = fakeClient(new Error('primary unavailable')); + const fallbackError = new Error('fallback unavailable'); + const fallback = fakeClient(fallbackError); + + await assert.rejects( + connectFirstAvailableMongo(['primary', 'fallback'], { + createClient(uri) { + return uri === 'primary' ? primary : fallback; + }, + }), + fallbackError, + ); + + assert.equal(primary.closed, true); + assert.equal(fallback.closed, true); +}); diff --git a/tests/recipe-search.test.js b/tests/recipe-search.test.js new file mode 100644 index 0000000..fa145f5 --- /dev/null +++ b/tests/recipe-search.test.js @@ -0,0 +1,47 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { searchRecipes } from '../public/js/recipe-search.js'; + +function recipe(id, overrides = {}) { + return { + _id: id, + title: '기본 레시피', + summary: null, + tags: [], + ingredientGroups: [{ name: '재료', items: [] }], + ...overrides, + }; +} + +test('재료 정확 일치부터 부분 일치, 제목, 태그, 요약 순으로 검색한다', () => { + const recipes = [ + recipe('summary', { summary: '감자를 맛있게 먹는 방법' }), + recipe('tag', { tags: ['감자'] }), + recipe('title', { title: '감자 수프' }), + recipe('partial', { + ingredientGroups: [{ name: '재료', items: [{ name: '알감자' }] }], + }), + recipe('exact', { + ingredientGroups: [{ name: '재료', items: [{ name: '감자' }, { name: '감자전분' }] }], + }), + ]; + + const matches = searchRecipes(recipes, ' 감자 '); + + assert.deepEqual(matches.map(({ recipe: item }) => item._id), [ + 'exact', + 'partial', + 'title', + 'tag', + 'summary', + ]); + assert.deepEqual(matches[0].matchedIngredients, ['감자', '감자전분']); +}); + +test('검색어가 없으면 기존 레시피 순서를 유지한다', () => { + const recipes = [recipe('new'), recipe('old')]; + assert.deepEqual( + searchRecipes(recipes, '').map(({ recipe: item }) => item._id), + ['new', 'old'], + ); +}); diff --git a/tests/schema.test.js b/tests/schema.test.js new file mode 100644 index 0000000..ee4656d --- /dev/null +++ b/tests/schema.test.js @@ -0,0 +1,75 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { recipeDraftSchema, recipePatchSchema } from '../src/schemas/recipe.schema.js'; +import { + normalizeModelJson, + normalizeRecipePayload, + RecipeParserService, +} from '../src/services/recipe-parser.service.js'; +import { recipeDraft } from './helpers/fakes.js'; + +test('유효한 AI recipe 결과를 허용한다', () => { + assert.deepEqual(recipeDraftSchema.parse(recipeDraft), recipeDraft); +}); + +test('재료 이름이나 단계가 비어 있으면 거부한다', () => { + const invalid = structuredClone(recipeDraft); + invalid.ingredientGroups[0].items[0].name = ''; + assert.equal(recipeDraftSchema.safeParse(invalid).success, false); +}); + +test('MiniMax reasoning 및 markdown fence에서 JSON만 분리한다', () => { + const value = normalizeModelJson('reasoning\n```json\n{"title":"test"}\n```'); + assert.equal(value, '{"title":"test"}'); +}); + +test('부분 수정은 보내지 않은 nullable/default 필드를 만들지 않는다', () => { + assert.deepEqual(recipePatchSchema.parse({ title: '새 제목' }), { title: '새 제목' }); +}); + +test('태그를 표준 태그로 정리하고 최대 3개만 유지한다', () => { + const input = structuredClone(recipeDraft); + input.tags = [' #한식 ', '한식', ' QUICK MEAL ', '감자요리', '중화요리', '샐러드']; + + assert.deepEqual(recipeDraftSchema.parse(input).tags, ['한식', '간단요리', '중식']); + assert.deepEqual( + recipePatchSchema.parse({ tags: ['##찌개', ' 샐러드 ', '식단', '고기'] }).tags, + ['국물', '샐러드', '다이어트'], + ); +}); + +test('AI가 비어 있는 재료 그룹 라벨을 반환하면 구조 라벨만 보완한다', () => { + const input = structuredClone(recipeDraft); + input.ingredientGroups[0].name = null; + const normalized = normalizeRecipePayload(input); + assert.equal(normalized.ingredientGroups[0].name, '재료'); + assert.deepEqual(normalized.ingredientGroups[0].items, recipeDraft.ingredientGroups[0].items); +}); + +test('MiniMax 사고 과정 분리와 충분한 출력 한도를 요청한다', async () => { + let request; + const client = { + chat: { + completions: { + async create(parameters) { + request = parameters; + const output = structuredClone(recipeDraft); + output.ingredientGroups[0].name = null; + return { choices: [{ message: { content: JSON.stringify(output) } }] }; + }, + }, + }, + }; + const service = new RecipeParserService({ client, model: 'MiniMax-M2.7' }); + const result = await service.parse({ + platform: 'youtube', + title: '김치찌개', + rawText: '[12.4] 김치를 볶는다.', + }); + + assert.equal(request.reasoning_split, true); + assert.equal(request.max_completion_tokens, 8192); + assert.match(request.messages[0].content, /최대 3개/); + assert.match(request.messages[0].content, /한식, 중식/); + assert.equal(result.ingredientGroups[0].name, '재료'); +}); diff --git a/tests/url.test.js b/tests/url.test.js new file mode 100644 index 0000000..663ad4e --- /dev/null +++ b/tests/url.test.js @@ -0,0 +1,36 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + canonicalSourceUrl, + detectPlatform, + extractInstagramShortcode, + extractYouTubeId, +} from '../src/utils/url.js'; + +test('지원 URL의 플랫폼과 source ID를 판별한다', () => { + assert.equal(detectPlatform('https://youtu.be/dQw4w9WgXcQ'), 'youtube'); + assert.equal(extractYouTubeId('https://www.youtube.com/shorts/dQw4w9WgXcQ'), 'dQw4w9WgXcQ'); + assert.equal(detectPlatform('https://www.instagram.com/reel/Db137BuzUJe/'), 'instagram'); + assert.equal(extractInstagramShortcode('https://www.instagram.com/p/Db137BuzUJe/'), 'Db137BuzUJe'); +}); + +test('source URL에서 추적 파라미터를 제거해 정규화한다', () => { + assert.equal( + canonicalSourceUrl('https://www.youtube.com/watch?v=dQw4w9WgXcQ&utm_source=x'), + 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', + ); + assert.equal( + canonicalSourceUrl('https://www.instagram.com/p/Db137BuzUJe/?igsh=abc'), + 'https://www.instagram.com/p/Db137BuzUJe/', + ); +}); + +test('HTTP 및 지원하지 않는 host를 거부한다', () => { + assert.throws(() => detectPlatform('http://youtube.com/watch?v=dQw4w9WgXcQ'), /HTTPS/); + assert.equal(detectPlatform('https://example.com/recipe'), 'unsupported'); + assert.throws( + () => extractYouTubeId('https://youtube.com.evil.example/watch?v=dQw4w9WgXcQ'), + /지원하지 않는 YouTube URL/, + ); +}); +