chore: 공개 저장소 전환 보안 강화 + README 재구성 (#11)

* chore: 공개 저장소 전환 보안 강화 + README 재구성

보안 리뷰 결과를 반영하여 회사 직원 로컬 사용에 안전하도록 강화.
README는 사용자가 보기 쉽도록 목차·기능별 anchor·보안 모델로 재구성.

## 보안 변경 (CRITICAL/HIGH 위주)

### 인프라 (docker-compose.yml)
- Postgres 호스트 포트 5432 노출 제거 (컨테이너 내부 네트워크만 사용)
  → LAN의 다른 장치에서 직접 DB 접근 불가
- App 포트 127.0.0.1:3000으로 바인딩 (LAN 노출 차단)
- DB 자격증명 fallback `:- meetingpass` 제거 → `:?` 강제 환경변수
  → .env 누락 시 명시적 에러로 실패 (기본 비밀번호 사용 위험 제거)

### 응답 헤더 (next.config.ts)
- X-Content-Type-Options: nosniff
- X-Frame-Options: DENY
- Referrer-Policy: strict-origin-when-cross-origin
- Permissions-Policy: camera=(), microphone=(self), ...

### 입력 검증
- /api/summarize: transcript 100,000자 상한 (이전 무제한)
- DOMPurify: FORBID_TAGS/ATTR ['style'] 추가 → 인라인 CSS 인젝션 차단

### 런타임 안전성
- src/lib/db.ts: DATABASE_URL 미설정 시 production에서 명시적 경고
  (build-time 빌드는 placeholder로 통과 유지)

### 사용자 안내 (UI)
- /settings 페이지: 공유 PC 경고 + Web Speech API 외부 송출 안내

## 문서

### 신규
- LICENSE (MIT)
- SECURITY.md (위협 모델 / 신뢰 모델 / 한계 / 신고 방법)

### README 재구성
- 4-line Quick Start 헤더
- 📑 목차 (anchor 점프)
-  주요 기능 7개 sub-section (각각 사용법/동작/제한)
- 🔒 보안 모델 섹션 추가 (공개 배포 권장 사항 명시)
- ⚠️ 알려진 제약 표 정리

### .env.example
- 강력한 비밀번호 생성 가이드 (openssl rand -base64 24)
- 필수/선택 명확히 구분

### docs/ROADMAP.md
- "보안 강화" 섹션 추가 (완료된 보안 조치 목록)

## 사용자 직접 조치 필요 (코드로 처리 불가)
- ⚠️ .env 파일에 발급된 Gemini API 키가 주석 처리되어 남아 있음
  Google AI Studio에서 폐기 후 .env에서 해당 줄 삭제 필요
- POSTGRES_PASSWORD를 강력한 임의 값으로 교체 (openssl rand -base64 24)
- 비밀번호 변경 시 docker compose down -v 후 재시작 (데이터 초기화)

## 검증
- 102 tests passing
- 보안 헤더 4종 적용 확인 (curl -sI)
- LAN IP에서 접근 시도 차단 확인 (192.168.x.x:3000 → 000)
- 127.0.0.1:3000 정상 동작 확인 (200)

## 보류된 항목 (문서화로 처리)
- M-1: Web Speech API의 음성 → Google 송출 (구조적 한계)
- M-2: npm audit 6건 (dev 의존성, 자동 수정 가능한 것 없음)
- H-3: 파일 업로드 매직 바이트 검증 (업로드 파일 미사용 상태)
- 인증 시스템: 단일 사용자 가정으로 비목표

* docs: 직원용 5분 시작 가이드 추가

EMPLOYEE_QUICKSTART.md에 직원 셋업 5분 가이드 작성:
- 한 번에 끝나는 setup 명령 (openssl로 강력한 비밀번호 자동 생성)
- 추천 사용 흐름 + 기능별 단축 액션
- 사내 정책 확인 안내 (음성 → Google 송출 명시)
- 단순 변환 모드(API 키 없음) 옵션 안내
- 자주 쓰는 docker 명령

README에서 직원/팀원 공유 시 가이드로 링크.
This commit is contained in:
2026-04-28 18:59:39 +09:00
committed by GitHub
parent 908cc860e8
commit f4ad512056
12 changed files with 837 additions and 162 deletions
+33 -4
View File
@@ -1,8 +1,37 @@
# PostgreSQL
DATABASE_URL="postgresql://meetinguser:meetingpass@localhost:5432/meetingminutes"
# =============================================================================
# Meeting Minutes — 환경 변수
#
# 사용법:
# 1. 이 파일을 .env로 복사: cp .env.example .env
# 2. 아래 값들을 직접 채워주세요. 빈 값으로 두면 docker compose가 실패합니다.
#
# 강력한 비밀번호 생성 (macOS / Linux):
# openssl rand -base64 24
# =============================================================================
# -----------------------------------------------------------------------------
# 필수 — PostgreSQL
# -----------------------------------------------------------------------------
POSTGRES_USER=meetinguser
POSTGRES_PASSWORD=meetingpass
POSTGRES_DB=meetingminutes
# Gemini API (선택사항 - AI 요약 기능에 필요)
# ⚠️ 강력한 임의 값으로 채우세요 (예: openssl rand -base64 24)
# 이 비밀번호는 docker compose가 첫 기동 시 사용하며, 이후 변경하려면
# `docker compose down -v`로 볼륨을 삭제하거나 ALTER USER로 갱신해야 합니다.
POSTGRES_PASSWORD=
# 위 USER/PASSWORD/DB와 일치해야 합니다.
# 로컬 개발(npm run dev) 시에는 host=localhost, Docker에서는 host=db.
DATABASE_URL=
# -----------------------------------------------------------------------------
# 선택 — Gemini API 키
# -----------------------------------------------------------------------------
# Gemini AI 요약 기능을 쓰려면 키가 필요합니다.
# 두 가지 방법 중 선택:
# (A) 웹 UI 방식 — 앱 기동 후 /settings에서 입력 (브라우저 LocalStorage 저장, 추천)
# (B) 환경변수 방식 — 아래 값 채우면 모든 사용자가 공통으로 사용
#
# 무료 발급: https://aistudio.google.com/apikey
# 키 없이도 "단순 변환" 모드는 정상 동작합니다.
GEMINI_API_KEY=
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 nad4-su
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+384 -145
View File
@@ -1,29 +1,71 @@
# Meeting Minutes — 실시간 AI 회의록
# 🎙️ Meeting Minutes
음성 녹음을 실시간으로 텍스트·요약으로 변환하고, 구조화된 회의록 자동 생성하는 웹 서비스.
**음성을 텍스트로, 텍스트를 회의록으로** — 브라우저 기반 실시간 AI 회의록 자동 작성 도구.
향후 로드맵 — 노션 AI Meeting Notes 수준의 워크스페이스: [`docs/ROADMAP.md`](docs/ROADMAP.md)
녹음 시작 한 번이면 끝. Gemini AI가 30초마다 회의록을 갱신하고, 종료 시 자동으로 최종 문서를 생성합니다. 회의록 외에도 강의 노트, 1:1 미팅, 브레인스토밍 등 6가지 템플릿을 제공하며, Google Docs에 서식 그대로 붙여넣기까지 한 번에.
```bash
git clone https://github.com/nad4-su/meeting-minutes.git
cd meeting-minutes && cp .env.example .env
# ⚠️ .env 파일을 열어 POSTGRES_PASSWORD를 강력한 값으로 채워주세요
# 예: echo "POSTGRES_PASSWORD=$(openssl rand -base64 24)" >> .env
docker compose --profile tools run --rm migrate
docker compose up -d
# → http://127.0.0.1:3000 (Chrome 권장, localhost 전용)
```
> ⚠️ **보안 주의**: 회의 내용에 민감 정보가 있다면 [SECURITY.md](SECURITY.md)의 위협 모델 섹션을 먼저 읽어주세요. 실시간 녹음은 Chrome Web Speech API를 사용하여 음성을 Google 서버로 전송합니다.
>
> 👥 **직원/팀원에게 공유할 때**: [docs/EMPLOYEE_QUICKSTART.md](docs/EMPLOYEE_QUICKSTART.md) — 5분 시작 가이드 (사내 정책 안내 포함)
---
## 현재 기능 (Phase 0 + 0.5 + 1 + 1.5 + 2)
## 📑 목차
### 녹음 & 전사
- 브라우저 실시간 음성 인식 (Web Speech API · Chrome `ko-KR`)
- 네트워크 끊김 시 **자동 재연결** (최대 8회) — 누적 transcript 보존
- 파일 업로드: MP3, WAV, WebM, M4A, OGG, FLAC (최대 500MB)
- 인식된 텍스트는 textarea에서 직접 편집 가능
- [✨ 주요 기능](#-주요-기능)
- [🎤 실시간 녹음 & 전사](#-실시간-녹음--전사)
- [🤖 AI 회의록 (6 템플릿 × 3 강도)](#-ai-회의록-6-템플릿--3-강도)
- [⚡ 실시간 롤링 요약](#-실시간-롤링-요약)
- [💾 회의록 저장/조회/검색](#-회의록-저장조회검색)
- [✅ 액션 아이템 자동 추출](#-액션-아이템-자동-추출)
- [🔑 웹에서 API 키 설정](#-웹에서-api-키-설정)
- [📋 Google Docs 호환 복사](#-google-docs-호환-복사)
- [🚀 시작하기](#-시작하기)
- [Docker Compose (권장)](#docker-compose-권장)
- [로컬 개발 환경](#로컬-개발-환경)
- [🛠️ 기술 스택](#-기술-스택)
- [📁 프로젝트 구조](#-프로젝트-구조)
- [🗺️ 로드맵](#-로드맵)
- [🔒 보안 모델](#-보안-모델)
- [⚠️ 알려진 제약](#-알려진-제약)
- [🤝 기여하기](#-기여하기)
- [📄 라이선스](#-라이선스)
### AI 요약 (Gemini)
- **실시간 회의록**: 녹음 중 30초 간격으로 Gemini가 중간 요약 갱신 (25단어 이상, 40단어 증분 게이트)
- **최종 회의록**: 녹음 중지 시 자동 생성
- 모델: `gemini-2.5-flash-lite` (개인 무료 등급에서 여유있게 동작)
- 429 쿼터 초과 시 **60초 쿨다운** + UI 안내, 그 외 실패는 지수 백오프
- Gemini 실패 시 **단순 변환 마크다운으로 자동 폴백** — 결과물은 항상 보장
- 내보내기: Markdown(.md), HTML(.html), 클립보드 복사
---
### 템플릿 & 강도 조절 (Phase 0.5)
용도에 맞게 출력 구조와 요약 깊이를 조절할 수 있음.
## ✨ 주요 기능
### 🎤 실시간 녹음 & 전사
브라우저 내장 Web Speech API(Chrome `ko-KR`)로 마이크 입력을 실시간 텍스트로 변환합니다.
**특징**
- 즉시 시작 — 별도 STT 서버나 키 없이 작동
- **자동 재연결** — 네트워크 끊김 시 최대 8회 재시도, 누적 transcript 보존
- 확정 전 텍스트는 회색 이탤릭 + 깜빡이는 커서로 시각화
- 녹음 중 `🔴 N단어 · M개 구간` 실시간 카운터
**사용**
1. 홈에서 **🎤 녹음 시작** 클릭
2. 마이크 권한 허용
3. 발화 → 좌측 패널에 텍스트가 쌓임
4. **녹음 중지** → 텍스트가 textarea로 이동, 직접 편집 가능
---
### 🤖 AI 회의록 (6 템플릿 × 3 강도)
회의 외에도 다양한 용도에 맞춰 출력 구조를 선택할 수 있습니다.
| 템플릿 | 생성되는 구조 | 기본 강도 | 라이브 요약 |
|---|---|---|---|
@@ -35,221 +77,418 @@
| 📝 원문 정리 | 요약 없이 문단화·오탈자 정리만 | 상세 고정 | ❌ |
| ⚙️ 커스텀 | 자유 프롬프트 입력 | - | ✅ |
강도 3단계:
**강도 3단계**
- **간결** — 각 섹션 3줄 이내
- **표준** — 맥락이 이해될 정도
- **상세** — 세부사항·수치 누락 없이 보존
### 액션 아이템 (Phase 2)
- 회의록 저장 시 마크다운의 `- [ ]` 항목을 자동으로 추출 → DB에 구조화 저장
- 상세 페이지에 **체크리스트 섹션** — 체크박스 토글로 완료 표시
- 개별 항목 삭제, 마크다운 편집 후 **🔄 재추출** 버튼으로 동기화
- `/meetings` 상단에 "내 미완료 액션 (최근 5)" 위젯
- 회의 카드에 미완료 카운트 배지 (예: `✅ 3 미완료`)
- **Google Docs 호환 복사** — `📋 Docs용` 버튼으로 서식 유지 (heading/list/bold/code) 채로 클립보드에 복사 → Docs/Word/Notion에 그대로 붙여넣기
### Gemini 키 웹 설정 (Phase 1.5)
- **`/settings` 페이지** — 헤더 ⚙️ 링크로 진입
- 브라우저 LocalStorage에 저장 (서버 DB 미사용)
- 마스킹된 현재 키 표시 (`AIza••••XYZ12`), 보이기/숨기기 토글
- **🧪 테스트 호출** — 가벼운 Gemini 응답으로 키 유효성 검증
- 우선순위: 브라우저 키 → 환경변수 → 단순 변환 폴백
- 키 미설정 시에도 단순 변환 모드로 회의록은 항상 생성됨
### 저장·조회 워크스페이스 (Phase 1)
- **`📌 저장` 버튼** — 생성된 회의록을 DB에 영속 저장
- **`/meetings` 목록 페이지** — 카드 그리드, 페이지네이션, 빈 상태 UI
- **`/meetings/[id]` 상세 페이지** — Markdown 렌더 + 인라인 편집 + 삭제
- 편집 모드는 좌(textarea) · 우(live preview) 분할
- 참석자/태그도 상세 페이지에서 편집
- **검색 바** — 제목·transcript·본문 부분 검색 (300ms debounce, URL 동기화)
- **Markdown 라이브러리** — `marked` + `isomorphic-dompurify`로 안전한 HTML 렌더 (표/리스트/인용 등 모두 정상)
### 화면
- 녹음 시 **좌(실시간 텍스트) · 우(실시간 회의록)** 분할 뷰
- 확정 전 interim 텍스트는 회색 이탤릭 + 깜빡이는 커서
- 새 내용 도착 시 자동 스크롤
- 진행률 바 (첫 요약까지 단어 수), 쿨다운 카운터
- 템플릿/강도 변경 시 라이브 요약도 즉시 반영
- 메인 화면 상단 우측 `📚 저장된 회의록` 링크 → 목록으로 이동
**사용**
1. 변환 모드 = `Gemini AI 요약` 선택
2. 템플릿 선택 (예: `🎓 강의·세미나 노트`)
3. 강도 선택
4. 녹음 또는 텍스트 입력 → **회의록 생성**
---
## 기술 스택
### ⚡ 실시간 롤링 요약
| 구분 | 기술 |
|------|------|
| 프레임워크 | Next.js 16 (App Router) + TypeScript |
| STT (실시간) | Web Speech API — Chrome 내장, 무료 |
| AI 요약 | Gemini 2.5 Flash Lite — 무료 등급 15 RPM / 1000 RPD |
| DB | PostgreSQL 16 + Prisma 7 (driver adapter `@prisma/adapter-pg`) |
| Markdown | `marked` + `isomorphic-dompurify` |
| 테스트 | Vitest (102 tests, jsdom) |
| 배포 | Docker Compose (app + db + test profile) |
녹음 중 **30초마다** Gemini가 그동안의 발화를 분석해 중간 회의록을 자동 갱신합니다.
**동작**
- 첫 갱신 조건: 25단어 이상
- 이후 갱신 조건: 직전 요약 후 40단어 증가
- 화면 우측에 진행률 바 + 갱신 시각 표시
- 429 쿼터 초과 시 60초 자동 쿨다운, UI에 카운트다운 노출
- 일반 실패 시 지수 백오프 (15s → 30s → 최대 120s)
**비용 가드 (Gemini 2.5 Flash Lite 무료 등급 기준)**
- 15 RPM / 1000 RPD / 250K TPM
- 30초 폴링 + 증분 게이트 → 1시간 회의 ≤ 60회 호출, 발화량 적으면 훨씬 적음
- 개인 사용 시 일일 한도 도달 거의 불가
---
## 빠른 시작 (Docker Compose)
### 💾 회의록 저장/조회/검색
### 1. 준비
생성한 회의록을 PostgreSQL에 영속 저장하고 나중에 다시 조회/편집/검색할 수 있습니다.
**기능**
- **`📌 저장`** 버튼 → DB에 저장 → 상세 페이지 링크 제공
- **`/meetings`** 카드 그리드 — 제목 / 날짜 / 템플릿 배지 / 미완료 액션 카운트
- **검색** — 300ms debounce, 제목·transcript·본문 부분 매칭, URL 동기화
- **`/meetings/[id]`** 상세 — Markdown 렌더링 + 인라인 편집 (좌 textarea / 우 미리보기)
- **참석자 / 태그** 편집 (목록 필터링은 Phase 3 예정)
- 삭제 (확인 다이얼로그)
**API**
| 메서드 | 경로 | 용도 |
|---|---|---|
| `GET` | `/api/meetings` | 목록 + 검색 (`?q=`) + 페이지네이션 |
| `POST` | `/api/meetings` | 새 회의록 저장 (액션 아이템 자동 추출 동반) |
| `GET` | `/api/meetings/[id]` | 상세 (actionItems 포함) |
| `PUT` | `/api/meetings/[id]` | 본문/메타 편집 |
| `DELETE` | `/api/meetings/[id]` | 삭제 |
---
### ✅ 액션 아이템 자동 추출
회의록 저장 시 마크다운의 `- [ ]` / `- [x]` 항목을 **자동으로 구조화** 추출하여 별도 체크리스트로 관리합니다.
**기능**
- 저장 시 자동 추출 (Gemini 추가 호출 0 — 휴리스틱 파싱)
- 상세 페이지에 **✅ 액션 아이템** 섹션
- 체크박스 토글 (낙관적 업데이트, 즉시 반영)
- 진행률 배지 (`완료/전체`)
- 개별 삭제 (hover ✕)
- **🔄 재추출** — 마크다운 편집 후 동기화 (확인 후 토글 상태 초기화)
- `/meetings` 상단 위젯 "내 미완료 액션 (최근 5건)"
- 회의 카드에 미완료 카운트 배지
**API**
| 메서드 | 경로 | 용도 |
|---|---|---|
| `PATCH` | `/api/action-items/[id]` | 토글 / task 수정 |
| `DELETE` | `/api/action-items/[id]` | 개별 삭제 |
| `POST` | `/api/meetings/[id]/reparse-action-items` | 마크다운 재파싱 |
---
### 🔑 웹에서 API 키 설정
`.env`를 만지지 않고 `/settings` 페이지에서 Gemini 키를 입력·저장·검증할 수 있습니다.
**저장 위치**
- 브라우저 LocalStorage (서버 DB에 저장되지 않음)
- 요청 body로 함께 전송되어 일회성으로 사용
**우선순위**
```
1. 요청 body의 apiKey (브라우저 LocalStorage)
↓ 없으면
2. process.env.GEMINI_API_KEY (서버 환경변수 fallback)
↓ 없으면
3. summarize → 단순 변환 폴백 (warning 표시)
summarize-live → 503 + 안내
```
**기능**
- 마스킹된 현재 키 표시 (`AIza••••XYZ12`)
- 보이기/숨기기 토글
- **🧪 테스트 호출** — 가벼운 Gemini 응답으로 즉시 키 검증
- 현재 키 소스 배지 (`브라우저` / `환경변수` / `없음`)
**보안**
- HTTPS 권장 (LocalStorage는 동일 출처 정책 의존)
- GET 응답에 절대 풀 키 노출 안 함
---
### 📋 Google Docs 호환 복사
서식(헤딩/리스트/굵게/코드/표)을 유지한 채 클립보드에 복사 → Google Docs / Word / Notion에 그대로 붙여넣기.
**원리**
- `navigator.clipboard.write([new ClipboardItem({ 'text/html', 'text/plain' })])`
- 미지원 브라우저는 `contenteditable` + `execCommand('copy')` 폴백
**버튼 라벨링**
- `📋 .md` — 마크다운 원문 복사
- `📋 Docs용` — 서식 유지 복사 (파랑 강조)
- `⬇ .md` / `⬇ .html` — 파일 다운로드
---
## 🚀 시작하기
### Docker Compose (권장)
#### 사전 요구사항
- [Docker Desktop](https://docs.docker.com/get-docker/) 또는 Docker Engine + Compose v2
- Chrome 브라우저 (Web Speech API)
#### 1. 저장소 클론
```bash
git clone git@github.com:nad4-su/meeting-minutes.git
git clone https://github.com/nad4-su/meeting-minutes.git
cd meeting-minutes
```
#### 2. 환경 변수 (필수)
```bash
cp .env.example .env
```
**Gemini API 키 설정 — 두 가지 방법 중 선택**:
- (A) **웹 UI** — 앱 기동 후 `/settings` 페이지에서 입력 (브라우저 LocalStorage 저장, 추천)
- (B) **`.env` 환경변수** — `GEMINI_API_KEY=AIza...` 작성 ([Google AI Studio](https://aistudio.google.com/apikey)에서 무료 발급)
`.env` 파일을 열어 다음 값을 채워주세요:
> 키가 없어도 "단순 변환" 모드는 정상 동작. Gemini 모드를 쓰려면 둘 중 하나는 필요.
```bash
POSTGRES_USER=meetinguser # 임의 사용자명
POSTGRES_DB=meetingminutes
POSTGRES_PASSWORD=<강력한_임의_값> # openssl rand -base64 24
DATABASE_URL=postgresql://meetinguser:<위와_같은_비번>@localhost:5432/meetingminutes
```
### 2. DB 마이그레이션 (최초 1회 + 스키마 변경 시)
> ⚠️ `POSTGRES_PASSWORD`를 비워두면 docker compose가 명시적 에러로 실패합니다. 보안을 위한 의도된 동작.
> 💡 **Gemini API 키는 두 가지 방법 중 선택**
>
> - **(A) 웹 UI** — 앱 기동 후 `/settings`에서 입력 (브라우저 LocalStorage, 추천)
> - **(B) `.env` 환경변수** — `GEMINI_API_KEY=AIza...` 작성
>
> [Google AI Studio](https://aistudio.google.com/apikey)에서 무료로 발급. 키 없이도 "단순 변환" 모드는 동작.
#### 3. DB 마이그레이션 (최초 1회)
```bash
docker compose --profile tools run --rm migrate
```
이 명령은 `db` 컨테이너를 자동 기동하고 `prisma migrate deploy`로 테이블 생성합니다.
`db` 컨테이너를 자동 기동 `prisma migrate deploy`로 테이블 생성.
### 3. 기동
#### 4. 앱 기동
```bash
docker compose up -d --build
docker compose up -d
```
브라우저에서 [http://localhost:3000](http://localhost:3000) 접속**Chrome 권장** (Web Speech API).
브라우저에서 [http://localhost:3000](http://localhost:3000) 접속.
### 4. 확인 방법
1. 회의 제목 입력 (선택)
2. **Gemini AI 요약** 모드 + 원하는 **템플릿** / **강도** 선택
3. **녹음 시작** → 마이크 권한 허용
4. 말하기 시작 → 좌측 실시간 텍스트, 25단어 이상 쌓이면 우측에 중간 회의록 생성
5. **녹음 중지** → 최종 회의록이 페이지 하단에 자동 생성됨
6. **`📌 저장`** 버튼 → `/meetings/[id]`로 영속화
7. 상단 **`📚 저장된 회의록`** 링크 → 목록·검색·편집
### 서비스 관리
#### 서비스 관리
```bash
docker compose logs -f app # 로그
docker compose restart app # 재시작
docker compose down # 중지 (데이터 유지)
docker compose down -v # 중지 + 볼륨 삭제
docker compose up -d --build # 코드 변경 후 재빌드
docker compose logs -f app # 로그
docker compose restart app # 재시작 (데이터 유지)
docker compose down # 중지
docker compose down -v # 중지 + 볼륨 삭제 (데이터 초기화)
docker compose up -d --build # 코드 변경 후 재빌드
```
### 테스트 (Docker)
#### 데이터 백업
```bash
docker compose exec db pg_dump -U meetinguser meetingminutes > backup-$(date +%Y%m%d).sql
```
#### 테스트
```bash
docker compose --profile tools run --rm test
```
별도 Node 설치 불필요. `tools` 프로필 컨테이너에서 `vitest` 실행.
별도 Node 설치 불필요. `tools` 프로필이 vitest를 컨테이너에서 실행 (102 tests).
---
## 로컬 개발 (Docker 없이)
### 로컬 개발 환경
```bash
npm install
# 별도 PostgreSQL 필요
docker compose up -d db
npx prisma migrate dev
# 개발 서버
npm run dev # http://localhost:3000
npm test # 전체 테스트
npm run test:watch # 워치 모드
npm run test:coverage # 커버리지 리포트
```
---
## 프로젝트 구조
## 🛠️ 기술 스택
| 구분 | 기술 |
|------|------|
| 프레임워크 | Next.js 16 (App Router) + React 19 + TypeScript |
| STT (실시간) | Web Speech API — Chrome 내장, 무료 |
| AI 요약 | Gemini 2.5 Flash Lite (무료 등급 15 RPM / 1000 RPD) |
| DB | PostgreSQL 16 + Prisma 7 (driver adapter `@prisma/adapter-pg`) |
| Markdown | `marked` + `isomorphic-dompurify` |
| 스타일 | Tailwind CSS v4 |
| 테스트 | Vitest 4 (jsdom, 102 tests) |
| 배포 | Docker Compose + standalone Next.js 빌드 |
---
## 📁 프로젝트 구조
```
src/
├── app/
│ ├── api/
│ │ ├── upload/route.ts # 파일 업로드
│ │ ├── summarize/route.ts # 최종 회의록 (Gemini + simple fallback)
│ │ ├── summarize-live/route.ts # 실시간 중간 요약
│ │ ├── upload/route.ts # 파일 업로드
│ │ ├── summarize/route.ts # 최종 회의록 (Gemini + simple fallback)
│ │ ├── summarize-live/route.ts # 실시간 중간 요약
│ │ ├── meetings/
│ │ │ ├── route.ts # GET 목록 (q 검색) / POST 저장
│ │ │ ├── route.ts # GET 목록 / POST 저장
│ │ │ └── [id]/
│ │ │ ├── route.ts # GET / PUT / DELETE 상세
│ │ │ └── reparse-action-items/route.ts # POST 마크다운 재파싱
│ │ │ ├── route.ts # GET / PUT / DELETE
│ │ │ └── reparse-action-items/route.ts # 마크다운 재파싱
│ │ ├── action-items/
│ │ │ └── [id]/route.ts # PATCH 토글 / DELETE
│ │ │ └── [id]/route.ts # PATCH / DELETE
│ │ └── settings/
│ │ ├── status/route.ts # 환경변수 키 설정 여부
│ │ └── test/route.ts # 키 유효성 검증 (가벼운 Gemini 호출)
│ │ ├── status/route.ts # 환경변수 키 존재 여부
│ │ └── test/route.ts # 키 유효성 검증
│ ├── meetings/
│ │ ├── page.tsx # 저장된 회의록 목록 + 검색
│ │ └── [id]/page.tsx # 상세 + 편집 + 삭제
│ ├── settings/page.tsx # 키 설정 페이지 (LocalStorage)
│ ├── page.tsx # 메인 UI (녹음/요약)
│ │ ├── page.tsx # 목록 + 검색 + 미완료 위젯
│ │ └── [id]/page.tsx # 상세 + 편집
│ ├── settings/page.tsx # 키 설정
│ ├── page.tsx # 메인 (녹음/요약)
│ └── layout.tsx
├── lib/
│ ├── db.ts # Prisma 싱글톤 (pg adapter)
│ ├── markdown.ts # marked + DOMPurify 렌더 + Docs용 서식 복사
│ ├── action-items.ts # 마크다운 - [ ] 휴리스틱 파서
│ ├── api-keys.ts # 서버: 요청 키 → env fallback 우선순위
│ ├── api-key-storage.ts # 클라이언트: LocalStorage 헬퍼 + 마스킹
│ ├── db.ts # Prisma 싱글톤 (pg adapter)
│ ├── markdown.ts # marked + DOMPurify + Docs용 복사
│ ├── action-items.ts # - [ ] 휴리스틱 파서
│ ├── api-keys.ts # 서버: 요청 키 → env fallback
│ ├── api-key-storage.ts # 클라: LocalStorage + 마스킹
│ ├── templates.ts # 템플릿 레지스트리 + 프롬프트 빌더
│ ├── minutes-generator.ts # 최종 요약 생성
│ ├── live-summary.ts # 롤링 요약 생성
│ ├── transcript-formatter.ts
│ ├── audio-validation.ts
│ ├── upload-handler.ts
│ ├── transcript-formatter.ts
│ ├── templates.ts # 템플릿 레지스트리 + 프롬프트 빌더
│ ├── minutes-generator.ts # 최종 요약 생성 (템플릿 적용)
│ ├── live-summary.ts # 롤링 요약 생성 (템플릿 적용)
│ └── export-minutes.ts
├── hooks/
│ ├── useSpeechRecognition.ts # Web Speech API + 네트워크 재시도
│ └── useLiveSummary.ts # 30s 폴링 + 429 쿨다운 + 증분 게이트
│ ├── useSpeechRecognition.ts # Web Speech + 네트워크 재시도
│ └── useLiveSummary.ts # 30s 폴링 + 429 쿨다운
├── components/
│ ├── upload/AudioUploader.tsx
│ ├── recorder/LiveRecorder.tsx # 좌우 분할 뷰
│ ├── minutes/MinutesViewer.tsx # 미리보기/원문 토글 + 저장
│ ├── recorder/LiveRecorder.tsx # 좌우 분할 뷰
│ ├── minutes/MinutesViewer.tsx # 미리보기 / 원문 / 저장
│ └── meeting/
│ ├── MeetingCard.tsx # 미완료 액션 카운트 배지 포함
│ ├── MeetingSearchBar.tsx # debounce + URL 동기화
│ ├── MeetingDetail.tsx # 상세/편집 UI + Docs용 복사
│ └── ActionItemList.tsx # 체크박스 토글 + 재추출 + 삭제
└── __tests__/ # 102 tests
│ ├── MeetingCard.tsx
│ ├── MeetingSearchBar.tsx
│ ├── MeetingDetail.tsx
│ └── ActionItemList.tsx
└── __tests__/ # 102 tests / 13 files
docs/
├── ROADMAP.md # 개발 로드맵 (Phase 0~4)
── CREATE_ISSUES.sh # gh CLI용 이슈 자동 생성 스크립트
├── ROADMAP.md # 단계별 계획
── CREATE_ISSUES.sh # gh CLI용 epic 이슈 생성
└── PR_DRAFT_*.md # PR 본문 히스토리
prisma/
├── schema.prisma # Meeting 모델
└── migrations/ # 마이그레이션 히스토리
├── schema.prisma # Meeting + ActionItem
└── migrations/ # 2개 (init, add_action_items)
```
---
## 로드맵 요약
## 🗺️ 로드맵
상세: [`docs/ROADMAP.md`](docs/ROADMAP.md)
상세 [`docs/ROADMAP.md`](docs/ROADMAP.md) 또는 [GitHub Milestones](https://github.com/nad4-su/meeting-minutes/milestones).
| Phase | 내용 | 상태 |
|---|---|---|
| **0** | 실시간 전사 + 롤링 요약 + 자동 최종 생성 | ✅ 완료 |
| **0.5** | 템플릿 (6종) + 강도 조절 (3단계) + 커스텀 프롬프트 | ✅ 완료 |
| **0** | 실시간 전사 + Gemini 롤링 요약 + 자동 최종 | ✅ 완료 |
| **0.5** | 6 템플릿 + 3 강도 + 커스텀 프롬프트 | ✅ 완료 |
| **1** | 회의 저장/조회/검색 워크스페이스 | ✅ 완료 |
| **1.5** | 웹에서 Gemini 키 설정 (LocalStorage) | ✅ 완료 |
| **1.5** | 웹에서 Gemini API 키 설정 (LocalStorage) | ✅ 완료 |
| **2** | 액션 아이템 추출/체크리스트 + Google Docs 복사 | ✅ 완료 |
| **3** | 참석자 + 태그 시스템 | 📋 기획됨 |
| **4** | 차별화 기능 (캘린더, AI Q&A, 블록 에디터) | 💡 선택 |
| **3** | 참석자 + 태그 시스템 (목록 필터) | 📋 [`#7`](https://github.com/nad4-su/meeting-minutes/issues/7) |
| **v0.1.0** | MVP + Phase 3 + 폴리시 | 🚧 [`#6`](https://github.com/nad4-su/meeting-minutes/issues/6) |
| **4** | 캘린더 / AI Q&A / 후속 메일 / 블록 에디터 | 💡 [`#8`](https://github.com/nad4-su/meeting-minutes/issues/8) |
### 진행 관리
1. `gh auth login``bash docs/CREATE_ISSUES.sh` — 4개 epic issue 자동 생성
2. 이슈당 1 브랜치 1 PR 원칙
3. PR 머지 전 `docker compose --profile tools run --rm test` 필수
4. 완료 시 `docs/ROADMAP.md` 체크박스 업데이트
### 의도된 비목표 (Non-goals)
- **화자 구분** — 유료 API 비용 / 복잡도 대비 개인용 범위에 과함
- **공동 편집 (CRDT)** — 단일 사용자 가정
- **권한 관리 / 다중 사용자** — 1인 1인스턴스 모델
- **모바일 네이티브 앱** — 웹 PWA로 충분
---
## 알려진 제약
## 🔒 보안 모델
- **Chrome 전용**: Web Speech API 비표준 — Safari/Firefox는 제한적
- **화자 구분 불가**: 의도적 비지원 (개인용 범위를 넘음)
- **Gemini 무료 등급**: 15 RPM / 1000 RPD — 개인 사용에 충분하나 팀 단위는 유료 전환 권장
- **검색**: 현재 PostgreSQL `ILIKE`(contains) 방식. 수천 건 이상 저장 시 `tsvector` GIN 인덱스로 후속 업그레이드 예정
> 이 프로젝트는 **개인용 단일 인스턴스 사용을 가정**합니다. 회사 직원이 사용할 경우 각자 본인 머신에서 별도 인스턴스를 실행하는 방식을 권장합니다.
### 신뢰 모델
- 인증 시스템 없음 — `localhost:3000`에 접근하는 사용자는 모든 데이터에 접근 가능
- 같은 머신을 다른 사람과 공유하지 않는 것을 가정
- 회의 transcript / 회의록은 평문으로 PostgreSQL에 저장됨 (디스크 암호화는 호스트 OS에 위임)
### Gemini API 키
- LocalStorage 저장 (브라우저 동일 출처 정책으로 보호)
- 서버 DB에 저장되지 않음
- 요청 시점에만 body로 전송, 일회성 사용
- HTTPS 환경 권장 (HTTP는 localhost 한정)
### 외부 데이터 송출
- **Web Speech API** — Chrome이 마이크 오디오를 Google 서버로 전송하여 전사 (Chrome 자체 동작, 우리 서버 경유 X)
- **Gemini API** — 사용자가 명시적으로 활성화한 경우에만 transcript를 Google 서버로 전송
- **그 외** — 외부 호출 없음 (텔레메트리 / 분석 도구 미설치)
> ⚠️ **회사 회의 등 민감 정보가 외부 클라우드(Google)로 송출되는 점에 유의.** 사내 컴플라이언스 정책 확인 후 사용 권장.
### 권장 배포 방식
**1인 1인스턴스 (권장)**
- 직원 각자 본인 머신에 `docker compose up`
- 데이터는 각 머신에 격리됨
- 키 / 회의록 / 액션 아이템 모두 본인만 접근
**중앙 서버 배포는 권장하지 않음** — 인증 / 권한 / 격리 미구현
### 보안 체크리스트 (배포 전)
- [ ] `.env`에 강력한 `POSTGRES_PASSWORD` 설정
- [ ] `docker-compose.yml`의 Postgres 포트 노출 (`5432:5432`) 사용 환경에 맞게 검토 (외부 노출 불필요 시 제거)
- [ ] HTTPS reverse proxy 추가 (사외 접속 시)
- [ ] 방화벽 / VPN 뒤에서만 접근 가능하도록 구성
- [ ] 정기 백업 (`pg_dump`)
자세한 보안 검토 결과는 [`SECURITY.md`](SECURITY.md) 참고.
---
## ⚠️ 알려진 제약
| 제약 | 설명 | 대응 |
|---|---|---|
| **Chrome 전용** | Web Speech API는 비표준 — Safari / Firefox는 제한적 | Phase 4에서 서버 사이드 전사 검토 |
| **화자 구분 없음** | 의도적 비지원 (Non-goal) | — |
| **Gemini 무료 등급 한도** | 15 RPM / 1000 RPD | 한도 초과 시 자동 쿨다운, 단순 변환 폴백 |
| **단일 사용자** | 인증 없음, 데이터 격리 없음 | 1인 1인스턴스로 운용 |
| **마크다운 검색** | PostgreSQL `ILIKE` (수천 건 이상에서 느려질 수 있음) | 필요 시 `tsvector` 인덱스 추가 |
| **모바일 UX** | 데스크톱 우선 | 기본 동작은 가능, 폴리시 미흡 |
---
## 🤝 기여하기
이슈 / PR 환영합니다. 기여 전 다음을 확인해주세요:
1. [`docs/ROADMAP.md`](docs/ROADMAP.md)와 [Issues](https://github.com/nad4-su/meeting-minutes/issues)에서 중복 / 진행 중 항목 확인
2. **테스트 통과 필수**`docker compose --profile tools run --rm test`
3. **빌드 통과 필수**`docker compose build app`
4. 기능 추가 시 단위 테스트 동반 권장
### 개발 가이드
```bash
# 변경 후 검증
docker compose --profile tools run --rm test
docker compose build app
# DB 스키마 변경 시
# 1. prisma/schema.prisma 편집
# 2. 마이그레이션 생성
docker compose --profile tools run --rm \
--entrypoint "" migrate \
sh -c "npm ci && npx prisma migrate dev --name <변경_이름>"
```
### 커밋 컨벤션
- `feat:` 새 기능
- `fix:` 버그 수정
- `refactor:` 리팩터링
- `docs:` 문서
- `test:` 테스트
- `chore:` 빌드/설정
---
## 📄 라이선스
[MIT License](LICENSE) © 2026 nad4-su
+194
View File
@@ -0,0 +1,194 @@
# 🔒 Security
이 문서는 Meeting Minutes의 **위협 모델, 알려진 한계, 안전한 사용 방법**을 정리합니다. 보안 취약점을 발견했다면 [신고 방법](#-취약점-신고)을 참고해주세요.
---
## 📑 목차
- [위협 모델](#-위협-모델)
- [신뢰 모델](#-신뢰-모델)
- [데이터 흐름과 외부 송출](#-데이터-흐름과-외부-송출)
- [구현된 보안 조치](#-구현된-보안-조치)
- [알려진 한계](#-알려진-한계)
- [배포 권장 사항](#-배포-권장-사항)
- [취약점 신고](#-취약점-신고)
---
## 🎯 위협 모델
이 프로젝트는 **개인용 단일 인스턴스**를 가정합니다. 위협 모델은 다음과 같습니다:
### In-scope (방어 대상)
- 같은 LAN/Wifi에 있는 다른 사용자가 사용자의 노트북에 접근하는 시나리오
- 공유 PC에서 잠시 자리를 비웠을 때 다른 사람이 데이터에 접근하는 시나리오
- 마크다운에 삽입된 악성 HTML/스크립트로 인한 XSS
- 클라이언트가 보낸 비정상 입력 (큰 텍스트, 잘못된 MIME 등)
### Out-of-scope (방어하지 않음)
- **다중 사용자 / 인증** — 단일 사용자 가정으로 인증 시스템 미구현
- **호스트 OS / 디스크 암호화** — 운영체제와 디스크 암호화 설정에 위임
- **클라우드 환경 배포** — localhost 전용으로 설계됨
- **물리적 접근 통제** — 사용자 PC에 대한 물리적 보안
---
## 🔐 신뢰 모델
| 신뢰 수준 | 항목 |
|---|---|
| **신뢰** | 호스트 OS, Docker, 사용자가 발급한 Gemini API 키, npm 패키지 (`marked`, `DOMPurify`, Prisma 등) |
| **반신뢰** | 사용자 입력 transcript / markdown (DOMPurify로 sanitize) |
| **신뢰 안 함** | 같은 네트워크의 다른 장치, 외부 HTTP 요청, Web Speech API 결과 |
---
## 📡 데이터 흐름과 외부 송출
| 데이터 | 저장 위치 | 외부 송출 |
|---|---|---|
| 회의 transcript / 회의록 | PostgreSQL (`meetings.markdownMinutes`, `rawTranscript`) | Gemini AI 요약 활성화 시 transcript가 Google로 전송 |
| 액션 아이템 | PostgreSQL (`action_items`) | 외부 송출 없음 |
| Gemini API 키 | 브라우저 LocalStorage 또는 `.env` | 요청 시 Google에만 전송 |
| 음성 데이터 | 메모리 (실시간), `/app/uploads` (파일 업로드) | **Chrome Web Speech API → Google 서버** ⚠️ |
### ⚠️ 주의: Web Speech API의 음성 외부 전송
Chrome의 `SpeechRecognition` API는 **음성 데이터를 Google 서버로 전송하여 처리**합니다. 이는 Chrome 자체의 동작이며 우리 서버를 경유하지 않습니다. 다음의 경우 사용을 재고하세요:
- 회의가 회사 기밀, 인사 정보, 환자 정보 등을 포함
- 사내 컴플라이언스 정책이 외부 클라우드 음성 전송을 금지
- GDPR / HIPAA 등 규제 환경
이 경우 **파일 업로드 탭**도 같은 한계가 있으므로(현재 업로드 후 전사는 미구현, Phase 4에서 자체 STT 검토 예정), 이 도구의 사용을 보류하는 것을 권장합니다.
---
## ✅ 구현된 보안 조치
### 인프라
- **Postgres 포트 미노출** — `db` 컨테이너는 `app`만 내부 네트워크로 접근, 호스트 포트 안 열림
- **App 포트 localhost 바인딩** — `127.0.0.1:3000` (LAN의 다른 장치는 접근 불가)
- **DB 비밀번호 fallback 제거** — `.env`에서 명시적으로 설정해야 docker-compose 시작됨
- **Prisma adapter 사용** — Prisma 7 `@prisma/adapter-pg`로 안전한 PostgreSQL 연결
### 응답 헤더
`next.config.ts`에서 다음 헤더 적용:
- `X-Content-Type-Options: nosniff` — MIME sniffing 차단
- `X-Frame-Options: DENY` — 클릭재킹 방지
- `Referrer-Policy: strict-origin-when-cross-origin`
- `Permissions-Policy: camera=(), microphone=(self), geolocation=(), interest-cohort=()`
### 입력 검증
- **마크다운**: `marked``isomorphic-dompurify` 통과
- `USE_PROFILES: { html: true }`
- `FORBID_TAGS: ['style']`, `FORBID_ATTR: ['style']`
- `<script>`, `onerror=`, `javascript:` URL 등 제거
- **/api/summarize**: transcript 100,000자 상한, 초과 시 413
- **/api/summarize-live**: transcript 40,000자 상한 (뒤쪽만 사용)
- **파일 업로드**: 500MB 상한, MIME allowlist (단, [한계](#-알려진-한계) 참고)
### API 키 보호
- `/api/settings/status`: 키 존재 여부만 응답 (값 노출 X)
- `/api/settings/test`: 401/403/429 분류, detail 200자로 제한
- `getStoredApiKey` GET 요청에 절대 포함 안 함 (body 전송만)
- `maskApiKey()`: UI에 표시 시 `AIza••••XYZ12` 형태로 마스킹
---
## ⚠️ 알려진 한계
### MEDIUM — 인증 부재
**현황**: `/api/*` 엔드포인트에 인증이 없습니다. `127.0.0.1:3000` 바인딩으로 LAN 노출은 차단되지만, 같은 머신의 다른 사용자나 잠금 해제된 PC에서는 접근 가능합니다.
**완화**:
- 1인 1머신 + 잠금 해제 시 PC를 떠나지 않음
- 추가 보호가 필요하면 reverse proxy(nginx, Caddy)에 Basic Auth 추가
- 회사 환경에서는 SSO 통합이 필요하지만 현재 비목표
### MEDIUM — 파일 업로드 매직 바이트 미검증
**현황**: `audio-validation.ts``file.type`(클라이언트 제공)과 파일명 확장자만 검사. 매직 바이트 검증 없음.
**완화**: 업로드된 파일은 현재 어디에도 서빙되지 않으며 Phase 4까지 활용되지 않음. 디스크에 저장만 됨.
### MEDIUM — 서버 측 레이트 리미팅 부재
**현황**: 클라이언트 측에 백오프/쿨다운은 있으나 서버는 IP당 요청 수 제한 없음.
**완화**:
- localhost 바인딩으로 외부 abuse 차단
- Gemini 호출은 사용자 본인 키로 본인 quota 소진 → 본인 비용
### LOW — npm audit moderate 6건
**현황**:
- `@hono/node-server`, `hono`, `postcss` (모두 dev 의존성)
- 프로덕션 번들 미포함, 실제 공격 가능성 낮음
**완화**: 정기적으로 `npm audit fix` 실행
### LOW — LocalStorage 키 노출
**현황**: 브라우저 개발자 도구로 `localStorage.getItem('meeting-minutes:gemini-api-key')` 실행 시 키 노출.
**완화**: 동일 출처 정책으로 다른 사이트에서 접근 불가. 사용 후 🗑️ 삭제 버튼으로 즉시 제거.
---
## 🚀 배포 권장 사항
### ✅ 권장 (개인용)
```bash
# 강력한 비밀번호로 .env 작성
echo "POSTGRES_PASSWORD=$(openssl rand -base64 24)" >> .env
# 로컬 머신에서만 접근
docker compose up -d
# → 127.0.0.1:3000 (LAN 노출 X)
```
### ⚠️ 비권장 (현재 미구현 보안 통제 필요)
- 클라우드 인스턴스(EC2, GCP 등) 배포
- 회사 LAN의 공용 서버 배포
- HTTPS 없이 외부 도메인에 노출
### 🔧 추가 보호 (선택)
**Reverse proxy + Basic Auth** (예: Caddy)
```caddyfile
meeting.example.com {
basic_auth {
user $2a$14$... # bcrypt 해시
}
reverse_proxy 127.0.0.1:3000
}
```
**VPN 뒤에서만 접근**: 회사 VPN/Tailscale 등 + 호스트 IP 제한
**디스크 암호화**: macOS FileVault, Linux LUKS, Windows BitLocker
---
## 🚨 취약점 신고
보안 취약점을 발견하면:
1. **공개 이슈 X** — GitHub Issues에 공개로 올리지 마세요
2. 메인테이너에게 직접 연락 또는 [GitHub Security Advisory](https://github.com/nad4-su/meeting-minutes/security/advisories) 사용
3. 다음을 포함:
- 영향받는 버전 / 컴포넌트
- 재현 방법
- 영향 범위
- 가능한 완화책
응답 시간 목표: **72시간 이내 1차 응답**.
---
## 📚 참고
- [위협 모델 OWASP](https://owasp.org/www-community/Threat_Modeling)
- [DOMPurify 보안 가이드](https://github.com/cure53/DOMPurify/wiki)
- [Next.js 보안 헤더](https://nextjs.org/docs/app/api-reference/next-config-js/headers)
---
**최종 업데이트**: 2026-04-28
+12 -9
View File
@@ -3,15 +3,17 @@ services:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-meetinguser}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-meetingpass}
POSTGRES_DB: ${POSTGRES_DB:-meetingminutes}
POSTGRES_USER: ${POSTGRES_USER:?POSTGRES_USER must be set in .env}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set in .env}
POSTGRES_DB: ${POSTGRES_DB:?POSTGRES_DB must be set in .env}
volumes:
- pgdata:/var/lib/postgresql/data
ports:
- "5432:5432"
# 호스트 포트 노출 안 함 — app 컨테이너만 내부 네트워크로 접근.
# 외부 DB 클라이언트(예: psql)가 필요하면 아래 주석 해제 (LAN 노출은 위험):
# ports:
# - "127.0.0.1:5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-meetinguser}"]
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER}"]
interval: 5s
timeout: 5s
retries: 5
@@ -21,7 +23,7 @@ services:
image: node:22-alpine
working_dir: /app
environment:
DATABASE_URL: postgresql://${POSTGRES_USER:-meetinguser}:${POSTGRES_PASSWORD:-meetingpass}@db:5432/${POSTGRES_DB:-meetingminutes}
DATABASE_URL: postgresql://${POSTGRES_USER:?required}:${POSTGRES_PASSWORD:?required}@db:5432/${POSTGRES_DB:?required}
volumes:
- .:/app
- migrate_node_modules:/app/node_modules
@@ -36,9 +38,10 @@ services:
dockerfile: Dockerfile
restart: unless-stopped
ports:
- "3000:3000"
# 127.0.0.1 바인딩 — 같은 머신에서만 접근 가능 (LAN 노출 방지)
- "127.0.0.1:3000:3000"
environment:
DATABASE_URL: postgresql://${POSTGRES_USER:-meetinguser}:${POSTGRES_PASSWORD:-meetingpass}@db:5432/${POSTGRES_DB:-meetingminutes}
DATABASE_URL: postgresql://${POSTGRES_USER:?required}:${POSTGRES_PASSWORD:?required}@db:5432/${POSTGRES_DB:?required}
GEMINI_API_KEY: ${GEMINI_API_KEY:-}
volumes:
- uploads:/app/uploads
+114
View File
@@ -0,0 +1,114 @@
# 🎙️ 5분만에 시작하기
> 회의 녹음 한 번으로 회의록까지 자동 작성하는 개인용 도구입니다. **본인 노트북에서 직접 실행**하며, 데이터는 본인 머신에만 저장됩니다.
## 사전 준비 (1분)
- [Docker Desktop](https://docs.docker.com/get-docker/) 설치
- Chrome 브라우저 (Web Speech API 필요)
- (선택) Gemini API 키 — 없어도 단순 변환은 동작
## 설치 (3분)
```bash
# 1. 코드 받기
git clone https://github.com/nad4-su/meeting-minutes.git
cd meeting-minutes
# 2. 환경 설정 — .env 파일 생성하고 비밀번호만 채우면 끝
cp .env.example .env
cat <<EOF >> .env
# 자동 생성된 강력한 비밀번호
EOF
PASS=$(openssl rand -base64 24 | tr -d '=+/' | head -c 24)
echo "POSTGRES_PASSWORD=$PASS" >> .env
echo "DATABASE_URL=postgresql://meetinguser:$PASS@localhost:5432/meetingminutes" >> .env
# 3. DB 초기화 + 앱 기동
docker compose --profile tools run --rm migrate
docker compose up -d
```
브라우저 → http://127.0.0.1:3000
## Gemini AI 요약 켜기 (1분, 선택)
1. https://aistudio.google.com/apikey 접속 (Google 계정 필요)
2. **Create API Key** 클릭 → 키 복사
3. 앱 우상단 **⚙️ 설정** → 키 붙여넣기 → 💾 저장 → 🧪 테스트 호출
> 🔒 키는 **본인 브라우저에만** 저장됩니다 (서버 DB / 다른 사람과 공유 X). 무료 등급 한도는 개인 사용에 충분합니다 (분당 15건, 일일 1000건).
## 기본 사용법
| 화면 | 단축 액션 |
|---|---|
| `/` (홈) | 🎤 녹음 시작 → 자동 텍스트화 → 자동 요약 → 📌 저장 |
| `/meetings` | 저장된 회의록 목록, 검색, 미완료 액션 위젯 |
| `/meetings/[id]` | ✅ 액션 체크 토글, ✏️ 편집, 📋 Docs용 복사, 🗑️ 삭제 |
| `/settings` | API 키 관리 |
### 추천 흐름
1. 홈에서 제목 입력 (선택)
2. 템플릿 선택 — 회의록 / 강의 / 1:1 / 브레인스토밍 / 인터뷰 / 원문 정리
3. **🎤 녹음 시작** → 발화 → 좌측에 텍스트 쌓이고 우측에 30초마다 중간 회의록 갱신
4. **녹음 중지** → 최종 회의록 자동 생성
5. **📌 저장** → 액션 아이템 자동 추출되어 체크리스트 등장
6. 필요하면 **📋 Docs용** 버튼으로 Google Docs에 서식 그대로 붙여넣기
## ⚠️ 사용 전 알아둘 것 — 개인정보 / 보안
### 외부로 전송되는 데이터
- **음성** → Chrome Web Speech API → **Google 서버** (Chrome 자체 동작)
- **transcript** → Gemini AI 요약 활성화 시 → **Google 서버**
- 데이터는 본인 머신과 Google 외에는 어디에도 가지 않음
### 사내 정책 확인 필요
다음 내용이 회의에 포함되면 **사용 보류 또는 단순 변환 모드만 사용**:
- 미공개 사업 정보 / 전략
- 인사 / 평가 정보
- 고객 개인정보
- 보안 / 컴플라이언스 데이터
### 단순 변환 모드 (오프라인 안전)
- API 키 없이도 동작 — 변환 모드에서 **`단순 변환`** 선택
- transcript를 마크다운 형식으로 정리만 함, 외부 호출 없음
- 단, **녹음 자체는 Chrome이 Google로 보냄** — 완전 오프라인이 필요하면 이 도구 사용 안 권장
## 자주 쓰는 명령
```bash
# 로그 보기
docker compose logs -f app
# 잠깐 끄기 (데이터 유지)
docker compose down
# 다시 켜기
docker compose up -d
# 코드 업데이트 받기
git pull
docker compose --profile tools run --rm migrate # 마이그레이션 있을 시
docker compose up -d --build
# 데이터 백업
docker compose exec db pg_dump -U meetinguser meetingminutes > backup-$(date +%Y%m%d).sql
# 완전 삭제 (회의록 모두 잃음)
docker compose down -v
```
## 도움 / 피드백
- 👤 메인테이너: [@nad4-su](https://github.com/nad4-su)
- 🐛 이슈 / 기능 제안: [GitHub Issues](https://github.com/nad4-su/meeting-minutes/issues)
- 📋 **실사용 피드백 (적극 환영)**: [Issue #10](https://github.com/nad4-su/meeting-minutes/issues/10)
- 📖 자세한 보안 모델: [SECURITY.md](../SECURITY.md)
- 🗺️ 로드맵: [docs/ROADMAP.md](ROADMAP.md)
---
**한 줄 요약**: 본인 노트북에서 `docker compose up`만 하면 시작. 회의 데이터는 본인 머신에만 저장. 민감 회의는 사내 정책 먼저 확인.
+25
View File
@@ -30,6 +30,31 @@
---
## ✅ 보안 강화 — 공개 저장소 전환 준비 (완료)
회사 직원이 각자 로컬에서 사용할 수 있도록 보안 검토 및 강화 작업.
### 구현됨
- [x] **C-1 대응**: 발급된 Gemini 키 `.env`에서 제거하도록 사용자 안내 (Google AI Studio 폐기 + .env 정리)
- [x] **C-2 대응**: docker-compose의 DB 비밀번호 fallback 제거 → `:?` 강제 환경변수
- [x] **C-3 대응**: Postgres 포트 `5432` 호스트 노출 제거 (컨테이너 내부 네트워크 전용)
- [x] **H-1 대응**: App 포트 `127.0.0.1:3000`로 바인딩 (LAN 노출 차단)
- [x] **H-2 대응**: `/api/summarize`에 transcript 100,000자 상한 추가
- [x] **H-4 대응**: `next.config.ts`에 보안 응답 헤더 4종 적용
- [x] **H-5 대응**: `/settings` 페이지에 공유 PC 경고 + Web Speech 외부 송출 안내 추가
- [x] **H-6 대응**: `db.ts`에서 `DATABASE_URL` 누락 시 명시적 경고 로그
- [x] **M-3 대응**: DOMPurify에 `FORBID_TAGS: ['style']`, `FORBID_ATTR: ['style']` 추가
- [x] **문서화**: `SECURITY.md` 작성 (위협 모델, 신뢰 모델, 한계, 신고 방법)
- [x] **MIT LICENSE** 추가
### 확인됨 (현재 비목표)
- M-1 (Web Speech → Google) — 구조적 한계, 문서화로 처리
- M-2 (npm audit moderate 6건) — dev 의존성, 자동 수정 가능한 것 0건
- H-3 (파일 업로드 매직 바이트) — 업로드된 파일 미사용/미서빙으로 위험 낮음
- 인증 시스템 — 단일 사용자 가정 (의도된 비목표)
---
## ✅ Phase 0.5 — 템플릿 + 강도 조절 (완료)
사용자가 회의록 외 다양한 용도로 쓸 수 있도록 템플릿 시스템과 강도 조절 도입.
+18
View File
@@ -1,7 +1,25 @@
import type { NextConfig } from "next";
const securityHeaders = [
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "X-Frame-Options", value: "DENY" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{
key: "Permissions-Policy",
value: "camera=(), microphone=(self), geolocation=(), interest-cohort=()",
},
];
const nextConfig: NextConfig = {
output: "standalone",
async headers() {
return [
{
source: "/(.*)",
headers: securityHeaders,
},
];
},
};
export default nextConfig;
+11
View File
@@ -6,6 +6,8 @@ import {
import { resolveGeminiApiKey } from '@/lib/api-keys'
import type { SummaryDepth, TemplateId } from '@/lib/templates'
const MAX_TRANSCRIPT_CHARS = 100_000
export async function POST(request: NextRequest) {
try {
const body = await request.json()
@@ -27,6 +29,15 @@ export async function POST(request: NextRequest) {
)
}
if (transcript.length > MAX_TRANSCRIPT_CHARS) {
return Response.json(
{
error: `텍스트가 너무 깁니다 (최대 ${MAX_TRANSCRIPT_CHARS.toLocaleString()}자).`,
},
{ status: 413 },
)
}
const input = {
title: title || '무제 회의',
transcript,
+6
View File
@@ -259,6 +259,12 @@ export default function SettingsPage() {
<li> / .</li>
<li>릿 , .</li>
<li>HTTPS ( localhost는 ).</li>
<li className="text-amber-700">
<strong> PC에서는 🗑 .</strong> LocalStorage를 .
</li>
<li className="text-amber-700">
<strong> </strong>, Google (Chrome Web Speech API ). .
</li>
</ul>
</section>
</div>
+17 -4
View File
@@ -5,11 +5,24 @@ const globalForPrisma = globalThis as unknown as {
prisma?: PrismaClient
}
const BUILD_PLACEHOLDER =
'postgresql://placeholder:placeholder@localhost:5432/placeholder'
function createPrismaClient(): PrismaClient {
const connectionString =
process.env.DATABASE_URL ??
'postgresql://placeholder:placeholder@localhost:5432/placeholder'
const adapter = new PrismaPg({ connectionString })
const connectionString = process.env.DATABASE_URL
if (!connectionString && process.env.NODE_ENV === 'production') {
console.warn(
'[db] DATABASE_URL이 설정되지 않았습니다. ' +
'docker-compose 환경변수 또는 .env 파일을 확인하세요. ' +
'실제 쿼리는 실패합니다.',
)
}
const adapter = new PrismaPg({
connectionString: connectionString ?? BUILD_PLACEHOLDER,
})
return new PrismaClient({
adapter,
log: process.env.NODE_ENV === 'development' ? ['warn', 'error'] : ['error'],
+2
View File
@@ -11,6 +11,8 @@ export function renderMarkdownToSafeHtml(markdown: string): string {
const rawHtml = marked.parse(markdown) as string
return DOMPurify.sanitize(rawHtml, {
USE_PROFILES: { html: true },
FORBID_TAGS: ['style'],
FORBID_ATTR: ['style'],
})
}