first commit

This commit is contained in:
2026-08-13 18:36:31 +09:00
commit cee36bf12d
48 changed files with 4212 additions and 0 deletions
+19
View File
@@ -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=
+8
View File
@@ -0,0 +1,8 @@
node_modules/
.env
*.log
.DS_Store
data/images/*
!data/images/.gitkeep
coverage/
package-lock.json
+252
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
+36
View File
@@ -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;
}
}
+19
View File
@@ -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=
+24
View File
@@ -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
+26
View File
@@ -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,
},
},
];
+37
View File
@@ -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"
}
}
+329
View File
@@ -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; }
}
+150
View File
@@ -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>
+21
View File
@@ -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;
}
+515
View File
@@ -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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
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;
}
+24
View File
@@ -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');
});
});
}
+49
View File
@@ -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 }));
}
+233
View File
@@ -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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
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>`;
}
+14
View File
@@ -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();
}
+24
View File
@@ -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>
+29
View File
@@ -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
View File
@@ -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;
}
+175
View File
@@ -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,
});
}
+48
View File
@@ -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;
}
+187
View File
@@ -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: [],
});
+76
View File
@@ -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' });
+30
View File
@@ -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;
}
}
+64
View File
@@ -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;
}
}
+30
View File
@@ -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;
}
}
+41
View File
@@ -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();
},
);
}
+16
View File
@@ -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,
};
});
}
+104
View File
@@ -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();
});
}
+72
View File
@@ -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,
'수정할 내용을 하나 이상 입력해 주세요.',
);
+13
View File
@@ -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;
}
+24
View File
@@ -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,
},
};
}
}
+135
View File
@@ -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 });
}
}
+124
View File
@@ -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,
});
}
}
+27
View File
@@ -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' });
}
}
+94
View File
@@ -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입니다.');
}
+283
View File
@@ -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);
});
+69
View File
@@ -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: '물을 넣는다' },
]);
});
+151
View File
@@ -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: {},
};
+36
View File
@@ -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);
});
+131
View File
@@ -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);
});
+47
View File
@@ -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'],
);
});
+75
View File
@@ -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, '재료');
});
+36
View File
@@ -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/,
);
});