first commit
This commit is contained in:
@@ -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=
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules/
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
data/images/*
|
||||||
|
!data/images/.gitkeep
|
||||||
|
coverage/
|
||||||
|
package-lock.json
|
||||||
@@ -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/<recipe-id>/cover.webp` 형식입니다.
|
||||||
|
|
||||||
|
Windows에서 만든 `node_modules`는 운영 서버로 복사하지 말고, `sharp` 등 OS별 바이너리가 Linux용으로 설치되도록 운영 서버에서 `npm ci --omit=dev`를 실행해야 합니다.
|
||||||
|
|
||||||
|
## 환경변수
|
||||||
|
|
||||||
|
| 변수 | 설명 |
|
||||||
|
| --- | --- |
|
||||||
|
| `HOST`, `PORT` | Fastify listen 주소와 포트 |
|
||||||
|
| `PUBLIC_BASE_URL` | 브라우저가 접근하는 외부 기준 URL |
|
||||||
|
| `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET` | Google OAuth Web client 자격 증명 |
|
||||||
|
| `GOOGLE_CALLBACK_URL` | Google Console에 등록한 정확한 callback URL |
|
||||||
|
| `ALLOWED_GOOGLE_EMAILS` | DB가 비어 있을 때 최초 관리자로 등록할 이메일 목록 |
|
||||||
|
| `AUTH_JWT_SECRET` | 자체 session token 서명 비밀값. 운영에서 필수 |
|
||||||
|
| `AUTH_SESSION_TTL_HOURS` | 로그인 세션의 절대 만료시간(시간). 기본값 `24`, 범위 `1`~`720` |
|
||||||
|
| `MINIMAX_API_KEY` | MiniMax API 또는 Token Plan key |
|
||||||
|
| `MINIMAX_BASE_URL` | 기본값 `https://api.minimax.io/v1` |
|
||||||
|
| `MINIMAX_MODEL` | 기본값 `MiniMax-M2.7` |
|
||||||
|
| `INSTAGRAM_SESSION_COOKIE` | insta-fetcher에 전달할 session cookie |
|
||||||
|
|
||||||
|
MongoDB 주소와 이미지 루트는 위의 OS 프로필에 고정됩니다. 서버 시작 시 primary MongoDB를 먼저 시도하고, 3초 내 연결되지 않으면 VPN fallback으로 연결합니다. 실행 중인 연결이 끊겼을 때 주소를 자동 전환하는 기능은 아닙니다.
|
||||||
|
|
||||||
|
`.env`는 Git에 포함되지 않습니다. Google secret, MiniMax key, Instagram cookie를 브라우저 코드나 로그에 넣지 마세요.
|
||||||
|
|
||||||
|
안전한 `AUTH_JWT_SECRET` 예시는 Node로 생성할 수 있습니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node -e "console.log(require('node:crypto').randomBytes(48).toString('base64url'))"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Google OAuth 설정
|
||||||
|
|
||||||
|
Google Cloud Console에서 OAuth 동의 화면과 OAuth 2.0 Web application client를 준비합니다.
|
||||||
|
|
||||||
|
개발용 Authorized redirect URI:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://localhost:3000/auth/google/callback
|
||||||
|
```
|
||||||
|
|
||||||
|
운영용 Authorized redirect URI:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://recipe.example.com/auth/google/callback
|
||||||
|
```
|
||||||
|
|
||||||
|
운영에서는 `http://192.168.0.250:3000/...` 같은 raw IP callback을 사용하지 않습니다. 실제 도메인과 HTTPS를 nginx 또는 Caddy에서 종료하고 내부 `192.168.0.250:3000`으로 proxy합니다. scope는 `openid email profile`이며 ID Token의 서명, audience, issuer, expiry를 `google-auth-library`로 검증합니다. 내부 사용자 키는 이메일이 아닌 Google `sub`입니다.
|
||||||
|
|
||||||
|
허용할 계정은 다음처럼 설정합니다.
|
||||||
|
|
||||||
|
```env
|
||||||
|
ALLOWED_GOOGLE_EMAILS=user1@gmail.com,user2@gmail.com
|
||||||
|
```
|
||||||
|
|
||||||
|
이 값은 `allowed_google_emails` 컬렉션이 비어 있을 때 한 번만 이관됩니다. 이후 로그인 허용 계정은 앱 하단의 **로그인 허용 계정**에서 이메일만 입력해 관리합니다. 최초 등록된 계정은 관리자이며, 관리자가 추가한 계정은 로그인만 허용되고 다른 계정을 초대할 수 없습니다.
|
||||||
|
|
||||||
|
## 외부 서비스 설정
|
||||||
|
|
||||||
|
### MiniMax
|
||||||
|
|
||||||
|
MiniMax의 OpenAI 호환 endpoint를 사용합니다. key와 model은 서버 환경변수로만 전달합니다.
|
||||||
|
|
||||||
|
```env
|
||||||
|
MINIMAX_API_KEY=...
|
||||||
|
MINIMAX_BASE_URL=https://api.minimax.io/v1
|
||||||
|
MINIMAX_MODEL=MiniMax-M2.7
|
||||||
|
```
|
||||||
|
|
||||||
|
파서는 최대 한 번만 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/<recipe-id>/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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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=
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="description" content="개인 레시피 아카이브">
|
||||||
|
<title>Our Recipe Atlas</title>
|
||||||
|
<link rel="stylesheet" href="/css/app.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="site-header">
|
||||||
|
<a class="brand" href="/" aria-label="Our Recipe Atlas 홈">
|
||||||
|
<span class="brand-mark brand-mark-small" aria-hidden="true">ORA</span>
|
||||||
|
<span>Our Recipe Atlas</span>
|
||||||
|
</a>
|
||||||
|
<div class="profile">
|
||||||
|
<img id="profile-image" class="avatar" alt="" hidden>
|
||||||
|
<span id="profile-name"></span>
|
||||||
|
<button id="access-menu-button" class="menu-button" type="button"
|
||||||
|
aria-label="메뉴 열기" aria-controls="access-drawer"
|
||||||
|
aria-expanded="false">
|
||||||
|
<span></span><span></span><span></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="page-shell">
|
||||||
|
<section class="hero">
|
||||||
|
<p class="eyebrow">COLLECT · REFINE · COOK</p>
|
||||||
|
<h1>
|
||||||
|
<span>오늘 발견한</span>
|
||||||
|
<span>레시피를</span>
|
||||||
|
<span>나만의</span>
|
||||||
|
<span>아틀라스에.</span>
|
||||||
|
</h1>
|
||||||
|
<p>Instagram Reel 또는 YouTube 영상 링크를 붙여 넣으면 재료와 조리법을 정리합니다.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="import-panel" aria-labelledby="import-title">
|
||||||
|
<div>
|
||||||
|
<p class="section-number">01</p>
|
||||||
|
<h2 id="import-title">새 레시피 가져오기</h2>
|
||||||
|
</div>
|
||||||
|
<form id="import-form" class="import-form">
|
||||||
|
<label class="sr-only" for="source-url">Instagram 또는 YouTube URL</label>
|
||||||
|
<input id="source-url" name="url" type="url" inputmode="url"
|
||||||
|
placeholder="https://www.instagram.com/reel/..." required>
|
||||||
|
<button class="button button-primary" type="submit">레시피 가져오기</button>
|
||||||
|
</form>
|
||||||
|
<div id="import-status" class="status" role="status" aria-live="polite" hidden></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="preview-section" class="preview-panel" aria-labelledby="preview-title" hidden>
|
||||||
|
<div class="section-heading">
|
||||||
|
<div>
|
||||||
|
<p class="section-number">02</p>
|
||||||
|
<h2 id="preview-title">확인하고 다듬기</h2>
|
||||||
|
</div>
|
||||||
|
<button id="cancel-preview" class="button button-quiet" type="button">취소</button>
|
||||||
|
</div>
|
||||||
|
<form id="preview-form"></form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="library" aria-labelledby="library-title">
|
||||||
|
<div class="section-heading">
|
||||||
|
<div>
|
||||||
|
<p class="section-number">03</p>
|
||||||
|
<h2 id="library-title">Recipe Library</h2>
|
||||||
|
</div>
|
||||||
|
<span id="recipe-count" class="count-label"></span>
|
||||||
|
</div>
|
||||||
|
<div class="recipe-search">
|
||||||
|
<label class="sr-only" for="recipe-search-input">재료나 레시피 검색</label>
|
||||||
|
<input id="recipe-search-input" type="search" autocomplete="off"
|
||||||
|
placeholder="재료나 레시피 검색 (예: 감자)">
|
||||||
|
<button id="clear-recipe-search" class="text-button" type="button" hidden>지우기</button>
|
||||||
|
</div>
|
||||||
|
<div id="tag-filter" class="tag-filter" hidden>
|
||||||
|
<div class="tag-filter-heading">
|
||||||
|
<span>태그 필터 <small>선택한 태그를 모두 포함</small></span>
|
||||||
|
<div class="tag-filter-actions">
|
||||||
|
<button id="toggle-tag-filter" class="text-button" type="button"
|
||||||
|
aria-expanded="false" hidden></button>
|
||||||
|
<button id="clear-tag-filter" class="text-button" type="button" hidden>전체 보기</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="tag-filter-options" class="tag-filter-options" aria-label="태그 필터"></div>
|
||||||
|
</div>
|
||||||
|
<div class="recipe-results">
|
||||||
|
<div id="recipe-grid" class="recipe-grid" aria-live="polite"></div>
|
||||||
|
<p id="empty-library" class="empty-state" hidden>아직 저장한 레시피가 없습니다.<br>첫 링크를 가져와 보세요.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<button id="drawer-backdrop" class="drawer-backdrop" type="button"
|
||||||
|
aria-label="메뉴 닫기" hidden></button>
|
||||||
|
<aside id="access-drawer" class="access-drawer" role="dialog" aria-modal="true"
|
||||||
|
aria-labelledby="drawer-title" aria-hidden="true" hidden>
|
||||||
|
<div class="drawer-heading">
|
||||||
|
<div>
|
||||||
|
<p class="section-number">MENU</p>
|
||||||
|
<h2 id="drawer-title">메뉴</h2>
|
||||||
|
</div>
|
||||||
|
<button id="close-access-drawer" class="drawer-close" type="button"
|
||||||
|
aria-label="메뉴 닫기">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="drawer-account">
|
||||||
|
<strong id="drawer-user-name"></strong>
|
||||||
|
<span id="drawer-user-email"></span>
|
||||||
|
</div>
|
||||||
|
<div class="drawer-actions">
|
||||||
|
<button id="open-access-overlay" class="drawer-action" type="button" hidden>
|
||||||
|
<span>로그인 허용 계정 관리</span><small>관리자</small>
|
||||||
|
</button>
|
||||||
|
<button id="drawer-logout" class="drawer-action danger" type="button" data-logout>
|
||||||
|
로그아웃
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<button id="access-overlay-backdrop" class="access-overlay-backdrop" type="button"
|
||||||
|
aria-label="로그인 허용 계정 관리 닫기" hidden></button>
|
||||||
|
<section id="access-overlay" class="access-overlay" role="dialog" aria-modal="true"
|
||||||
|
aria-labelledby="access-title" aria-hidden="true" hidden>
|
||||||
|
<div class="drawer-heading">
|
||||||
|
<div>
|
||||||
|
<p class="section-number">ADMIN</p>
|
||||||
|
<h2 id="access-title">로그인 허용 계정</h2>
|
||||||
|
</div>
|
||||||
|
<button id="close-access-overlay" class="drawer-close" type="button"
|
||||||
|
aria-label="로그인 허용 계정 관리 닫기">×</button>
|
||||||
|
</div>
|
||||||
|
<p class="access-copy">Google 로그인을 허용할 이메일을 입력하세요.</p>
|
||||||
|
<form id="access-form" class="access-form">
|
||||||
|
<label class="sr-only" for="access-email">Google 계정 이메일</label>
|
||||||
|
<input id="access-email" name="email" type="email" autocomplete="email"
|
||||||
|
placeholder="name@gmail.com" required>
|
||||||
|
<button class="button button-primary" type="submit">계정 추가</button>
|
||||||
|
</form>
|
||||||
|
<div id="allowed-email-list" class="allowed-email-list"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<button id="scroll-to-top" class="scroll-to-top" type="button"
|
||||||
|
aria-label="맨 위로 이동" title="맨 위로" hidden>↑</button>
|
||||||
|
<div id="toast" class="toast" role="alert" hidden></div>
|
||||||
|
<script type="module" src="/js/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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 }]) => `
|
||||||
|
<button class="tag-filter-button" type="button" data-tag="${escapeHtml(key)}"
|
||||||
|
aria-pressed="${selectedTags.has(key)}">#${escapeHtml(label)} <span>${count}</span></button>
|
||||||
|
`).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
|
||||||
|
? '아직 저장한 레시피가 없습니다.<br>첫 링크를 가져와 보세요.'
|
||||||
|
: '검색 조건에 맞는 레시피가 없습니다.';
|
||||||
|
elements.recipeGrid.innerHTML = matches.map(({ recipe, matchedIngredients }) => {
|
||||||
|
const image = safeImageUrl(recipeImage(recipe));
|
||||||
|
const tags = [...tagsForRecipe(recipe).values()];
|
||||||
|
return `<a class="recipe-card" href="/recipe/${encodeURIComponent(recipe._id)}">
|
||||||
|
<div class="recipe-card-image">
|
||||||
|
${image ? `<img src="${escapeHtml(image)}" alt="" loading="lazy">` : '<span>NO IMAGE</span>'}
|
||||||
|
</div>
|
||||||
|
<div class="recipe-card-body">
|
||||||
|
<span class="platform-label">${escapeHtml(recipe.source.platform)}</span>
|
||||||
|
<h3>${escapeHtml(recipe.title)}</h3>
|
||||||
|
<p>${escapeHtml(recipe.summary || recipe.source.author || '')}</p>
|
||||||
|
${matchedIngredients.length
|
||||||
|
? `<p class="recipe-card-match">일치 재료: ${matchedIngredients.map(escapeHtml).join(', ')}</p>`
|
||||||
|
: ''}
|
||||||
|
${tags.length ? `<div class="recipe-card-tags">${tags.map((tag) => `<span class="tag">#${escapeHtml(tag)}</span>`).join('')}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
</a>`;
|
||||||
|
}).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 `<div class="allowed-email-row">
|
||||||
|
<span>${escapeHtml(email)}</span>
|
||||||
|
${isCurrent
|
||||||
|
? '<small>현재 관리자</small>'
|
||||||
|
: `<button class="text-button danger" type="button" data-remove-email="${escapeHtml(email)}">삭제</button>`}
|
||||||
|
</div>`;
|
||||||
|
}).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 `<fieldset class="editor-group" data-ingredient-group>
|
||||||
|
<div class="editor-group-heading">
|
||||||
|
<input aria-label="재료 그룹 이름" data-group-name value="${escapeHtml(group.name)}" required>
|
||||||
|
<button class="text-button danger" type="button" data-action="remove-group" data-group="${groupIndex}">그룹 삭제</button>
|
||||||
|
</div>
|
||||||
|
<div class="ingredient-list">
|
||||||
|
${group.items.map((item, itemIndex) => `<div class="ingredient-row" data-ingredient-item>
|
||||||
|
<input aria-label="재료 이름" data-item-name value="${escapeHtml(item.name)}" placeholder="재료" required>
|
||||||
|
<input aria-label="재료 수량" data-item-amount value="${escapeHtml(item.amount || '')}" placeholder="수량">
|
||||||
|
<button class="icon-button" type="button" aria-label="재료 삭제" data-action="remove-item" data-group="${groupIndex}" data-item="${itemIndex}">×</button>
|
||||||
|
</div>`).join('')}
|
||||||
|
</div>
|
||||||
|
<button class="text-button" type="button" data-action="add-item" data-group="${groupIndex}">+ 재료 추가</button>
|
||||||
|
</fieldset>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPreview() {
|
||||||
|
const { recipe, imagePreviewUrl, source } = previewData;
|
||||||
|
const image = safeImageUrl(imagePreviewUrl);
|
||||||
|
elements.previewForm.innerHTML = `
|
||||||
|
<div class="preview-layout">
|
||||||
|
<div class="preview-image">
|
||||||
|
${image ? `<img src="${escapeHtml(image)}" alt="가져온 레시피 미리보기">` : '<span>NO IMAGE</span>'}
|
||||||
|
<span class="platform-label">${escapeHtml(source.platform)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="editor-fields">
|
||||||
|
<label>제목<input id="recipe-title" value="${escapeHtml(recipe.title)}" required></label>
|
||||||
|
<label>한 줄 설명<textarea id="recipe-summary" rows="2">${escapeHtml(recipe.summary || '')}</textarea></label>
|
||||||
|
<label>분량<input id="recipe-servings" value="${escapeHtml(recipe.servings || '')}"></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="editor-section">
|
||||||
|
<div class="editor-section-heading"><h3>재료</h3><button class="text-button" type="button" data-action="add-group">+ 그룹 추가</button></div>
|
||||||
|
<div id="ingredient-groups">${recipe.ingredientGroups.map(ingredientGroupTemplate).join('')}</div>
|
||||||
|
</div>
|
||||||
|
<div class="editor-section">
|
||||||
|
<div class="editor-section-heading"><h3>조리 순서</h3><button class="text-button" type="button" data-action="add-step">+ 단계 추가</button></div>
|
||||||
|
<div id="step-list">${recipe.steps.map((step, index) => `<div class="step-row" data-step>
|
||||||
|
<span>${index + 1}</span>
|
||||||
|
<textarea data-step-text rows="2" required>${escapeHtml(step.text)}</textarea>
|
||||||
|
<input data-step-time type="number" min="0" step="1" value="${step.timestampSec ?? ''}" placeholder="초">
|
||||||
|
<button class="icon-button" type="button" aria-label="단계 삭제" data-action="remove-step" data-step-index="${index}">×</button>
|
||||||
|
</div>`).join('')}</div>
|
||||||
|
</div>
|
||||||
|
<div class="editor-fields two-column">
|
||||||
|
<label>팁 (한 줄에 하나)<textarea id="recipe-tips" rows="3">${escapeHtml(recipe.tips.join('\n'))}</textarea></label>
|
||||||
|
<label>태그 (표준 태그 중 최대 3개, 쉼표로 구분)<textarea id="recipe-tags" rows="3">${escapeHtml(recipe.tags.join(', '))}</textarea></label>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="button button-primary" type="submit">아틀라스에 저장</button>
|
||||||
|
</div>`;
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 }));
|
||||||
|
}
|
||||||
@@ -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 `<fieldset class="editor-group" data-ingredient-group>
|
||||||
|
<div class="editor-group-heading">
|
||||||
|
<input aria-label="재료 그룹 이름" data-group-name value="${escapeHtml(group.name)}" required>
|
||||||
|
<button class="text-button danger" type="button" data-action="remove-group" data-group="${groupIndex}">그룹 삭제</button>
|
||||||
|
</div>
|
||||||
|
<div class="ingredient-list">
|
||||||
|
${group.items.map((item, itemIndex) => `<div class="ingredient-row" data-ingredient-item>
|
||||||
|
<input aria-label="재료 이름" data-item-name value="${escapeHtml(item.name)}" placeholder="재료" required>
|
||||||
|
<input aria-label="재료 수량" data-item-amount value="${escapeHtml(item.amount || '')}" placeholder="수량">
|
||||||
|
<button class="icon-button" type="button" aria-label="재료 삭제" data-action="remove-item" data-group="${groupIndex}" data-item="${itemIndex}">×</button>
|
||||||
|
</div>`).join('')}
|
||||||
|
</div>
|
||||||
|
<button class="text-button" type="button" data-action="add-item" data-group="${groupIndex}">+ 재료 추가</button>
|
||||||
|
</fieldset>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = `
|
||||||
|
<a class="back-link" href="/">← Recipe Library</a>
|
||||||
|
<form id="recipe-edit-form" class="preview-panel recipe-edit-panel">
|
||||||
|
<div class="section-heading">
|
||||||
|
<div><p class="section-number">EDIT RECIPE</p><h2>레시피 수정</h2></div>
|
||||||
|
<button id="cancel-edit" class="button button-quiet" type="button">취소</button>
|
||||||
|
</div>
|
||||||
|
<div class="preview-layout">
|
||||||
|
<div class="preview-image">
|
||||||
|
${image ? `<img src="${escapeHtml(image)}" alt="">` : '<span>NO IMAGE</span>'}
|
||||||
|
<span class="platform-label">${escapeHtml(recipe.source.platform)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="editor-fields">
|
||||||
|
<label>제목<input id="recipe-title" value="${escapeHtml(recipe.title)}" required></label>
|
||||||
|
<label>한 줄 설명<textarea id="recipe-summary" rows="2">${escapeHtml(recipe.summary || '')}</textarea></label>
|
||||||
|
<label>분량<input id="recipe-servings" value="${escapeHtml(recipe.servings || '')}"></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="editor-section">
|
||||||
|
<div class="editor-section-heading"><h3>재료</h3><button class="text-button" type="button" data-action="add-group">+ 그룹 추가</button></div>
|
||||||
|
<div>${recipe.ingredientGroups.map(ingredientGroupTemplate).join('')}</div>
|
||||||
|
</div>
|
||||||
|
<div class="editor-section">
|
||||||
|
<div class="editor-section-heading"><h3>조리 순서</h3><button class="text-button" type="button" data-action="add-step">+ 단계 추가</button></div>
|
||||||
|
<div>${recipe.steps.map((step, index) => `<div class="step-row" data-step>
|
||||||
|
<span>${index + 1}</span>
|
||||||
|
<textarea data-step-text rows="2" required>${escapeHtml(step.text)}</textarea>
|
||||||
|
<input data-step-time type="number" min="0" step="1" value="${step.timestampSec ?? ''}" placeholder="초">
|
||||||
|
<button class="icon-button" type="button" aria-label="단계 삭제" data-action="remove-step" data-step-index="${index}">×</button>
|
||||||
|
</div>`).join('')}</div>
|
||||||
|
</div>
|
||||||
|
<div class="editor-fields two-column">
|
||||||
|
<label>팁 (한 줄에 하나)<textarea id="recipe-tips" rows="3">${escapeHtml((recipe.tips ?? []).join('\n'))}</textarea></label>
|
||||||
|
<label>태그 (쉼표로 구분, 기존 표현 재사용 권장)<textarea id="recipe-tags" rows="3">${escapeHtml((recipe.tags ?? []).join(', '))}</textarea></label>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions"><button class="button button-primary" type="submit">변경사항 저장</button></div>
|
||||||
|
</form>`;
|
||||||
|
|
||||||
|
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 = `
|
||||||
|
<a class="back-link" href="/">← Recipe Library</a>
|
||||||
|
<article class="recipe-detail">
|
||||||
|
<div class="detail-hero">
|
||||||
|
<div class="detail-image">${image ? `<img src="${escapeHtml(image)}" alt="">` : '<span>NO IMAGE</span>'}</div>
|
||||||
|
<div class="detail-intro">
|
||||||
|
<span class="platform-label">${escapeHtml(recipe.source.platform)}</span>
|
||||||
|
<h1>${escapeHtml(recipe.title)}</h1>
|
||||||
|
${recipe.summary ? `<p>${escapeHtml(recipe.summary)}</p>` : ''}
|
||||||
|
${recipe.servings ? `<p class="servings">분량 · ${escapeHtml(recipe.servings)}</p>` : ''}
|
||||||
|
<div class="detail-actions">
|
||||||
|
<button id="edit-recipe" class="button button-primary" type="button">레시피 수정</button>
|
||||||
|
<a class="button button-secondary" href="${escapeHtml(recipe.source.url)}" target="_blank" rel="noreferrer">원본 보기 ↗</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-columns">
|
||||||
|
<section>
|
||||||
|
<p class="section-number">INGREDIENTS</p>
|
||||||
|
<h2>재료</h2>
|
||||||
|
${recipe.ingredientGroups.map((group) => `<div class="ingredient-detail">
|
||||||
|
<h3>${escapeHtml(group.name)}</h3>
|
||||||
|
<ul>${group.items.map((item) => `<li><span>${escapeHtml(item.name)}</span><strong>${escapeHtml(item.amount || '')}</strong></li>`).join('')}</ul>
|
||||||
|
</div>`).join('')}
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<p class="section-number">METHOD</p>
|
||||||
|
<h2>조리 순서</h2>
|
||||||
|
<ol class="method-list">${recipe.steps.map((step) => {
|
||||||
|
const body = `<span>${escapeHtml(step.text)}</span>`;
|
||||||
|
return `<li><strong>${String(step.order).padStart(2, '0')}</strong>${step.timestampSec != null && recipe.source.platform === 'youtube'
|
||||||
|
? `<a href="${escapeHtml(youtubeTimestampUrl(recipe.source.url, step.timestampSec))}" target="_blank" rel="noreferrer">${body}<small>${Math.floor(step.timestampSec / 60)}:${String(Math.round(step.timestampSec) % 60).padStart(2, '0')} ↗</small></a>`
|
||||||
|
: body}</li>`;
|
||||||
|
}).join('')}</ol>
|
||||||
|
${recipe.tips.length ? `<div class="tips"><h3>Tips</h3><ul>${recipe.tips.map((tip) => `<li>${escapeHtml(tip)}</li>`).join('')}</ul></div>` : ''}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<footer class="detail-footer">
|
||||||
|
<div>${recipe.tags.map((tag) => `<span class="tag">#${escapeHtml(tag)}</span>`).join('')}</div>
|
||||||
|
<button id="delete-recipe" class="button button-danger" type="button">레시피 삭제</button>
|
||||||
|
</footer>
|
||||||
|
</article>`;
|
||||||
|
|
||||||
|
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 = `<p class="empty-state">${escapeHtml(error.message)}</p><a class="button button-secondary" href="/">목록으로 돌아가기</a>`;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="description" content="Our Recipe Atlas 로그인">
|
||||||
|
<title>로그인 · Our Recipe Atlas</title>
|
||||||
|
<link rel="stylesheet" href="/css/app.css">
|
||||||
|
</head>
|
||||||
|
<body class="login-page">
|
||||||
|
<main class="login-card">
|
||||||
|
<div class="brand-mark" aria-hidden="true">ORA</div>
|
||||||
|
<p class="eyebrow">PRIVATE RECIPE ARCHIVE</p>
|
||||||
|
<h1>Our Recipe Atlas</h1>
|
||||||
|
<p class="login-copy">Instagram과 YouTube에서 발견한 레시피를 한곳에 정리하세요.</p>
|
||||||
|
<a class="button button-primary google-login" href="/auth/google">
|
||||||
|
<span aria-hidden="true">G</span>
|
||||||
|
Google 계정으로 계속하기
|
||||||
|
</a>
|
||||||
|
<p class="fine-print">허용 목록에 등록된 Google 계정만 로그인할 수 있습니다.</p>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Recipe · Our Recipe Atlas</title>
|
||||||
|
<link rel="stylesheet" href="/css/app.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="site-header">
|
||||||
|
<a class="brand" href="/">
|
||||||
|
<span class="brand-mark brand-mark-small" aria-hidden="true">ORA</span>
|
||||||
|
<span>Our Recipe Atlas</span>
|
||||||
|
</a>
|
||||||
|
<div class="profile">
|
||||||
|
<img id="profile-image" class="avatar" alt="" hidden>
|
||||||
|
<span id="profile-name"></span>
|
||||||
|
<button id="logout-button" class="button button-quiet" type="button" data-logout>로그아웃</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main id="recipe-detail" class="detail-shell">
|
||||||
|
<p class="empty-state">레시피를 불러오는 중입니다.</p>
|
||||||
|
</main>
|
||||||
|
<button id="scroll-to-top" class="scroll-to-top" type="button"
|
||||||
|
aria-label="맨 위로 이동" title="맨 위로" hidden>↑</button>
|
||||||
|
<div id="toast" class="toast" role="alert" hidden></div>
|
||||||
|
<script type="module" src="/js/recipe.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+92
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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: [],
|
||||||
|
});
|
||||||
@@ -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' });
|
||||||
@@ -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' });
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
'수정할 내용을 하나 이상 입력해 주세요.',
|
||||||
|
);
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(/<think>[\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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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입니다.');
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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: '물을 넣는다' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
@@ -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: {},
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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'],
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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('<think>reasoning</think>\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, '재료');
|
||||||
|
});
|
||||||
@@ -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/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
Reference in New Issue
Block a user