diff --git a/.gitignore b/.gitignore index 7d58449..715819f 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,8 @@ functions/dist npm-debug.log* .env +!.env.example # firebase -.firebase/ \ No newline at end of file +.firebase/ +firebase-debug.log* \ No newline at end of file diff --git a/README.md b/README.md index 39b42f4..5312272 100644 --- a/README.md +++ b/README.md @@ -1,96 +1,137 @@ # Today I Learned Alarm -GitHub 커밋 데이터를 기반으로 학습용 플래시카드를 생성하는 PWA 애플리케이션입니다. +매일 학습한 내용을 정리하고 알림을 받는 앱입니다. -## 🏗️ 프로젝트 구조 +## 🚀 빠른 시작 +### 1. 환경 설정 +```bash +# Firebase 프로젝트 정보를 사용해 app/.env에 Functions 프록시 주소 생성/추가 +pnpm env:setup + +# (옵션) 수동 설정 시 app/.env에 다음 키들을 추가하세요 +# Firebase Web 설정 (Console > 프로젝트 설정 > 일반 > 웹 앱 구성에서 복사) +VITE_API_KEY=... +VITE_AUTH_DOMAIN=... +VITE_PROJECT_ID=... +VITE_STORAGE_BUCKET=... +VITE_MESSAGING_SENDER_ID=... +VITE_APP_ID=... +VITE_MEASUREMENT_ID=... + +# Functions 호출용 (env:setup가 자동 추가) +VITE_FIREBASE_PROJECT_ID=your-project-id +VITE_FIREBASE_REGION=us-central1 +VITE_FUNCTIONS_URL_LOCAL=http://localhost:5001/your-project-id/us-central1 +VITE_FUNCTIONS_URL_PROD=https://us-central1-your-project-id.cloudfunctions.net ``` -repo/ -├── app/ # React + Vite + TypeScript (PWA) -├── functions/ # Cloud Functions (TypeScript + tsup) -├── package.json # 워크스페이스 루트 -├── pnpm-workspace.yaml -└── firebase.json -``` - -## 🚀 기술 스택 - -- **패키지 매니저**: pnpm (워크스페이스 지원) -- **프론트엔드**: Vite + React + TypeScript + PWA -- **백엔드**: Firebase Cloud Functions (TypeScript + tsup) -- **데이터베이스**: IndexedDB (클라이언트) -- **배포**: Firebase Hosting + Functions -- **스케줄링**: Firebase Functions v2 onSchedule -## 📦 설치 및 실행 - -### 사전 요구사항 -- Node.js 20+ -- pnpm 9+ -- Firebase CLI - -### 설치 +### 2. 개발 서버 시작 ```bash -# 의존성 설치 -pnpm install - -# 개발 서버 실행 pnpm dev +``` -# 빌드 -pnpm build +### 3. Firebase Functions 설정 +```bash +cd functions -# 배포 -pnpm deploy +# 환경변수 설정 (로컬 개발용) +echo "GITHUB_TOKEN=your_github_token_here" > .env + +# Functions 실행 +pnpm serve ``` -### 환경 변수 설정 -`app/env.example`을 참고하여 `.env` 파일을 생성하세요: +## 📁 프로젝트 구조 -```bash -cp app/env.example app/.env +``` +├── app/ # React 앱 (프론트엔드) +│ ├── src/ +│ │ ├── api/ # API 호출 함수들 +│ │ ├── modules/ # 유틸리티 (axios 등) +│ │ └── pages/ # 페이지 컴포넌트들 +│ └── vite.config.ts # Vite 설정 (프록시 포함) +├── functions/ # Firebase Functions (백엔드) +│ ├── src/ +│ │ ├── github.ts # GitHub API Functions +│ │ ├── schedule.ts # 스케줄러 Functions +│ │ └── hypercloax.ts # Hypercloax API Functions +│ └── package.json +└── scripts/ + └── setup-proxy.js # app/.env에 Functions URL 자동 추가/보강 스크립트 ``` -## 🔧 개발 +## 🔧 환경변수 -### 웹 앱 개발 +### 앱 환경변수 (app/.env) ```bash -cd app -pnpm dev +# Firebase Web 설정 (콘솔에서 복사) +VITE_API_KEY=... +VITE_AUTH_DOMAIN=... +VITE_PROJECT_ID=... +VITE_STORAGE_BUCKET=... +VITE_MESSAGING_SENDER_ID=... +VITE_APP_ID=... +VITE_MEASUREMENT_ID=... + +# Functions 호출 설정 (env:setup 실행 시 자동 추가/보강) +VITE_FIREBASE_PROJECT_ID=til-alarm +VITE_FIREBASE_REGION=us-central1 +VITE_FUNCTIONS_URL_LOCAL=http://localhost:5001/til-alarm/us-central1 +VITE_FUNCTIONS_URL_PROD=https://us-central1-til-alarm.cloudfunctions.net ``` -### Functions 개발 +### Functions 환경변수 (functions/.env) ```bash -cd functions -pnpm serve # 에뮬레이터 실행 +GITHUB_TOKEN=your_github_token_here +CLOVA_API_KEY=your_clova_api_key +NCLOUD_API_KEY=your_ncloud_api_key ``` -## 📱 PWA 기능 +## 🚀 배포 -- 오프라인 지원 -- 웹 푸시 알림 -- 설치 가능한 앱 -- 백그라운드 동기화 +### Functions 개별 배포 +```bash +cd functions -## 🔔 알림 기능 +# GitHub API만 배포 +pnpm deploy:github -- 매일 오전 8시(KST) 자동 알림 -- Firebase Cloud Messaging 사용 -- 토픽 기반 브로드캐스트 +# Schedule만 배포 +pnpm deploy:schedule -## 🚀 배포 +# Hypercloax만 배포 +pnpm deploy:hypercloax -### Firebase 설정 +# 전체 배포 +pnpm deploy +``` + +### 앱 배포 ```bash -firebase login -firebase init hosting functions +# 루트에서 전체 배포 +pnpm deploy ``` -### CI/CD -GitHub Actions를 통한 자동 배포: -- `main` 브랜치 푸시 시 자동 배포 -- Firebase Hosting + Functions 동시 배포 +## 🔄 API 구조 + +### GitHub API +- `GET /api/getCommits?since={date}&until={date}` - 커밋 목록 +- `GET /api/getFilename?commit_sha={sha}` - 커밋 상세 +- `GET /api/getMarkdown?filename={filename}` - 마크다운 내용 + +### Hypercloax API +- `POST /api/chatCompletions` - CLOVA Studio 질문 생성 +- `POST /api/registerDeviceToken` - FCM 토큰 등록 +- `POST /api/removeDeviceToken` - FCM 토큰 삭제 +- `POST /api/registerSchedule` - 스케줄 등록 + +### Schedule +- 자동 실행 (매일 오전 8시 KST) -## 📝 라이선스 +## 🛠️ 개발 도구 -MIT License \ No newline at end of file +- **프론트엔드**: React + TypeScript + Vite +- **백엔드**: Firebase Functions + TypeScript +- **API 통신**: Axios +- **배포**: Firebase Hosting + Functions \ No newline at end of file diff --git a/app/env.example b/app/.env.example similarity index 69% rename from app/env.example rename to app/.env.example index 0d8cae5..d455f28 100644 --- a/app/env.example +++ b/app/.env.example @@ -1,4 +1,4 @@ -# Firebase 설정 +# Firebase auth VITE_API_KEY=your_firebase_api_key_here VITE_AUTH_DOMAIN=your_project.firebaseapp.com VITE_PROJECT_ID=your_project_id @@ -8,9 +8,12 @@ VITE_APP_ID=your_app_id VITE_MEASUREMENT_ID=your_measurement_id VITE_VAPID_KEY=your_vapid_key_here +# Firebase Functions (pnpm proxy 실행시 자동 생성) +VITE_FIREBASE_PROJECT_ID= +VITE_FIREBASE_REGION= +VITE_FUNCTIONS_URL_LOCAL= +VITE_FUNCTIONS_URL_PROD= + # 사용자 설정 VITE_USER_ID=your_user_id_here VITE_SCHEDULE_CODE=your_schedule_code_here - -# Naver Cloud Platform -VITE_NCLOUD_HYPERCLOVAX_URL=your_ncloud_hyperclovax_url_here diff --git a/app/package.json b/app/package.json index 6c3c2ad..bc721de 100644 --- a/app/package.json +++ b/app/package.json @@ -8,6 +8,7 @@ "build": "tsc && vite build" }, "dependencies": { + "axios": "^1.12.2", "firebase": "^10.4.0", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/app/src/App.tsx b/app/src/App.tsx index 6ed4c95..4c50458 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -8,16 +8,20 @@ import { chatCompletions } from './api/ncloud-api'; import { getCurrentDate } from './modules/utils'; import FlashCardViewer from './pages/FlashCardViewer'; import Login from './pages/Login'; +import Settings from './pages/Settings'; import { getGithubData } from './services/github-service'; initDB(DBConfig); const dates = [1, 7, 30]; // days ago list +type Page = 'flashcard' | 'settings'; + const App: React.FC = () => { const { add, getByID } = useIndexedDB("data"); const [loading, setLoading] = useState(true); const [user, setUser] = useState(null); const [authLoading, setAuthLoading] = useState(true); + const [currentPage, setCurrentPage] = useState('flashcard'); // 인증 상태 감지 useEffect(() => { @@ -104,7 +108,64 @@ const App: React.FC = () => { // 메인 앱 렌더링 return (
- + {/* 네비게이션 */} + + + {/* 페이지 컨텐츠 */} +
+ {currentPage === 'flashcard' && } + {currentPage === 'settings' && } +
); }; diff --git a/app/src/api/github-api.ts b/app/src/api/github-api.ts index 9017549..60ec260 100644 --- a/app/src/api/github-api.ts +++ b/app/src/api/github-api.ts @@ -1,3 +1,5 @@ +import { apiClient } from '../modules/axios'; + interface Commit { sha: string; commit: { @@ -14,29 +16,34 @@ interface CommitDetail { }>; } +interface MarkdownResponse { + content: string; +} + export async function getCommits(since: Date, until: Date): Promise { const sinceISO = since.toISOString(); const untilISO = until.toISOString(); - const response = await fetch(`https://api.github.com/repos/hssuh/TIL/commits?since=${sinceISO}&until=${untilISO}`); - if (!response.ok) { - throw new Error(`GitHub API error: ${response.status}`); - } - return await response.json(); + const response = await apiClient.get('/getCommits', { + params: { since: sinceISO, until: untilISO } + }); + + return response.data; } export async function getFilename(sha: string): Promise { - const response = await fetch(`https://api.github.com/repos/hssuh/TIL/commits/${sha}`); - if (!response.ok) { - throw new Error(`GitHub API error: ${response.status}`); - } - return await response.json(); + const response = await apiClient.get('/getFilename', { + params: { commit_sha: sha } + }); + + return response.data; } export async function getMarkdown(filename: string): Promise { - const response = await fetch(`https://raw.githubusercontent.com/hssuh/TIL/main/${filename}`); - if (!response.ok) { - throw new Error(`GitHub API error: ${response.status}`); - } - return await response.text(); + const response = await apiClient.get('/getMarkdown', { + params: { filename } + }); + + const data: MarkdownResponse = response.data; + return data.content; } diff --git a/app/src/api/ncloud-api.ts b/app/src/api/ncloud-api.ts index e3987ad..c876080 100644 --- a/app/src/api/ncloud-api.ts +++ b/app/src/api/ncloud-api.ts @@ -1,3 +1,5 @@ +import { apiClient } from '../modules/axios'; + interface ChatCompletionResponse { body: { result: { @@ -9,43 +11,32 @@ interface ChatCompletionResponse { } /** - * CLOVA Studio + * CLOVA Studio - Firebase Functions를 통해 호출 */ export async function chatCompletions(text: string): Promise { try { - const option = { - method: "POST", - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ + const response = await apiClient.post('/chatCompletions', { prompt: "마크다운 파일을 읽고 질문을 만들어주세요. 질문은 다음과 같은 형식으로 출력해주세요. [\"첫 번째 질문\", \"두 번째 질문\", ...]", text: text, - }), - }; - - const response = await fetch("/question-generator/v1/json", option); + }); - if (!response.ok) { - throw new Error("Network response was not ok"); - } - - return await response.json(); + return response.data; } catch (err) { - console.error("Error during fetch:", err); + console.error("Error during API call:", err); throw err; } } /** * Firebase Cloud Messaging 관련 API - * TODO: 실제 API 엔드포인트로 교체 필요 */ export async function registerDeviceToken(userId: string, token: string): Promise { try { - // 실제 API 호출로 교체 필요 - console.log('Registering device token:', { userId, token }); - return true; + const response = await apiClient.post('/registerDeviceToken', { + userId, + token + }); + return response.data.success; } catch (error) { console.error('Error registering device token:', error); return false; @@ -54,18 +45,22 @@ export async function registerDeviceToken(userId: string, token: string): Promis export async function removeDeviceToken(userId: string): Promise { try { - // 실제 API 호출로 교체 필요 - console.log('Removing device token for user:', userId); + await apiClient.post('/removeDeviceToken', { + userId + }); } catch (error) { console.error('Error removing device token:', error); + throw error; } } export async function registerSchedule(scheduleCode: string): Promise { try { - // 실제 API 호출로 교체 필요 - console.log('Registering schedule:', scheduleCode); + await apiClient.post('/registerSchedule', { + scheduleCode + }); } catch (error) { console.error('Error registering schedule:', error); + throw error; } } \ No newline at end of file diff --git a/app/src/firebase.ts b/app/src/firebase.ts index 3cd4e51..ef6bf76 100644 --- a/app/src/firebase.ts +++ b/app/src/firebase.ts @@ -1,5 +1,6 @@ import { initializeApp } from 'firebase/app'; import { getAuth, GithubAuthProvider } from 'firebase/auth'; +import { getFirestore, connectFirestoreEmulator } from 'firebase/firestore'; const firebaseConfig = { apiKey: import.meta.env.VITE_API_KEY, @@ -17,9 +18,23 @@ export const app = initializeApp(firebaseConfig); // Auth 인스턴스 생성 export const auth = getAuth(app); +// Firestore 인스턴스 생성 +export const db = getFirestore(app); + +// Firestore 에뮬레이터는 Java 필요 → 실제 DB 사용이 더 간단 +if (import.meta.env.DEV && import.meta.env.VITE_USE_EMULATOR === 'true') { + console.log('🔧 Firestore 에뮬레이터 모드'); + try { + connectFirestoreEmulator(db, 'localhost', 8080); + } catch (error) { + console.warn('Firestore 에뮬레이터 연결 실패:', error); + } +} + // GitHub 프로바이더 생성 export const githubProvider = new GithubAuthProvider(); // 스코프 설정 (필요한 GitHub 권한) githubProvider.addScope('user:email'); githubProvider.addScope('read:user'); +githubProvider.addScope('repo'); // 리포지토리 읽기 권한 diff --git a/app/src/modules/axios.ts b/app/src/modules/axios.ts new file mode 100644 index 0000000..e259987 --- /dev/null +++ b/app/src/modules/axios.ts @@ -0,0 +1,50 @@ +import axios from 'axios'; +import { auth } from '../firebase'; + +// Firebase Functions URL 설정 +const FUNCTIONS_URL = import.meta.env.PROD + ? import.meta.env.VITE_FUNCTIONS_URL_PROD + : '/api'; // Vite 프록시 사용 + +// 기본 axios 인스턴스 생성 +export const apiClient = axios.create({ + baseURL: FUNCTIONS_URL, + timeout: 10000, + headers: { + 'Content-Type': 'application/json', + }, +}); + +// 요청 인터셉터 - Firebase ID Token을 헤더에 추가 +apiClient.interceptors.request.use( + async (config) => { + // Firebase Auth ID Token 가져오기 + const user = auth.currentUser; + + if (user) { + try { + const idToken = await user.getIdToken(); + config.headers['Authorization'] = `Bearer ${idToken}`; + } catch (error) { + console.error('Firebase ID Token 가져오기 실패:', error); + } + } + + return config; + }, + (error) => { + console.error('API 요청 오류:', error); + return Promise.reject(error); + } +); + +// 응답 인터셉터 +apiClient.interceptors.response.use( + (response) => response, + (error) => { + console.error('API 응답 오류:', error.response?.data || error.message); + return Promise.reject(error); + } +); + +export default apiClient; diff --git a/app/src/pages/Login.tsx b/app/src/pages/Login.tsx index 873a260..2c69583 100644 --- a/app/src/pages/Login.tsx +++ b/app/src/pages/Login.tsx @@ -1,6 +1,7 @@ import React, { useState } from 'react'; -import { signInWithPopup, signOut, onAuthStateChanged, User } from 'firebase/auth'; -import { auth, githubProvider } from '../firebase'; +import { signInWithPopup, signOut, onAuthStateChanged, User, GithubAuthProvider } from 'firebase/auth'; +import { doc, setDoc, deleteDoc } from 'firebase/firestore'; +import { auth, githubProvider, db } from '../firebase'; import './Login.css'; const Login: React.FC = () => { @@ -24,6 +25,18 @@ const Login: React.FC = () => { setLoading(true); setError(''); const result = await signInWithPopup(auth, githubProvider); + + // GitHub OAuth 토큰을 Firestore에 저장 + // Google의 at-rest encryption으로 자동 암호화됨 + const credential = GithubAuthProvider.credentialFromResult(result); + if (credential && credential.accessToken && result.user) { + await setDoc(doc(db, 'users', result.user.uid), { + githubToken: credential.accessToken, + updatedAt: new Date().toISOString(), + }); + console.log('로그인 성공 및 GitHub 토큰 저장 완료'); + } + console.log('로그인 성공:', result.user); } catch (error: any) { console.error('로그인 실패:', error); @@ -36,7 +49,11 @@ const Login: React.FC = () => { // 로그아웃 함수 const handleLogout = async () => { try { + const currentUser = auth.currentUser; await signOut(auth); + if (currentUser) { + await deleteDoc(doc(db, 'users', currentUser.uid)); + } console.log('로그아웃 성공'); } catch (error) { console.error('로그아웃 실패:', error); diff --git a/app/src/pages/Settings.css b/app/src/pages/Settings.css new file mode 100644 index 0000000..f8edac2 --- /dev/null +++ b/app/src/pages/Settings.css @@ -0,0 +1,188 @@ +.settings-container { + min-height: 100vh; + display: flex; + justify-content: center; + align-items: center; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + padding: 20px; +} + +.settings-card { + background: white; + border-radius: 16px; + padding: 40px; + max-width: 600px; + width: 100%; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); +} + +.settings-header { + text-align: center; + margin-bottom: 40px; +} + +.settings-header h1 { + margin: 0 0 10px 0; + color: #333; + font-size: 2rem; +} + +.settings-header p { + margin: 0; + color: #666; + font-size: 0.95rem; +} + +.settings-form { + display: flex; + flex-direction: column; + gap: 24px; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 8px; +} + +.form-group label { + font-weight: 600; + color: #333; + font-size: 0.95rem; +} + +.required { + color: #e53e3e; + margin-left: 4px; +} + +.form-input { + padding: 12px 16px; + border: 2px solid #e2e8f0; + border-radius: 8px; + font-size: 1rem; + transition: all 0.2s; + font-family: 'Consolas', 'Monaco', monospace; +} + +.form-input:focus { + outline: none; + border-color: #667eea; + box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); +} + +.form-hint { + margin: 0; + font-size: 0.85rem; + color: #718096; +} + +.form-preview { + background: #f7fafc; + border: 1px solid #e2e8f0; + border-radius: 8px; + padding: 16px; + margin-top: 8px; +} + +.preview-label { + margin: 0 0 8px 0; + font-size: 0.9rem; + font-weight: 600; + color: #4a5568; +} + +.preview-path { + display: block; + padding: 8px 12px; + background: white; + border: 1px solid #cbd5e0; + border-radius: 6px; + font-family: 'Consolas', 'Monaco', monospace; + font-size: 0.9rem; + color: #2d3748; + word-break: break-all; +} + +.message { + padding: 12px 16px; + border-radius: 8px; + font-size: 0.9rem; + font-weight: 500; +} + +.message.success { + background: #c6f6d5; + color: #22543d; + border: 1px solid #9ae6b4; +} + +.message.error { + background: #fed7d7; + color: #742a2a; + border: 1px solid #fc8181; +} + +.save-button { + padding: 14px 24px; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + border: none; + border-radius: 8px; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: all 0.3s; + margin-top: 8px; +} + +.save-button:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 10px 20px rgba(102, 126, 234, 0.3); +} + +.save-button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.settings-footer { + margin-top: 24px; + padding-top: 24px; + border-top: 1px solid #e2e8f0; +} + +.info-text { + margin: 0; + font-size: 0.85rem; + color: #718096; + text-align: center; + line-height: 1.6; +} + +.loading-spinner { + border: 4px solid #f3f3f3; + border-top: 4px solid #667eea; + border-radius: 50%; + width: 40px; + height: 40px; + animation: spin 1s linear infinite; + margin: 0 auto 16px; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +/* 반응형 */ +@media (max-width: 768px) { + .settings-card { + padding: 24px; + } + + .settings-header h1 { + font-size: 1.5rem; + } +} + diff --git a/app/src/pages/Settings.tsx b/app/src/pages/Settings.tsx new file mode 100644 index 0000000..f8d2d94 --- /dev/null +++ b/app/src/pages/Settings.tsx @@ -0,0 +1,186 @@ +import React, { useState, useEffect } from 'react'; +import { doc, getDoc, setDoc } from 'firebase/firestore'; +import { auth, db } from '../firebase'; +import './Settings.css'; + +interface RepositorySettings { + githubUsername: string; + repositoryName: string; +} + +const Settings: React.FC = () => { + const [settings, setSettings] = useState({ + githubUsername: '', + repositoryName: 'TIL', + }); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); + + // 설정 불러오기 + useEffect(() => { + const loadSettings = async () => { + const user = auth.currentUser; + if (!user) { + setLoading(false); + return; + } + + try { + const userDoc = await getDoc(doc(db, 'users', user.uid)); + if (userDoc.exists()) { + const data = userDoc.data(); + setSettings({ + githubUsername: data.githubUsername || '', + repositoryName: data.repositoryName || 'TIL', + }); + } + } catch (error) { + console.error('설정 불러오기 실패:', error); + } finally { + setLoading(false); + } + }; + + loadSettings(); + }, []); + + // 설정 저장 + const handleSave = async (e: React.FormEvent) => { + e.preventDefault(); + const user = auth.currentUser; + + if (!user) { + setMessage({ type: 'error', text: '로그인이 필요합니다.' }); + return; + } + + if (!settings.githubUsername.trim() || !settings.repositoryName.trim()) { + setMessage({ type: 'error', text: '모든 필드를 입력해주세요.' }); + return; + } + + try { + setSaving(true); + setMessage(null); + + // 기존 데이터 유지하면서 업데이트 + const userDoc = await getDoc(doc(db, 'users', user.uid)); + const existingData = userDoc.exists() ? userDoc.data() : {}; + + await setDoc(doc(db, 'users', user.uid), { + ...existingData, + githubUsername: settings.githubUsername.trim(), + repositoryName: settings.repositoryName.trim(), + updatedAt: new Date().toISOString(), + }); + + setMessage({ type: 'success', text: '설정이 저장되었습니다!' }); + } catch (error) { + console.error('설정 저장 실패:', error); + setMessage({ type: 'error', text: '설정 저장에 실패했습니다.' }); + } finally { + setSaving(false); + } + }; + + // 입력 변경 핸들러 + const handleChange = (field: keyof RepositorySettings, value: string) => { + setSettings(prev => ({ ...prev, [field]: value })); + }; + + if (loading) { + return ( +
+
+
+

설정을 불러오는 중...

+
+
+ ); + } + + return ( +
+
+
+

⚙️ 리포지토리 설정

+

학습 내용을 가져올 GitHub 리포지토리를 설정하세요

+
+ +
+
+ + handleChange('githubUsername', e.target.value)} + placeholder="예: hssuh" + className="form-input" + required + /> +

+ GitHub 계정 사용자명 (https://github.com/사용자명) +

+
+ +
+ + handleChange('repositoryName', e.target.value)} + placeholder="예: TIL" + className="form-input" + required + /> +

+ 학습 내용이 저장된 리포지토리 이름 +

+
+ +
+

📂 리포지토리 경로:

+ + {settings.githubUsername && settings.repositoryName + ? `https://github.com/${settings.githubUsername}/${settings.repositoryName}` + : '설정을 입력해주세요'} + +
+ + {message && ( +
+ {message.text} +
+ )} + + +
+ +
+

+ ℹ️ 리포지토리는 public이거나, + 로그인한 계정이 접근 권한이 있어야 합니다. +

+
+
+
+ ); +}; + +export default Settings; + diff --git a/app/vite.config.ts b/app/vite.config.ts index 08d6cbb..e2593a7 100644 --- a/app/vite.config.ts +++ b/app/vite.config.ts @@ -1,42 +1,49 @@ -import { defineConfig } from 'vite'; +import { defineConfig, loadEnv } from 'vite'; import react from '@vitejs/plugin-react'; import { VitePWA } from 'vite-plugin-pwa'; -export default defineConfig({ - plugins: [ - react(), - VitePWA({ - registerType: 'autoUpdate', - includeAssets: ['favicon.ico'], - manifest: { - name: 'Today I Learned Alarm', - short_name: 'TIL Alarm', - description: '매일 학습한 내용을 정리하고 알림을 받는 앱', - start_url: '/', - display: 'standalone', - background_color: '#ffffff', - theme_color: '#121212', - icons: [ - { - src: 'favicon.ico', - sizes: '64x64 32x32 24x24 16x16', - type: 'image/x-icon' - } - ] - }, - workbox: { - globPatterns: ['**/*.{js,css,html,ico,png,svg,gif}'] - } - }) - ], - server: { - open: true, - proxy: { - '/question-generator': { - target: 'https://clovastudio.apigw.ntruss.com', - changeOrigin: true, - secure: true +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), ''); + const functionsUrl = mode === 'production' + ? env.VITE_FUNCTIONS_URL_PROD : env.VITE_FUNCTIONS_URL_LOCAL; + + return { + plugins: [ + react(), + VitePWA({ + registerType: 'autoUpdate', + includeAssets: ['favicon.ico'], + manifest: { + name: 'Today I Learned Alarm', + short_name: 'TIL Alarm', + description: '매일 학습한 내용을 정리하고 알림을 받는 앱', + start_url: '/', + display: 'standalone', + background_color: '#ffffff', + theme_color: '#121212', + icons: [ + { + src: 'favicon.ico', + sizes: '64x64 32x32 24x24 16x16', + type: 'image/x-icon' + } + ] + }, + workbox: { + globPatterns: ['**/*.{js,css,html,ico,png,svg,gif}'] + } + }) + ], + server: { + open: true, + proxy: { + '/api': { + target: functionsUrl || 'http://localhost:5001/til-alarm/us-central1', + changeOrigin: true, + secure: false, // 로컬 개발 시 false + rewrite: (path) => path.replace(/^\/api/, '') + } } } - } + }; }); diff --git a/firebase.json b/firebase.json index afff849..3172742 100644 --- a/firebase.json +++ b/firebase.json @@ -1,4 +1,19 @@ { + "functions": [ + { + "source": "functions", + "codebase": "default", + "ignore": [ + "node_modules", + ".git", + "firebase-debug.log", + "firebase-debug.*.log" + ] + } + ], + "firestore": { + "rules": "firestore.rules" + }, "hosting": { "public": "app/dist", "ignore": [ @@ -12,5 +27,18 @@ "destination": "/index.html" } ] + }, + "emulators": { + "functions": { + "port": 5001 + }, + "firestore": { + "port": 8080 + }, + "ui": { + "enabled": true, + "port": 4000 + }, + "singleProjectMode": true } } diff --git a/firestore.rules b/firestore.rules new file mode 100644 index 0000000..b592e50 --- /dev/null +++ b/firestore.rules @@ -0,0 +1,16 @@ +rules_version = '2'; + +service cloud.firestore { + match /databases/{database}/documents { + // 사용자 컬렉션: 로그인한 사용자는 본인 데이터 읽기/쓰기 가능 + match /users/{userId} { + allow read, write: if request.auth != null && request.auth.uid == userId; + } + + // 임시: 개발 중에는 모든 인증된 사용자 접근 허용 + match /{document=**} { + allow read, write: if request.auth != null; + } + } +} + diff --git a/functions/README.md b/functions/README.md new file mode 100644 index 0000000..6e80d8e --- /dev/null +++ b/functions/README.md @@ -0,0 +1,69 @@ +# Firebase Functions - 모듈별 분리 + +이 폴더는 3개의 모듈로 분리된 Firebase Functions를 포함합니다. + +## 모듈 구조 + +### 1. GitHub API (`src/github.ts`) +- `getCommits` - 커밋 목록 조회 +- `getFilename` - 커밋 상세 정보 조회 +- `getMarkdown` - 마크다운 파일 내용 조회 + +### 2. Schedule (`src/schedule.ts`) +- `sendDaily8amPush` - 매일 오전 8시 푸시 알림 전송 + +### 3. Hypercloax (`src/hypercloax.ts`) +- `hypercloaxApi` - Hypercloax API 연동 (구현 예정) + +## 인증 방식 + +### GitHub API 인증 +- **사용자 OAuth 토큰 방식**: Firebase Authentication의 GitHub Provider를 통해 로그인한 사용자의 토큰 사용 +- 클라이언트에서 `X-GitHub-Token` 헤더로 토큰 전달 +- 환경변수 토큰 설정 불필요 (사용자별 인증) + +### 장점 +- 사용자별 rate limit (5,000/시간) +- 개인 리포지토리 접근 가능 +- 보안 강화 (사용자 권한만 사용) + +## 배포 방법 + +### 전체 Functions 배포 +```bash +pnpm deploy +``` + +### 개별 모듈 배포 +```bash +# GitHub API만 배포 +pnpm deploy:github + +# Schedule만 배포 +pnpm deploy:schedule + +# Hypercloax만 배포 +pnpm deploy:hypercloax +``` + +## 로컬 개발 + +```bash +# 로컬에서 Functions 실행 +pnpm serve +``` + +## API 엔드포인트 + +배포 후 다음 엔드포인트를 사용할 수 있습니다: + +### GitHub API +- `GET /getCommits?since={date}&until={date}` - 커밋 목록 조회 +- `GET /getFilename?commit_sha={sha}` - 커밋 상세 정보 조회 +- `GET /getMarkdown?filename={filename}` - 마크다운 파일 내용 조회 + +### Schedule +- 자동 실행 (매일 오전 8시 KST) + +### Hypercloax +- `GET /hypercloaxApi?method={method}&path={path}` - Hypercloax API 호출 \ No newline at end of file diff --git a/functions/package.json b/functions/package.json index eb4377b..1f9e659 100644 --- a/functions/package.json +++ b/functions/package.json @@ -2,11 +2,15 @@ "name": "functions", "version": "1.0.0", "type": "module", + "main": "dist/index.js", "engines": { "node": "20" }, "scripts": { "build": "tsup", "serve": "pnpm build && firebase emulators:start --only functions,firestore", - "deploy": "pnpm build && firebase deploy --only functions" + "deploy": "pnpm build && firebase deploy --only functions", + "deploy:github": "pnpm build && firebase deploy --only functions:getCommits,functions:getFilename,functions:getMarkdown", + "deploy:schedule": "pnpm build && firebase deploy --only functions:sendDaily8amPush", + "deploy:hypercloax": "pnpm build && firebase deploy --only functions:hypercloaxApi,functions:chatCompletions,functions:registerDeviceToken,functions:removeDeviceToken,functions:registerSchedule" }, "dependencies": { "firebase-admin": "^12.0.0", @@ -17,4 +21,4 @@ "tsup": "^8.0.0", "@types/node": "^20.0.0" } -} +} \ No newline at end of file diff --git a/functions/src/github.ts b/functions/src/github.ts new file mode 100644 index 0000000..9cfcafd --- /dev/null +++ b/functions/src/github.ts @@ -0,0 +1,188 @@ +import { onRequest } from 'firebase-functions/v2/https'; +import { getAuth } from 'firebase-admin/auth'; +import { getFirestore } from 'firebase-admin/firestore'; + +/** + * Firebase ID Token 검증 및 사용자 정보 조회 + */ +async function getUserData(req: any): Promise<{ + githubToken: string; + githubUsername: string; + repositoryName: string; +}> { + const authHeader = req.headers.authorization; + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + throw new Error('Firebase ID token not provided. Please authenticate.'); + } + + const idToken = authHeader.split('Bearer ')[1]; + + try { + // Firebase ID Token 검증 + const decodedToken = await getAuth().verifyIdToken(idToken); + const userId = decodedToken.uid; + + // Firestore에서 사용자 정보 조회 + const userDoc = await getFirestore().collection('users').doc(userId).get(); + + if (!userDoc.exists) { + throw new Error('User not found. Please login again.'); + } + + const userData = userDoc.data(); + const githubToken = userData?.githubToken; + const githubUsername = userData?.githubUsername; + const repositoryName = userData?.repositoryName; + + if (!githubToken) { + throw new Error('GitHub token not found. Please login with GitHub again.'); + } + + if (!githubUsername || !repositoryName) { + throw new Error('Repository settings not found. Please configure in Settings page.'); + } + + return { + githubToken, + githubUsername, + repositoryName, + }; + } catch (error) { + console.error('Authentication error:', error); + throw error; + } +} + +// GitHub API 호출을 위한 HTTP Functions +export const getCommits = onRequest( + { cors: true }, + async (req, res) => { + try { + const { since, until } = req.query; + + if (!since || !until) { + res.status(400).json({ error: 'since and until parameters are required' }); + return; + } + + const userData = await getUserData(req); + const repoPath = `${userData.githubUsername}/${userData.repositoryName}`; + + const response = await fetch(`https://api.github.com/repos/${repoPath}/commits?since=${since}&until=${until}`, { + headers: { + "Authorization": `Bearer ${userData.githubToken}`, + "Accept": "application/vnd.github.v3+json", + "X-GitHub-Api-Version": "2022-11-28" + } + }); + + if (!response.ok) { + const errorBody = await response.text(); + console.error(`GitHub API error: ${response.status}`, errorBody); + res.status(response.status).json({ + error: 'Failed to fetch commits from GitHub', + details: errorBody + }); + return; + } + + const data = await response.json(); + res.json(data); + } catch (error) { + console.error('Error fetching commits:', error); + res.status(500).json({ + error: 'Failed to fetch commits', + message: error instanceof Error ? error.message : 'Unknown error' + }); + } + } +); + +export const getFilename = onRequest( + { cors: true }, + async (req, res) => { + try { + const { commit_sha } = req.query; + + if (!commit_sha) { + res.status(400).json({ error: 'commit_sha parameter is required' }); + return; + } + + const userData = await getUserData(req); + const repoPath = `${userData.githubUsername}/${userData.repositoryName}`; + + const response = await fetch(`https://api.github.com/repos/${repoPath}/commits/${commit_sha}`, { + headers: { + "Authorization": `Bearer ${userData.githubToken}`, + "Accept": "application/vnd.github.v3+json", + "X-GitHub-Api-Version": "2022-11-28" + } + }); + + if (!response.ok) { + const errorBody = await response.text(); + console.error(`GitHub API error: ${response.status}`, errorBody); + res.status(response.status).json({ + error: 'Failed to fetch commit details from GitHub', + details: errorBody + }); + return; + } + + const data = await response.json(); + res.json(data); + } catch (error) { + console.error('Error fetching commit details:', error); + res.status(500).json({ + error: 'Failed to fetch commit details', + message: error instanceof Error ? error.message : 'Unknown error' + }); + } + } +); + +export const getMarkdown = onRequest( + { cors: true }, + async (req, res) => { + try { + const { filename } = req.query; + + if (!filename) { + res.status(400).json({ error: 'filename parameter is required' }); + return; + } + + const userData = await getUserData(req); + const repoPath = `${userData.githubUsername}/${userData.repositoryName}`; + + const response = await fetch(`https://api.github.com/repos/${repoPath}/contents/${filename}`, { + headers: { + "Accept": "application/vnd.github.raw", + "Authorization": `Bearer ${userData.githubToken}`, + "X-GitHub-Api-Version": "2022-11-28" + } + }); + + if (!response.ok) { + const errorBody = await response.text(); + console.error(`GitHub API error: ${response.status}`, errorBody); + res.status(response.status).json({ + error: 'File not found or access denied', + details: errorBody + }); + return; + } + + const content = await response.text(); + res.json({ content }); + } catch (error) { + console.error('Error fetching markdown:', error); + res.status(500).json({ + error: 'Failed to fetch markdown content', + message: error instanceof Error ? error.message : 'Unknown error' + }); + } + } +); diff --git a/functions/src/hypercloax.ts b/functions/src/hypercloax.ts new file mode 100644 index 0000000..d98d101 --- /dev/null +++ b/functions/src/hypercloax.ts @@ -0,0 +1,140 @@ +import { onRequest } from 'firebase-functions/v2/https'; + +// Hypercloax 관련 API Functions +export const hypercloaxApi = onRequest( + { cors: true }, + async (req, res) => { + try { + const { method, path } = req.query; + + if (!method || !path) { + res.status(400).json({ error: 'method and path parameters are required' }); + return; + } + + // TODO: Hypercloax API 호출 로직 구현 + // 현재는 플레이스홀더 응답 + res.json({ + message: 'Hypercloax API endpoint', + method, + path, + status: 'not_implemented' + }); + } catch (error) { + console.error('Error calling Hypercloax API:', error); + res.status(500).json({ error: 'Failed to call Hypercloax API' }); + } + } +); + +// CLOVA Studio API (기존 NCloud API) +export const chatCompletions = onRequest( + { cors: true }, + async (req, res) => { + try { + const { prompt, text } = req.body; + + if (!prompt || !text) { + res.status(400).json({ error: 'prompt and text are required' }); + return; + } + + // CLOVA Studio API 호출 + const response = await fetch('https://clovastudio.apigw.ntruss.com/testapp/v1/chat-completions/HMX-001', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-NCP-CLOVASTUDIO-API-KEY': process.env.CLOVA_API_KEY || '', + 'X-NCP-APIGW-API-KEY': process.env.NCLOUD_API_KEY || '' + }, + body: JSON.stringify({ + messages: [ + { + role: 'user', + content: `${prompt}\n\n${text}` + } + ], + maxTokens: 1000, + temperature: 0.7, + topK: 0, + topP: 0.8, + repeatPenalty: 1.0 + }) + }); + + if (!response.ok) { + throw new Error(`CLOVA API error: ${response.status}`); + } + + const data = await response.json(); + res.json(data); + } catch (error) { + console.error('Error calling CLOVA API:', error); + res.status(500).json({ error: 'Failed to call CLOVA API' }); + } + } +); + +// Firebase Cloud Messaging 관련 API +export const registerDeviceToken = onRequest( + { cors: true }, + async (req, res) => { + try { + const { userId, token } = req.body; + + if (!userId || !token) { + res.status(400).json({ error: 'userId and token are required' }); + return; + } + + // TODO: Firestore에 토큰 저장 로직 구현 + console.log('Registering device token:', { userId, token }); + res.json({ success: true }); + } catch (error) { + console.error('Error registering device token:', error); + res.status(500).json({ error: 'Failed to register device token' }); + } + } +); + +export const removeDeviceToken = onRequest( + { cors: true }, + async (req, res) => { + try { + const { userId } = req.body; + + if (!userId) { + res.status(400).json({ error: 'userId is required' }); + return; + } + + // TODO: Firestore에서 토큰 삭제 로직 구현 + console.log('Removing device token for user:', userId); + res.json({ success: true }); + } catch (error) { + console.error('Error removing device token:', error); + res.status(500).json({ error: 'Failed to remove device token' }); + } + } +); + +export const registerSchedule = onRequest( + { cors: true }, + async (req, res) => { + try { + const { scheduleCode } = req.body; + + if (!scheduleCode) { + res.status(400).json({ error: 'scheduleCode is required' }); + return; + } + + // TODO: 스케줄 등록 로직 구현 + console.log('Registering schedule:', scheduleCode); + res.json({ success: true }); + } catch (error) { + console.error('Error registering schedule:', error); + res.status(500).json({ error: 'Failed to register schedule' }); + } + } +); diff --git a/functions/src/index.ts b/functions/src/index.ts index f938f86..d46680b 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -1,41 +1,8 @@ -import { onSchedule } from 'firebase-functions/v2/scheduler'; import { initializeApp } from 'firebase-admin/app'; -import { getMessaging } from 'firebase-admin/messaging'; // Firebase Admin SDK 초기화 initializeApp(); -// 매일 오전 8시(KST)에 실행되는 스케줄러 -export const sendDaily8amPush = onSchedule( - { - schedule: '0 23 * * *', // KST 08:00 = UTC 23:00 (전날) - timeZone: 'Asia/Seoul' - }, - async () => { - try { - console.log('Daily push notification scheduled task started'); - - // FCM 토픽/토큰으로 브로드캐스트 - // 실제 구현 시에는 Firestore에서 구독자 토큰들을 가져와야 함 - const messaging = getMessaging(); - - // 예시: 토픽을 통한 브로드캐스트 - const message = { - topic: 'daily-reminder', - notification: { - title: '오늘의 리마인더', - body: '복습할 카드가 도착했어요!' - }, - data: { - type: 'daily-reminder', - url: '/' - } - }; - - const response = await messaging.send(message); - console.log('Successfully sent message:', response); - } catch (error) { - console.error('Error sending push notification:', error); - } - } -); +export * from './github'; +export * from './schedule'; +export * from './hypercloax'; diff --git a/functions/src/schedule.ts b/functions/src/schedule.ts new file mode 100644 index 0000000..83c1eeb --- /dev/null +++ b/functions/src/schedule.ts @@ -0,0 +1,37 @@ +import { onSchedule } from 'firebase-functions/v2/scheduler'; +import { getMessaging } from 'firebase-admin/messaging'; + +// 매일 오전 8시(KST)에 실행되는 스케줄러 +export const sendDaily8amPush = onSchedule( + { + schedule: '0 23 * * *', // KST 08:00 = UTC 23:00 (전날) + timeZone: 'Asia/Seoul' + }, + async () => { + try { + console.log('Daily push notification scheduled task started'); + + // FCM 토픽/토큰으로 브로드캐스트 + // 실제 구현 시에는 Firestore에서 구독자 토큰들을 가져와야 함 + const messaging = getMessaging(); + + // 예시: 토픽을 통한 브로드캐스트 + const message = { + topic: 'daily-reminder', + notification: { + title: '오늘의 리마인더', + body: '복습할 카드가 도착했어요!' + }, + data: { + type: 'daily-reminder', + url: '/' + } + }; + + const response = await messaging.send(message); + console.log('Successfully sent message:', response); + } catch (error) { + console.error('Error sending push notification:', error); + } + } +); diff --git a/package.json b/package.json index 322dd54..007e02d 100644 --- a/package.json +++ b/package.json @@ -6,8 +6,13 @@ "functions" ], "scripts": { - "dev": "pnpm --filter app run dev", + "proxy": "node scripts/setup-proxy.js", + "dev": "concurrently --kill-others \"firebase emulators:start --only functions\" \"pnpm --filter app run dev\"", + "dev:build": "pnpm --filter functions build && pnpm dev", "build": "pnpm -r run build", - "deploy": "firebase deploy --only hosting,functions" + "push": "firebase deploy --only hosting,functions" + }, + "devDependencies": { + "concurrently": "^8.2.2" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 73c4ca5..8dc3439 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,10 +6,17 @@ settings: importers: - .: {} + .: + devDependencies: + concurrently: + specifier: ^8.2.2 + version: 8.2.2 app: dependencies: + axios: + specifier: ^1.12.2 + version: 1.12.2 firebase: specifier: ^10.4.0 version: 10.14.1 @@ -1569,6 +1576,9 @@ packages: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} + axios@1.12.2: + resolution: {integrity: sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==} + babel-plugin-polyfill-corejs2@0.4.14: resolution: {integrity: sha512-Co2Y9wX854ts6U8gAAPXfn0GmAyctHuK8n0Yhfjd6t30g7yvKjspvvOo9yG+z52PZRgFErt7Ka2pYnXCjLKEpg==} peerDependencies: @@ -1661,6 +1671,10 @@ packages: ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + character-entities-html4@2.1.0: resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} @@ -1724,6 +1738,11 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + concurrently@8.2.2: + resolution: {integrity: sha512-1dP4gpXFhei8IOtlXRE/T/4H88ElHgTiUzh71YUmtjTEHMSRS2Z/fgOxHSxxusGHogsRfxNq1vyAwxSC+EVyDg==} + engines: {node: ^14.13.0 || >=16.0.0} + hasBin: true + confbox@0.1.8: resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} @@ -1779,6 +1798,10 @@ packages: resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} engines: {node: '>= 0.4'} + date-fns@2.30.0: + resolution: {integrity: sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==} + engines: {node: '>=0.11'} + debug@2.6.9: resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} peerDependencies: @@ -2012,6 +2035,15 @@ packages: fix-dts-default-cjs-exports@1.0.1: resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} + follow-redirects@1.15.11: + resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + for-each@0.3.5: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} @@ -2024,6 +2056,10 @@ packages: resolution: {integrity: sha512-jqdObeR2rxZZbPSGL+3VckHMYtu+f9//KXBsVny6JSX/pa38Fy+bGjuG8eW/H6USNQWhLi8Num++cU2yOCNz4A==} engines: {node: '>= 0.12'} + form-data@4.0.4: + resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==} + engines: {node: '>= 6'} + format@0.2.2: resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} engines: {node: '>=0.4.x'} @@ -2922,6 +2958,9 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -3080,6 +3119,9 @@ packages: run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + safe-array-concat@1.1.3: resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} engines: {node: '>=0.4'} @@ -3144,6 +3186,10 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + shell-quote@1.8.3: + resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==} + engines: {node: '>= 0.4'} + side-channel-list@1.0.0: resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} engines: {node: '>= 0.4'} @@ -3195,6 +3241,9 @@ packages: space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + spawn-command@0.0.2: + resolution: {integrity: sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==} + statuses@2.0.1: resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} engines: {node: '>= 0.8'} @@ -3279,6 +3328,10 @@ packages: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + supports-preserve-symlinks-flag@1.0.0: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} @@ -5217,7 +5270,7 @@ snapshots: '@types/resolve@1.17.1': dependencies: - '@types/node': 20.19.19 + '@types/node': 24.6.2 '@types/send@0.17.5': dependencies: @@ -5327,8 +5380,7 @@ snapshots: async@3.2.6: {} - asynckit@0.4.0: - optional: true + asynckit@0.4.0: {} at-least-node@1.0.0: {} @@ -5336,6 +5388,14 @@ snapshots: dependencies: possible-typed-array-names: 1.1.0 + axios@1.12.2: + dependencies: + follow-redirects: 1.15.11 + form-data: 4.0.4 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + babel-plugin-polyfill-corejs2@0.4.14(@babel/core@7.28.4): dependencies: '@babel/compat-data': 7.28.4 @@ -5446,6 +5506,11 @@ snapshots: ccount@2.0.1: {} + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + character-entities-html4@2.1.0: {} character-entities-legacy@1.1.4: {} @@ -5481,7 +5546,6 @@ snapshots: combined-stream@1.0.8: dependencies: delayed-stream: 1.0.0 - optional: true comma-separated-tokens@1.0.8: {} @@ -5495,6 +5559,18 @@ snapshots: concat-map@0.0.1: {} + concurrently@8.2.2: + dependencies: + chalk: 4.1.2 + date-fns: 2.30.0 + lodash: 4.17.21 + rxjs: 7.8.2 + shell-quote: 1.8.3 + spawn-command: 0.0.2 + supports-color: 8.1.1 + tree-kill: 1.2.2 + yargs: 17.7.2 + confbox@0.1.8: {} consola@3.4.2: {} @@ -5548,6 +5624,10 @@ snapshots: es-errors: 1.3.0 is-data-view: 1.0.2 + date-fns@2.30.0: + dependencies: + '@babel/runtime': 7.28.4 + debug@2.6.9: dependencies: ms: 2.0.0 @@ -5574,8 +5654,7 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 - delayed-stream@1.0.0: - optional: true + delayed-stream@1.0.0: {} depd@2.0.0: {} @@ -5943,6 +6022,8 @@ snapshots: mlly: 1.8.0 rollup: 4.52.4 + follow-redirects@1.15.11: {} + for-each@0.3.5: dependencies: is-callable: 1.2.7 @@ -5962,6 +6043,14 @@ snapshots: safe-buffer: 5.2.1 optional: true + form-data@4.0.4: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + format@0.2.2: {} forwarded@0.2.0: {} @@ -6408,7 +6497,7 @@ snapshots: jest-worker@26.6.2: dependencies: - '@types/node': 20.19.19 + '@types/node': 24.6.2 merge-stream: 2.0.0 supports-color: 7.2.0 @@ -7127,6 +7216,8 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 + proxy-from-env@1.1.0: {} + punycode@2.3.1: {} qs@6.13.0: @@ -7366,6 +7457,10 @@ snapshots: dependencies: queue-microtask: 1.2.3 + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + safe-array-concat@1.1.3: dependencies: call-bind: 1.0.8 @@ -7458,6 +7553,8 @@ snapshots: shebang-regex@3.0.0: {} + shell-quote@1.8.3: {} + side-channel-list@1.0.0: dependencies: es-errors: 1.3.0 @@ -7511,6 +7608,8 @@ snapshots: space-separated-tokens@2.0.2: {} + spawn-command@0.0.2: {} + statuses@2.0.1: {} stop-iteration-iterator@1.1.0: @@ -7633,6 +7732,10 @@ snapshots: dependencies: has-flag: 4.0.0 + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + supports-preserve-symlinks-flag@1.0.0: {} teeny-request@9.0.0: diff --git a/scripts/setup-proxy.js b/scripts/setup-proxy.js new file mode 100644 index 0000000..cc5bf77 --- /dev/null +++ b/scripts/setup-proxy.js @@ -0,0 +1,99 @@ +#!/usr/bin/env node + +const { execSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +console.log('🔧 Firebase 프로젝트 설정을 확인하고 환경변수를 설정합니다...\n'); + +const ensureTrailingNewline = (text) => (text.endsWith('\n') ? text : text + '\n'); +const parseEnvToMap = (content) => { + const map = new Map(); + (content || '').split(/\r?\n/).forEach((line) => { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) return; + const eqIdx = trimmed.indexOf('='); + if (eqIdx === -1) return; + const key = trimmed.substring(0, eqIdx).trim(); + const value = trimmed.substring(eqIdx + 1).trim(); + map.set(key, value); + }); + return map; +}; +const upsertEnvVars = (existingContent, newVars, sectionTitle) => { + let result = existingContent || ''; + result = ensureTrailingNewline(result); + if (sectionTitle && !result.includes(sectionTitle)) { + result += `\n${sectionTitle}\n`; + } + const existingMap = parseEnvToMap(result); + Object.entries(newVars).forEach(([key, value]) => { + if (value == null || value === '') return; + if (!existingMap.has(key)) { + result += `${key}=${value}\n`; + } else { + // 이미 키가 존재하지만 값이 비어있는 경우에는 값을 채워준다 + const regex = new RegExp(`^(${key})=\s*$`, 'm'); + if (regex.test(result)) { + result = result.replace(regex, `$1=${value}`); + console.log(` ✏️ ${key}의 빈 값을 채웠습니다.`); + } else { + console.log(` ⚠️ ${key}는 이미 존재합니다. 건너뜁니다.`); + } + } + }); + return result; +}; + +try { + // Firebase 프로젝트 정보 가져오기 (현재 선택된 프로젝트 ID) + const projectId = execSync('firebase use', { encoding: 'utf8' }).trim(); + console.log(`📋 현재 Firebase 프로젝트: ${projectId}`); + + // .env 파일 경로 + const envPath = path.join(__dirname, '..', 'app', '.env'); + + // 환경변수 내용 생성 (Functions용) + const envVars = { + VITE_FIREBASE_PROJECT_ID: projectId, + VITE_FIREBASE_REGION: 'us-central1', + VITE_FUNCTIONS_URL_LOCAL: `http://localhost:5001/${projectId}/us-central1`, + VITE_FUNCTIONS_URL_PROD: `https://us-central1-${projectId}.cloudfunctions.net` + }; + + let envFileContent = ''; + + if (fs.existsSync(envPath)) { + console.log('📄 기존 .env 파일을 발견했습니다.'); + envFileContent = fs.readFileSync(envPath, 'utf8'); + envFileContent = upsertEnvVars(envFileContent, envVars, '# Firebase Functions 설정 (자동 생성)'); + console.log(' ✅ 기존 내용을 보존하고 Functions 환경변수를 추가했습니다.'); + } else { + console.log('📄 새로운 .env 파일을 생성합니다.'); + envFileContent = upsertEnvVars('', envVars, '# Firebase Functions 설정 (자동 생성)'); + } + + // Firebase Web 앱 설정 안내 + console.log('📝 Firebase Web 앱 설정이 필요한 경우:'); + console.log(' 1. Firebase Console (https://console.firebase.google.com) 접속'); + console.log(` 2. 프로젝트 "${projectId}" 선택`); + console.log(' 3. 프로젝트 설정 > 일반 탭 > 내 앱 > 웹 앱 선택'); + console.log(' 4. "구성" 버튼 클릭하여 config 객체 복사'); + console.log(' 5. app/src/firebase.ts에 config 객체 붙여넣기'); + console.log(' 또는 app/.env 파일에 환경변수로 설정'); + + // .env 파일 저장 (append/upsert 결과) + fs.writeFileSync(envPath, envFileContent); + + console.log('✅ .env 파일이 생성되었습니다:'); + console.log(envFileContent); + + console.log('🚀 이제 다음 명령어로 개발 서버를 시작할 수 있습니다:'); + console.log(' cd app && pnpm dev'); + +} catch (error) { + console.error('❌ 오류가 발생했습니다:', error.message); + console.log('\n📝 수동으로 .env 파일을 생성하세요:'); + console.log(' cp app/env.example app/.env'); + console.log(' # app/.env 파일에서 VITE_FIREBASE_PROJECT_ID를 실제 프로젝트 ID로 수정'); +}