diff --git a/README.md b/README.md index 16e9870..4d4071b 100644 --- a/README.md +++ b/README.md @@ -5,11 +5,12 @@ Instagram 게시물과 YouTube 영상의 텍스트를 레시피로 구조화해 ## 현재 구현 범위 - Google Authorization Code/OIDC 로그인, PKCE `S256`, 이메일 allowlist -- HttpOnly 자체 인증 cookie와 소유자별 API 접근 제어 +- HttpOnly 자체 인증 cookie와 그룹 멤버 간 recipe 공유 - Instagram caption 추출 및 YouTube 설명·자막·timestamp 추출 - MiniMax OpenAI 호환 Chat Completions API를 이용한 레시피 구조화 +- 영어 등 외국어 원문의 recipe 필드를 자연스러운 한국어로 번역 - AI 결과 미리보기/편집 후 저장 -- Recipe CRUD, 소유자·원본별 중복 방지 +- Recipe CRUD, 그룹 멤버 조회와 소유자·원본별 중복 방지 - 외부 이미지를 최대 1280px WebP로 변환해 `IMAGE_ROOT`에 저장 - 반응형 Recipe 목록/상세 화면과 YouTube timestamp 링크 - nginx, systemd 운영 예제 @@ -158,12 +159,22 @@ ID와 비밀번호로 서버에서 자동 로그인하지 않습니다. cookie MongoDB는 `our_recipe_atlas` 하나만 사용하고 collection으로 환경을 분리합니다. - 공통: `allowed_google_emails` -- 개발: `users_dev`, `recipes_dev` -- 운영: `users`, `recipes` +- 개발: `users_dev`, `groups_dev`, `recipes_dev` +- 운영: `users`, `groups`, `recipes` - 각 `users*.googleSub`: unique index +- 각 `groups*.memberGoogleSubs`: 조회 index - 각 `recipes*`: `ownerGoogleSub + source.platform + source.sourceId` unique index - 원본 text와 YouTube transcript를 recipe source에 보존해 재분석에 사용할 수 있습니다. +그룹 문서는 recipe를 함께 볼 Google 계정의 `sub`를 보관합니다. 그룹이 없는 사용자는 자기 recipe만 볼 수 있고, 같은 그룹의 recipe는 함께 조회하되 수정과 삭제는 작성자만 할 수 있습니다. + +```json +{ + "name": "우리 가족", + "memberGoogleSubs": ["google-sub-1", "google-sub-2"] +} +``` + 이미지의 DB 값은 다음과 같은 상대 경로뿐입니다. ```text @@ -174,13 +185,13 @@ recipes//cover.webp ## API -모든 `/api/**` endpoint는 인증 cookie가 필요하며 로그인한 사용자의 데이터만 반환합니다. +모든 `/api/**` endpoint는 인증 cookie가 필요합니다. Recipe 목록과 상세는 같은 그룹 멤버에게 공유되고, 생성·수정·삭제 권한은 작성자에게 유지됩니다. | Method | Path | 역할 | | --- | --- | --- | | `POST` | `/api/import/preview` | URL 추출 및 AI recipe 미리보기 | -| `GET` | `/api/recipes` | 내 recipe 목록 | -| `GET` | `/api/recipes/:id` | 내 recipe 상세 | +| `GET` | `/api/recipes` | 내 그룹의 recipe 목록 | +| `GET` | `/api/recipes/:id` | 내 그룹의 recipe 상세 | | `POST` | `/api/recipes` | 미리보기 확인 후 저장 | | `PATCH` | `/api/recipes/:id` | recipe 필드 수정 | | `DELETE` | `/api/recipes/:id` | recipe와 로컬 이미지 삭제 | @@ -199,7 +210,7 @@ npm test npm run lint ``` -테스트 범위에는 URL/ID 판별, AI schema, timestamp 정규화, allowlist, 인증 middleware, 소유자 격리, CRUD, 중복 source 처리, 이미지 상대경로와 SSRF 차단이 포함됩니다. +테스트 범위에는 URL/ID 판별, AI schema, timestamp 정규화, allowlist, 인증 middleware, 그룹 공유와 소유자 쓰기 권한, CRUD, 중복 source 처리, 이미지 상대경로와 SSRF 차단이 포함됩니다. 실제 계정과 네트워크가 준비된 뒤에는 별도 smoke test로 다음을 확인합니다. diff --git a/public/js/recipe.js b/public/js/recipe.js index 872a5a9..8ff1ce4 100644 --- a/public/js/recipe.js +++ b/public/js/recipe.js @@ -8,6 +8,7 @@ const detail = document.querySelector('#recipe-detail'); const toast = document.querySelector('#toast'); const scrollToTop = document.querySelector('#scroll-to-top'); let currentRecipe = null; +let currentUser = null; let toastTimer; bindScrollToTop(scrollToTop); @@ -166,6 +167,7 @@ function renderRecipeEditor(recipe) { function renderRecipe(recipe) { document.title = `${recipe.title} · Our Recipe Atlas`; const image = recipe.imagePath ? `/media/${recipe.imagePath}` : null; + const canEdit = recipe.ownerGoogleSub === currentUser.googleSub; detail.innerHTML = ` ← Recipe Library
@@ -177,7 +179,7 @@ function renderRecipe(recipe) { ${recipe.summary ? `

${escapeHtml(recipe.summary)}

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

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

` : ''}
- + ${canEdit ? '' : ''} 원본 보기 ↗
@@ -205,12 +207,13 @@ function renderRecipe(recipe) {
`; - document.querySelector('#edit-recipe').addEventListener('click', () => renderRecipeEditor(recipe)); + if (!canEdit) return; + document.querySelector('#edit-recipe').addEventListener('click', () => renderRecipeEditor(recipe)); document.querySelector('#delete-recipe').addEventListener('click', async () => { if (!window.confirm(`'${recipe.title}' 레시피를 삭제할까요?`)) return; try { @@ -223,7 +226,8 @@ function renderRecipe(recipe) { } bindLogout(); -appBar.setUser(await loadSession()); +currentUser = await loadSession(); +appBar.setUser(currentUser); try { const recipeId = decodeURIComponent(window.location.pathname.split('/').filter(Boolean).pop()); diff --git a/src/app.js b/src/app.js index 1ea194f..ba994fc 100644 --- a/src/app.js +++ b/src/app.js @@ -55,10 +55,12 @@ export async function buildApp({ config = loadConfig(), dependencies = {} } = {} if ( dependencies.allowedEmailRepository + && dependencies.groupRepository && dependencies.recipeRepository && dependencies.userRepository ) { app.decorate('allowedEmailRepository', dependencies.allowedEmailRepository); + app.decorate('groupRepository', dependencies.groupRepository); app.decorate('recipeRepository', dependencies.recipeRepository); app.decorate('userRepository', dependencies.userRepository); } else { diff --git a/src/config/env.js b/src/config/env.js index 7a2a513..5d49690 100644 --- a/src/config/env.js +++ b/src/config/env.js @@ -37,6 +37,7 @@ export function mongoCollectionNames(nodeEnv) { const suffix = nodeEnv === 'development' ? '_dev' : nodeEnv === 'test' ? '_test' : ''; return Object.freeze({ allowedEmails: 'allowed_google_emails', + groups: `groups${suffix}`, recipes: `recipes${suffix}`, users: `users${suffix}`, }); diff --git a/src/plugins/mongo.js b/src/plugins/mongo.js index 36e5c50..0a3ad51 100644 --- a/src/plugins/mongo.js +++ b/src/plugins/mongo.js @@ -1,6 +1,7 @@ import { MongoClient } from 'mongodb'; import fastifyPlugin from 'fastify-plugin'; import { AllowedEmailRepository } from '../repositories/allowed-email.repository.js'; +import { GroupRepository } from '../repositories/group.repository.js'; import { RecipeRepository } from '../repositories/recipe.repository.js'; import { UserRepository } from '../repositories/user.repository.js'; @@ -49,11 +50,13 @@ async function mongoPlugin(fastify, { config }) { const db = client.db(); const collections = config.mongoCollections; const allowedEmailRepository = new AllowedEmailRepository(db, collections.allowedEmails); + const groupRepository = new GroupRepository(db, collections.groups); const recipeRepository = new RecipeRepository(db, collections.recipes); const userRepository = new UserRepository(db, collections.users); await Promise.all([ allowedEmailRepository.ensureIndexes(), + groupRepository.ensureIndexes(), recipeRepository.ensureIndexes(), userRepository.ensureIndexes(), ]); @@ -65,6 +68,7 @@ async function mongoPlugin(fastify, { config }) { fastify.decorate('mongoClient', client); fastify.decorate('db', db); fastify.decorate('allowedEmailRepository', allowedEmailRepository); + fastify.decorate('groupRepository', groupRepository); fastify.decorate('recipeRepository', recipeRepository); fastify.decorate('userRepository', userRepository); diff --git a/src/repositories/group.repository.js b/src/repositories/group.repository.js new file mode 100644 index 0000000..10efcf9 --- /dev/null +++ b/src/repositories/group.repository.js @@ -0,0 +1,24 @@ +export class GroupRepository { + constructor(db, collectionName = 'groups') { + this.collection = db.collection(collectionName); + } + + async ensureIndexes() { + await this.collection.createIndex({ memberGoogleSubs: 1 }); + } + + async listMemberGoogleSubs(googleSub) { + const groups = await this.collection + .find( + { memberGoogleSubs: googleSub }, + { projection: { _id: 0, memberGoogleSubs: 1 } }, + ) + .toArray(); + + return [...new Set( + groups + .flatMap((group) => group.memberGoogleSubs ?? []) + .filter((memberGoogleSub) => typeof memberGoogleSub === 'string'), + )]; + } +} diff --git a/src/repositories/recipe.repository.js b/src/repositories/recipe.repository.js index 8dd1997..2d62d78 100644 --- a/src/repositories/recipe.repository.js +++ b/src/repositories/recipe.repository.js @@ -16,9 +16,13 @@ export class RecipeRepository { } async listByOwner(ownerGoogleSub) { + return this.listByOwners([ownerGoogleSub]); + } + + async listByOwners(ownerGoogleSubs) { return this.collection .find( - { ownerGoogleSub }, + { ownerGoogleSub: { $in: ownerGoogleSubs } }, { projection: { 'source.rawText': 0, @@ -34,6 +38,13 @@ export class RecipeRepository { return this.collection.findOne({ _id: recipeId, ownerGoogleSub }); } + async findByIdForOwners(ownerGoogleSubs, recipeId) { + return this.collection.findOne({ + _id: recipeId, + ownerGoogleSub: { $in: ownerGoogleSubs }, + }); + } + async findBySource(ownerGoogleSub, platform, sourceId) { return this.collection.findOne({ ownerGoogleSub, diff --git a/src/routes/recipe.routes.js b/src/routes/recipe.routes.js index 919e901..0afe5b2 100644 --- a/src/routes/recipe.routes.js +++ b/src/routes/recipe.routes.js @@ -29,13 +29,23 @@ function verifiedSource(source) { return source; } +async function visibleOwnerGoogleSubs(fastify, googleSub) { + const groupMembers = await fastify.groupRepository.listMemberGoogleSubs(googleSub); + return [...new Set([googleSub, ...groupMembers])]; +} + 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', { preHandler: fastify.authenticate }, async (request) => { + const ownerGoogleSubs = await visibleOwnerGoogleSubs(fastify, request.user.sub); + return { recipes: await fastify.recipeRepository.listByOwners(ownerGoogleSubs) }; + }); fastify.get('/api/recipes/:id', { preHandler: fastify.authenticate }, async (request) => { - const recipe = await fastify.recipeRepository.findById(request.user.sub, request.params.id); + const ownerGoogleSubs = await visibleOwnerGoogleSubs(fastify, request.user.sub); + const recipe = await fastify.recipeRepository.findByIdForOwners( + ownerGoogleSubs, + request.params.id, + ); if (!recipe) throw new NotFoundError('레시피를 찾을 수 없습니다.'); return { recipe }; }); diff --git a/src/services/recipe-parser.service.js b/src/services/recipe-parser.service.js index 1dfde6d..ca257ff 100644 --- a/src/services/recipe-parser.service.js +++ b/src/services/recipe-parser.service.js @@ -13,6 +13,9 @@ const SYSTEM_PROMPT = `당신은 원문에서 레시피를 구조화하는 데 - 수량이 명확하지 않으면 null 또는 원문 표현을 유지합니다. - g, ml, 큰술, 작은술, 장, 개 등의 원문 단위를 유지합니다. - 광고, 비즈니스 문의, SNS 링크, 해시태그 등 레시피와 무관한 내용을 제거합니다. +- 원문이 주로 영어 또는 다른 외국어이면 title, summary, servings, ingredientGroups, steps, tips를 자연스러운 한국어로 번역합니다. +- 번역할 때 재료와 요리의 고유명사는 의미가 달라지지 않게 유지하고, 수량, 온도, 시간 값은 변환하지 않습니다. +- 원문이 이미 한국어이면 불필요하게 문체나 표현을 바꾸지 않습니다. - YouTube timestamp는 원문 transcript에서 확인되는 경우에만 초 단위 숫자로 기록합니다. - Instagram 단계의 timestampSec은 null입니다. - 원문에 재료 그룹 이름이 없으면 "재료"를 사용합니다. ingredientGroups[].name은 null이 될 수 없습니다. diff --git a/tests/app.test.js b/tests/app.test.js index efc5d0e..89fbffc 100644 --- a/tests/app.test.js +++ b/tests/app.test.js @@ -6,15 +6,17 @@ import { SESSION_COOKIE_NAME } from '../src/plugins/auth.js'; import { createTestConfig, FakeAllowedEmailRepository, + FakeGroupRepository, FakeRecipeRepository, fakeUserRepository, recipeDraft, source, } from './helpers/fakes.js'; -function createDependencies(recipeRepository) { +function createDependencies(recipeRepository, groupRepository = new FakeGroupRepository()) { return { allowedEmailRepository: new FakeAllowedEmailRepository(), + groupRepository, recipeRepository, userRepository: fakeUserRepository, sourceExtractor: { extract: async () => source }, @@ -336,3 +338,56 @@ test('import preview와 소유자별 Recipe CRUD가 이어진다', async (t) => }); assert.equal(deleteResponse.statusCode, 204); }); + +test('같은 그룹 사용자는 레시피를 함께 조회하지만 작성자만 수정하고 삭제한다', async (t) => { + const recipeRepository = new FakeRecipeRepository(); + const groupRepository = new FakeGroupRepository([ + { memberGoogleSubs: ['google-user-1', 'google-user-2'] }, + ]); + const app = await buildApp({ + config: createTestConfig(), + dependencies: createDependencies(recipeRepository, groupRepository), + }); + t.after(() => app.close()); + + const createResponse = await app.inject({ + method: 'POST', + url: '/api/recipes', + headers: { cookie: authCookie(app, 'google-user-1') }, + payload: { recipe: recipeDraft, source }, + }); + assert.equal(createResponse.statusCode, 201); + const created = createResponse.json().recipe; + + const sharedListResponse = await app.inject({ + method: 'GET', + url: '/api/recipes', + headers: { cookie: authCookie(app, 'google-user-2') }, + }); + assert.deepEqual( + sharedListResponse.json().recipes.map((recipe) => recipe._id), + [created._id], + ); + + const sharedDetailResponse = await app.inject({ + method: 'GET', + url: `/api/recipes/${created._id}`, + headers: { cookie: authCookie(app, 'google-user-2') }, + }); + assert.equal(sharedDetailResponse.statusCode, 200); + + const updateResponse = await app.inject({ + method: 'PATCH', + url: `/api/recipes/${created._id}`, + headers: { cookie: authCookie(app, 'google-user-2') }, + payload: { title: '다른 사용자의 수정' }, + }); + assert.equal(updateResponse.statusCode, 404); + + const deleteResponse = await app.inject({ + method: 'DELETE', + url: `/api/recipes/${created._id}`, + headers: { cookie: authCookie(app, 'google-user-2') }, + }); + assert.equal(deleteResponse.statusCode, 404); +}); diff --git a/tests/helpers/fakes.js b/tests/helpers/fakes.js index 8c742ec..76c9a3b 100644 --- a/tests/helpers/fakes.js +++ b/tests/helpers/fakes.js @@ -4,7 +4,12 @@ export class FakeRecipeRepository { } async listByOwner(ownerGoogleSub) { - return [...this.recipes.values()].filter((recipe) => recipe.ownerGoogleSub === ownerGoogleSub); + return this.listByOwners([ownerGoogleSub]); + } + + async listByOwners(ownerGoogleSubs) { + return [...this.recipes.values()] + .filter((recipe) => ownerGoogleSubs.includes(recipe.ownerGoogleSub)); } async findById(ownerGoogleSub, recipeId) { @@ -12,6 +17,11 @@ export class FakeRecipeRepository { return recipe?.ownerGoogleSub === ownerGoogleSub ? recipe : null; } + async findByIdForOwners(ownerGoogleSubs, recipeId) { + const recipe = this.recipes.get(recipeId); + return ownerGoogleSubs.includes(recipe?.ownerGoogleSub) ? recipe : null; + } + async findBySource(ownerGoogleSub, platform, sourceId) { return [...this.recipes.values()].find((recipe) => ( recipe.ownerGoogleSub === ownerGoogleSub @@ -50,6 +60,18 @@ export class FakeRecipeRepository { } } +export class FakeGroupRepository { + constructor(groups = []) { + this.groups = groups; + } + + async listMemberGoogleSubs(googleSub) { + return [...new Set(this.groups + .filter((group) => group.memberGoogleSubs.includes(googleSub)) + .flatMap((group) => group.memberGoogleSubs))]; + } +} + export class FakeAllowedEmailRepository { constructor(accounts = [ { email: 'google-user-1@example.com', canManageAccess: true }, diff --git a/tests/mongo.test.js b/tests/mongo.test.js index 481130e..d5cef58 100644 --- a/tests/mongo.test.js +++ b/tests/mongo.test.js @@ -23,6 +23,7 @@ test('Windows에서 개발용 MongoDB와 로컬 이미지 프로필을 자동 assert.equal(config.mongoFallbackUri, 'mongodb://172.16.0.7:27017/our_recipe_atlas'); assert.deepEqual(config.mongoCollections, { allowedEmails: 'allowed_google_emails', + groups: 'groups_dev', recipes: 'recipes_dev', users: 'users_dev', }); @@ -43,6 +44,7 @@ test('OS 프로필이 환경파일의 개발·운영 선택값보다 우선한 assert.equal(linuxConfig.mongoUri, 'mongodb://192.168.0.240:27017/our_recipe_atlas'); assert.deepEqual(linuxConfig.mongoCollections, { allowedEmails: 'allowed_google_emails', + groups: 'groups', recipes: 'recipes', users: 'users', }); diff --git a/tests/schema.test.js b/tests/schema.test.js index ee4656d..48fc631 100644 --- a/tests/schema.test.js +++ b/tests/schema.test.js @@ -71,5 +71,7 @@ test('MiniMax 사고 과정 분리와 충분한 출력 한도를 요청한다', assert.equal(request.max_completion_tokens, 8192); assert.match(request.messages[0].content, /최대 3개/); assert.match(request.messages[0].content, /한식, 중식/); + assert.match(request.messages[0].content, /외국어.*자연스러운 한국어로 번역/); + assert.match(request.messages[0].content, /수량, 온도, 시간 값은 변환하지 않습니다/); assert.equal(result.ingredientGroups[0].name, '재료'); });