From 906f3fd4f867e95a9159a7d5f961ef0a2b871d9d Mon Sep 17 00:00:00 2001 From: chucoding Date: Mon, 6 Oct 2025 18:25:04 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat=20:=20[TSK-28]=20axios=20=EB=B0=8F=20p?= =?UTF-8?q?roxy=20=EC=85=8B=ED=8C=85=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 +- README.md | 169 +++++++++++++++++++----------- app/{env.example => .env.example} | 3 - app/package.json | 1 + app/src/api/github-api.ts | 37 ++++--- app/src/api/ncloud-api.ts | 45 ++++---- app/src/modules/axios.ts | 41 ++++++++ app/vite.config.ts | 87 ++++++++------- functions/.env.example | 4 + functions/README.md | 81 ++++++++++++++ functions/package.json | 7 +- functions/src/github.ts | 118 +++++++++++++++++++++ functions/src/hypercloax.ts | 140 +++++++++++++++++++++++++ functions/src/index.ts | 44 +------- functions/src/schedule.ts | 41 ++++++++ package.json | 3 +- pnpm-lock.yaml | 49 ++++++++- scripts/setup-proxy.js | 99 +++++++++++++++++ 18 files changed, 780 insertions(+), 193 deletions(-) rename app/{env.example => .env.example} (82%) create mode 100644 app/src/modules/axios.ts create mode 100644 functions/.env.example create mode 100644 functions/README.md create mode 100644 functions/src/github.ts create mode 100644 functions/src/hypercloax.ts create mode 100644 functions/src/schedule.ts create mode 100644 scripts/setup-proxy.js 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 82% rename from app/env.example rename to app/.env.example index 0d8cae5..e66bf74 100644 --- a/app/env.example +++ b/app/.env.example @@ -11,6 +11,3 @@ VITE_VAPID_KEY=your_vapid_key_here # 사용자 설정 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/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/modules/axios.ts b/app/src/modules/axios.ts new file mode 100644 index 0000000..11e1fba --- /dev/null +++ b/app/src/modules/axios.ts @@ -0,0 +1,41 @@ +import axios from 'axios'; + +// Firebase Functions URL 설정 +const FUNCTIONS_URL = import.meta.env.PROD + ? import.meta.env.VITE_FUNCTIONS_URL_PROD || `https://us-central1-${import.meta.env.VITE_FIREBASE_PROJECT_ID || 'til-alarm'}.cloudfunctions.net` + : '/api'; // Vite 프록시 사용 + +// 기본 axios 인스턴스 생성 +export const apiClient = axios.create({ + baseURL: FUNCTIONS_URL, + timeout: 10000, + headers: { + 'Content-Type': 'application/json', + }, +}); + +// 요청 인터셉터 +apiClient.interceptors.request.use( + (config) => { + console.log(`API 요청: ${config.method?.toUpperCase()} ${config.url}`); + return config; + }, + (error) => { + console.error('API 요청 오류:', error); + return Promise.reject(error); + } +); + +// 응답 인터셉터 +apiClient.interceptors.response.use( + (response) => { + console.log(`API 응답: ${response.status} ${response.config.url}`); + return response; + }, + (error) => { + console.error('API 응답 오류:', error.response?.data || error.message); + return Promise.reject(error); + } +); + +export default apiClient; diff --git a/app/vite.config.ts b/app/vite.config.ts index 08d6cbb..584218e 100644 --- a/app/vite.config.ts +++ b/app/vite.config.ts @@ -1,42 +1,57 @@ -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(), ''); + + // Firebase Functions URL 동적 생성 + const projectId = env.VITE_FIREBASE_PROJECT_ID || 'til-alarm'; + const region = env.VITE_FIREBASE_REGION || 'us-central1'; + + const functionsUrl = { + local: env.VITE_FUNCTIONS_URL_LOCAL || `http://localhost:5001/${projectId}/${region}`, + prod: env.VITE_FUNCTIONS_URL_PROD || `https://${region}-${projectId}.cloudfunctions.net` + }; + + 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: mode === 'production' ? functionsUrl.prod : functionsUrl.local, + changeOrigin: true, + secure: true, + rewrite: (path) => path.replace(/^\/api/, '') + } } } - } + }; }); diff --git a/functions/.env.example b/functions/.env.example new file mode 100644 index 0000000..1a1436e --- /dev/null +++ b/functions/.env.example @@ -0,0 +1,4 @@ +# GitHub API Token +# GitHub Personal Access Token을 여기에 설정하세요 +# https://github.com/settings/tokens 에서 생성할 수 있습니다 +GITHUB_TOKEN=your_github_token_here \ No newline at end of file diff --git a/functions/README.md b/functions/README.md new file mode 100644 index 0000000..e48280a --- /dev/null +++ b/functions/README.md @@ -0,0 +1,81 @@ +# 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 연동 (구현 예정) + +## 환경변수 설정 + +### 1. 로컬 개발용 (.env 파일) +```bash +# functions 폴더에 .env 파일 생성 +cp env.example .env + +# .env 파일에서 GITHUB_TOKEN 설정 +GITHUB_TOKEN=your_github_token_here +``` + +### 2. 프로덕션용 (Firebase Config) +```bash +# Firebase Functions 환경변수 설정 +firebase functions:config:set github.token="your_github_token_here" + +# 설정 확인 +firebase functions:config:get +``` + +### 환경변수 우선순위 +1. `process.env.GITHUB_TOKEN` (로컬 개발용) +2. `functions.config().github.token` (프로덕션용) + +## 배포 방법 + +### 전체 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..6403def 100644 --- a/functions/package.json +++ b/functions/package.json @@ -6,7 +6,10 @@ "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 +20,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..aa2149c --- /dev/null +++ b/functions/src/github.ts @@ -0,0 +1,118 @@ +import { onRequest } from 'firebase-functions/v2/https'; +import * as functions from 'firebase-functions'; + +// 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; + } + + // 환경변수에서 GitHub 토큰 가져오기 (로컬: process.env, 프로덕션: functions.config) + const githubToken = process.env.GITHUB_TOKEN || functions.config().github?.token; + + if (!githubToken) { + throw new Error('GitHub token not configured'); + } + + const response = await fetch(`https://api.github.com/repos/hssuh/TIL/commits?since=${since}&until=${until}`, { + headers: { + "Authorization": `Bearer ${githubToken}`, + "Accept": "application/vnd.github.v3+json" + } + }); + + if (!response.ok) { + throw new Error(`GitHub API error: ${response.status}`); + } + + 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' }); + } + } +); + +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; + } + + // 환경변수에서 GitHub 토큰 가져오기 + const githubToken = process.env.GITHUB_TOKEN || functions.config().github?.token; + + if (!githubToken) { + throw new Error('GitHub token not configured'); + } + + const response = await fetch(`https://api.github.com/repos/hssuh/TIL/commits/${commit_sha}`, { + headers: { + "Authorization": `Bearer ${githubToken}`, + "Accept": "application/vnd.github.v3+json" + } + }); + + if (!response.ok) { + throw new Error(`GitHub API error: ${response.status}`); + } + + 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' }); + } + } +); + +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; + } + + // 환경변수에서 GitHub 토큰 가져오기 + const githubToken = process.env.GITHUB_TOKEN || functions.config().github?.token; + + if (!githubToken) { + throw new Error('GitHub token not configured'); + } + + const response = await fetch(`https://api.github.com/repos/hssuh/TIL/contents/${filename}`, { + headers: { + "Accept": "application/vnd.github.raw", + "Authorization": `Bearer ${githubToken}` + } + }); + + if (!response.ok) { + res.status(404).json({ error: 'File not found' }); + 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' }); + } + } +); 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..0a45a59 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -1,41 +1,3 @@ -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..f938f86 --- /dev/null +++ b/functions/src/schedule.ts @@ -0,0 +1,41 @@ +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); + } + } +); diff --git a/package.json b/package.json index 322dd54..e893da8 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "scripts": { "dev": "pnpm --filter app run dev", "build": "pnpm -r run build", - "deploy": "firebase deploy --only hosting,functions" + "deploy": "firebase deploy --only hosting,functions", + "setup:proxy": "node scripts/setup-proxy.js" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 73c4ca5..fb66c35 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,6 +10,9 @@ importers: app: dependencies: + axios: + specifier: ^1.12.2 + version: 1.12.2 firebase: specifier: ^10.4.0 version: 10.14.1 @@ -1569,6 +1572,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: @@ -2012,6 +2018,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 +2039,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 +2941,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'} @@ -5327,8 +5349,7 @@ snapshots: async@3.2.6: {} - asynckit@0.4.0: - optional: true + asynckit@0.4.0: {} at-least-node@1.0.0: {} @@ -5336,6 +5357,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 @@ -5481,7 +5510,6 @@ snapshots: combined-stream@1.0.8: dependencies: delayed-stream: 1.0.0 - optional: true comma-separated-tokens@1.0.8: {} @@ -5574,8 +5602,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 +5970,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 +5991,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: {} @@ -7127,6 +7164,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: 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로 수정'); +} From c3961409a7a67203f79d36b0838f43327da8ba1a Mon Sep 17 00:00:00 2001 From: chucoding Date: Tue, 7 Oct 2025 15:36:41 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat=20:=20[TSK-30]=20firebase=20functions?= =?UTF-8?q?=20=EB=B0=B0=ED=8F=AC=20=EB=B0=8F=20emulator=20=EA=B5=AC?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/.env.example | 8 ++++- app/vite.config.ts | 15 +++------ firebase.json | 25 +++++++++++++++ functions/package.json | 1 + functions/src/index.ts | 5 +++ functions/src/schedule.ts | 4 --- package.json | 9 ++++-- pnpm-lock.yaml | 66 ++++++++++++++++++++++++++++++++++++++- 8 files changed, 113 insertions(+), 20 deletions(-) diff --git a/app/.env.example b/app/.env.example index e66bf74..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,6 +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 diff --git a/app/vite.config.ts b/app/vite.config.ts index 584218e..1d789e0 100644 --- a/app/vite.config.ts +++ b/app/vite.config.ts @@ -3,17 +3,10 @@ import react from '@vitejs/plugin-react'; import { VitePWA } from 'vite-plugin-pwa'; export default defineConfig(({ mode }) => { - // 환경변수 로드 const env = loadEnv(mode, process.cwd(), ''); - - // Firebase Functions URL 동적 생성 - const projectId = env.VITE_FIREBASE_PROJECT_ID || 'til-alarm'; - const region = env.VITE_FIREBASE_REGION || 'us-central1'; - - const functionsUrl = { - local: env.VITE_FUNCTIONS_URL_LOCAL || `http://localhost:5001/${projectId}/${region}`, - prod: env.VITE_FUNCTIONS_URL_PROD || `https://${region}-${projectId}.cloudfunctions.net` - }; + const functionsUrl = mode === 'production' + ? env.VITE_FUNCTIONS_URL_PROD + : env.VITE_FUNCTIONS_URL_LOCAL; return { plugins: [ @@ -46,7 +39,7 @@ export default defineConfig(({ mode }) => { open: true, proxy: { '/api': { - target: mode === 'production' ? functionsUrl.prod : functionsUrl.local, + target: functionsUrl, changeOrigin: true, secure: true, rewrite: (path) => path.replace(/^\/api/, '') diff --git a/firebase.json b/firebase.json index afff849..e11eac0 100644 --- a/firebase.json +++ b/firebase.json @@ -1,4 +1,16 @@ { + "functions": [ + { + "source": "functions", + "codebase": "default", + "ignore": [ + "node_modules", + ".git", + "firebase-debug.log", + "firebase-debug.*.log" + ] + } + ], "hosting": { "public": "app/dist", "ignore": [ @@ -12,5 +24,18 @@ "destination": "/index.html" } ] + }, + "emulators": { + "functions": { + "port": 5001 + }, + "firestore": { + "port": 8080 + }, + "ui": { + "enabled": true, + "port": 4000 + }, + "singleProjectMode": true } } diff --git a/functions/package.json b/functions/package.json index 6403def..1f9e659 100644 --- a/functions/package.json +++ b/functions/package.json @@ -2,6 +2,7 @@ "name": "functions", "version": "1.0.0", "type": "module", + "main": "dist/index.js", "engines": { "node": "20" }, "scripts": { "build": "tsup", diff --git a/functions/src/index.ts b/functions/src/index.ts index 0a45a59..d46680b 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -1,3 +1,8 @@ +import { initializeApp } from 'firebase-admin/app'; + +// Firebase Admin SDK 초기화 +initializeApp(); + export * from './github'; export * from './schedule'; export * from './hypercloax'; diff --git a/functions/src/schedule.ts b/functions/src/schedule.ts index f938f86..83c1eeb 100644 --- a/functions/src/schedule.ts +++ b/functions/src/schedule.ts @@ -1,10 +1,6 @@ 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( { diff --git a/package.json b/package.json index e893da8..b56a74e 100644 --- a/package.json +++ b/package.json @@ -6,9 +6,12 @@ "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\"", "build": "pnpm -r run build", - "deploy": "firebase deploy --only hosting,functions", - "setup:proxy": "node scripts/setup-proxy.js" + "push": "firebase deploy --only hosting,functions" + }, + "devDependencies": { + "concurrently": "^8.2.2" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fb66c35..632a79c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,7 +6,11 @@ settings: importers: - .: {} + .: + devDependencies: + concurrently: + specifier: ^8.2.2 + version: 8.2.2 app: dependencies: @@ -1667,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==} @@ -1730,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==} @@ -1785,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: @@ -3102,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'} @@ -3166,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'} @@ -3217,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'} @@ -3301,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'} @@ -5475,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: {} @@ -5523,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: {} @@ -5576,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 @@ -7405,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 @@ -7497,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 @@ -7550,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: @@ -7672,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: From 272d9a4db8736d7fc2cc1a091e471e9a4163df98 Mon Sep 17 00:00:00 2001 From: chucoding Date: Tue, 7 Oct 2025 16:44:01 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix=20:=20[TSK-31]=20github=20API=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD(personal=20=3D>=20OAuth=20APP=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20=EB=B0=A9=EC=8B=9D)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/firebase.ts | 1 + app/src/modules/axios.ts | 11 ++++- app/src/pages/Login.tsx | 13 +++++- functions/README.md | 30 +++++--------- functions/src/github.ts | 87 ++++++++++++++++++++++++++-------------- 5 files changed, 88 insertions(+), 54 deletions(-) diff --git a/app/src/firebase.ts b/app/src/firebase.ts index 3cd4e51..b55085e 100644 --- a/app/src/firebase.ts +++ b/app/src/firebase.ts @@ -23,3 +23,4 @@ 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 index 11e1fba..363a815 100644 --- a/app/src/modules/axios.ts +++ b/app/src/modules/axios.ts @@ -14,10 +14,17 @@ export const apiClient = axios.create({ }, }); -// 요청 인터셉터 +// 요청 인터셉터 - GitHub OAuth 토큰을 헤더에 추가 apiClient.interceptors.request.use( - (config) => { + async (config) => { console.log(`API 요청: ${config.method?.toUpperCase()} ${config.url}`); + + // 로컬 스토리지에서 GitHub OAuth 토큰 가져오기 + const githubToken = localStorage.getItem('github_access_token'); + if (githubToken) { + config.headers['X-GitHub-Token'] = githubToken; + } + return config; }, (error) => { diff --git a/app/src/pages/Login.tsx b/app/src/pages/Login.tsx index 873a260..e111f08 100644 --- a/app/src/pages/Login.tsx +++ b/app/src/pages/Login.tsx @@ -1,5 +1,5 @@ import React, { useState } from 'react'; -import { signInWithPopup, signOut, onAuthStateChanged, User } from 'firebase/auth'; +import { signInWithPopup, signOut, onAuthStateChanged, User, GithubAuthProvider } from 'firebase/auth'; import { auth, githubProvider } from '../firebase'; import './Login.css'; @@ -24,6 +24,15 @@ const Login: React.FC = () => { setLoading(true); setError(''); const result = await signInWithPopup(auth, githubProvider); + + // GitHub OAuth 토큰 저장 + const credential = GithubAuthProvider.credentialFromResult(result); + if (credential && credential.accessToken) { + // GitHub access token을 로컬 스토리지에 저장 + localStorage.setItem('github_access_token', credential.accessToken); + console.log('로그인 성공 및 GitHub 토큰 저장 완료'); + } + console.log('로그인 성공:', result.user); } catch (error: any) { console.error('로그인 실패:', error); @@ -37,6 +46,8 @@ const Login: React.FC = () => { const handleLogout = async () => { try { await signOut(auth); + // GitHub 토큰도 함께 제거 + localStorage.removeItem('github_access_token'); console.log('로그아웃 성공'); } catch (error) { console.error('로그아웃 실패:', error); diff --git a/functions/README.md b/functions/README.md index e48280a..6e80d8e 100644 --- a/functions/README.md +++ b/functions/README.md @@ -15,29 +15,17 @@ ### 3. Hypercloax (`src/hypercloax.ts`) - `hypercloaxApi` - Hypercloax API 연동 (구현 예정) -## 환경변수 설정 +## 인증 방식 -### 1. 로컬 개발용 (.env 파일) -```bash -# functions 폴더에 .env 파일 생성 -cp env.example .env - -# .env 파일에서 GITHUB_TOKEN 설정 -GITHUB_TOKEN=your_github_token_here -``` - -### 2. 프로덕션용 (Firebase Config) -```bash -# Firebase Functions 환경변수 설정 -firebase functions:config:set github.token="your_github_token_here" - -# 설정 확인 -firebase functions:config:get -``` +### GitHub API 인증 +- **사용자 OAuth 토큰 방식**: Firebase Authentication의 GitHub Provider를 통해 로그인한 사용자의 토큰 사용 +- 클라이언트에서 `X-GitHub-Token` 헤더로 토큰 전달 +- 환경변수 토큰 설정 불필요 (사용자별 인증) -### 환경변수 우선순위 -1. `process.env.GITHUB_TOKEN` (로컬 개발용) -2. `functions.config().github.token` (프로덕션용) +### 장점 +- 사용자별 rate limit (5,000/시간) +- 개인 리포지토리 접근 가능 +- 보안 강화 (사용자 권한만 사용) ## 배포 방법 diff --git a/functions/src/github.ts b/functions/src/github.ts index aa2149c..a0f96d5 100644 --- a/functions/src/github.ts +++ b/functions/src/github.ts @@ -1,5 +1,18 @@ import { onRequest } from 'firebase-functions/v2/https'; -import * as functions from 'firebase-functions'; + +/** + * GitHub API 인증 헤더 생성 + * 클라이언트에서 전달받은 사용자의 GitHub OAuth 토큰 사용 + */ +function getGitHubAuthHeader(req: any): string { + const userToken = req.headers['x-github-token']; + + if (!userToken) { + throw new Error('GitHub token not provided. Please authenticate with GitHub.'); + } + + return `Bearer ${userToken}`; +} // GitHub API 호출을 위한 HTTP Functions export const getCommits = onRequest( @@ -13,29 +26,34 @@ export const getCommits = onRequest( return; } - // 환경변수에서 GitHub 토큰 가져오기 (로컬: process.env, 프로덕션: functions.config) - const githubToken = process.env.GITHUB_TOKEN || functions.config().github?.token; - - if (!githubToken) { - throw new Error('GitHub token not configured'); - } + const authHeader = getGitHubAuthHeader(req); const response = await fetch(`https://api.github.com/repos/hssuh/TIL/commits?since=${since}&until=${until}`, { headers: { - "Authorization": `Bearer ${githubToken}`, - "Accept": "application/vnd.github.v3+json" + "Authorization": authHeader, + "Accept": "application/vnd.github.v3+json", + "X-GitHub-Api-Version": "2022-11-28" } }); if (!response.ok) { - throw new Error(`GitHub API error: ${response.status}`); + 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' }); + res.status(500).json({ + error: 'Failed to fetch commits', + message: error instanceof Error ? error.message : 'Unknown error' + }); } } ); @@ -51,29 +69,34 @@ export const getFilename = onRequest( return; } - // 환경변수에서 GitHub 토큰 가져오기 - const githubToken = process.env.GITHUB_TOKEN || functions.config().github?.token; - - if (!githubToken) { - throw new Error('GitHub token not configured'); - } + const authHeader = getGitHubAuthHeader(req); const response = await fetch(`https://api.github.com/repos/hssuh/TIL/commits/${commit_sha}`, { headers: { - "Authorization": `Bearer ${githubToken}`, - "Accept": "application/vnd.github.v3+json" + "Authorization": authHeader, + "Accept": "application/vnd.github.v3+json", + "X-GitHub-Api-Version": "2022-11-28" } }); if (!response.ok) { - throw new Error(`GitHub API error: ${response.status}`); + 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' }); + res.status(500).json({ + error: 'Failed to fetch commit details', + message: error instanceof Error ? error.message : 'Unknown error' + }); } } ); @@ -89,22 +112,23 @@ export const getMarkdown = onRequest( return; } - // 환경변수에서 GitHub 토큰 가져오기 - const githubToken = process.env.GITHUB_TOKEN || functions.config().github?.token; - - if (!githubToken) { - throw new Error('GitHub token not configured'); - } + const authHeader = getGitHubAuthHeader(req); const response = await fetch(`https://api.github.com/repos/hssuh/TIL/contents/${filename}`, { headers: { "Accept": "application/vnd.github.raw", - "Authorization": `Bearer ${githubToken}` + "Authorization": authHeader, + "X-GitHub-Api-Version": "2022-11-28" } }); if (!response.ok) { - res.status(404).json({ error: 'File not found' }); + 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; } @@ -112,7 +136,10 @@ export const getMarkdown = onRequest( res.json({ content }); } catch (error) { console.error('Error fetching markdown:', error); - res.status(500).json({ error: 'Failed to fetch markdown content' }); + res.status(500).json({ + error: 'Failed to fetch markdown content', + message: error instanceof Error ? error.message : 'Unknown error' + }); } } ); From 88bd161fd97d62a79979961288c9345aa9a53235 Mon Sep 17 00:00:00 2001 From: chucoding Date: Tue, 7 Oct 2025 18:03:06 +0900 Subject: [PATCH 4/4] =?UTF-8?q?feat=20:=20[TSK-9,=20TSK-31]=20firestore?= =?UTF-8?q?=EC=97=90=20github=20=EC=9C=A0=EC=A0=80=20=EC=A0=95=EB=B3=B4=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5(=EB=A0=88=ED=8F=AC,=20=ED=86=A0=ED=81=B0)=20?= =?UTF-8?q?=EB=B0=8F=20github=20API=20=EC=88=98=EC=A0=95(=EB=A0=88?= =?UTF-8?q?=ED=8F=AC=20=EB=8F=99=EC=A0=81=EC=9C=BC=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=EA=B0=80=EB=8A=A5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/App.tsx | 63 ++++++++++++- app/src/firebase.ts | 14 +++ app/src/modules/axios.ts | 24 ++--- app/src/pages/Login.tsx | 20 ++-- app/src/pages/Settings.css | 188 +++++++++++++++++++++++++++++++++++++ app/src/pages/Settings.tsx | 186 ++++++++++++++++++++++++++++++++++++ app/vite.config.ts | 7 +- firebase.json | 3 + firestore.rules | 16 ++++ functions/.env.example | 4 - functions/src/github.ts | 75 +++++++++++---- package.json | 1 + pnpm-lock.yaml | 4 +- 13 files changed, 560 insertions(+), 45 deletions(-) create mode 100644 app/src/pages/Settings.css create mode 100644 app/src/pages/Settings.tsx create mode 100644 firestore.rules delete mode 100644 functions/.env.example 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/firebase.ts b/app/src/firebase.ts index b55085e..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,6 +18,19 @@ 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(); diff --git a/app/src/modules/axios.ts b/app/src/modules/axios.ts index 363a815..e259987 100644 --- a/app/src/modules/axios.ts +++ b/app/src/modules/axios.ts @@ -1,8 +1,9 @@ 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 || `https://us-central1-${import.meta.env.VITE_FIREBASE_PROJECT_ID || 'til-alarm'}.cloudfunctions.net` + ? import.meta.env.VITE_FUNCTIONS_URL_PROD : '/api'; // Vite 프록시 사용 // 기본 axios 인스턴스 생성 @@ -14,15 +15,19 @@ export const apiClient = axios.create({ }, }); -// 요청 인터셉터 - GitHub OAuth 토큰을 헤더에 추가 +// 요청 인터셉터 - Firebase ID Token을 헤더에 추가 apiClient.interceptors.request.use( async (config) => { - console.log(`API 요청: ${config.method?.toUpperCase()} ${config.url}`); + // Firebase Auth ID Token 가져오기 + const user = auth.currentUser; - // 로컬 스토리지에서 GitHub OAuth 토큰 가져오기 - const githubToken = localStorage.getItem('github_access_token'); - if (githubToken) { - config.headers['X-GitHub-Token'] = githubToken; + if (user) { + try { + const idToken = await user.getIdToken(); + config.headers['Authorization'] = `Bearer ${idToken}`; + } catch (error) { + console.error('Firebase ID Token 가져오기 실패:', error); + } } return config; @@ -35,10 +40,7 @@ apiClient.interceptors.request.use( // 응답 인터셉터 apiClient.interceptors.response.use( - (response) => { - console.log(`API 응답: ${response.status} ${response.config.url}`); - return response; - }, + (response) => response, (error) => { console.error('API 응답 오류:', error.response?.data || error.message); return Promise.reject(error); diff --git a/app/src/pages/Login.tsx b/app/src/pages/Login.tsx index e111f08..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, GithubAuthProvider } from 'firebase/auth'; -import { auth, githubProvider } from '../firebase'; +import { doc, setDoc, deleteDoc } from 'firebase/firestore'; +import { auth, githubProvider, db } from '../firebase'; import './Login.css'; const Login: React.FC = () => { @@ -25,11 +26,14 @@ const Login: React.FC = () => { setError(''); const result = await signInWithPopup(auth, githubProvider); - // GitHub OAuth 토큰 저장 + // GitHub OAuth 토큰을 Firestore에 저장 + // Google의 at-rest encryption으로 자동 암호화됨 const credential = GithubAuthProvider.credentialFromResult(result); - if (credential && credential.accessToken) { - // GitHub access token을 로컬 스토리지에 저장 - localStorage.setItem('github_access_token', credential.accessToken); + if (credential && credential.accessToken && result.user) { + await setDoc(doc(db, 'users', result.user.uid), { + githubToken: credential.accessToken, + updatedAt: new Date().toISOString(), + }); console.log('로그인 성공 및 GitHub 토큰 저장 완료'); } @@ -45,9 +49,11 @@ const Login: React.FC = () => { // 로그아웃 함수 const handleLogout = async () => { try { + const currentUser = auth.currentUser; await signOut(auth); - // GitHub 토큰도 함께 제거 - localStorage.removeItem('github_access_token'); + 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 1d789e0..e2593a7 100644 --- a/app/vite.config.ts +++ b/app/vite.config.ts @@ -5,8 +5,7 @@ import { VitePWA } from 'vite-plugin-pwa'; export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd(), ''); const functionsUrl = mode === 'production' - ? env.VITE_FUNCTIONS_URL_PROD - : env.VITE_FUNCTIONS_URL_LOCAL; + ? env.VITE_FUNCTIONS_URL_PROD : env.VITE_FUNCTIONS_URL_LOCAL; return { plugins: [ @@ -39,9 +38,9 @@ export default defineConfig(({ mode }) => { open: true, proxy: { '/api': { - target: functionsUrl, + target: functionsUrl || 'http://localhost:5001/til-alarm/us-central1', changeOrigin: true, - secure: true, + secure: false, // 로컬 개발 시 false rewrite: (path) => path.replace(/^\/api/, '') } } diff --git a/firebase.json b/firebase.json index e11eac0..3172742 100644 --- a/firebase.json +++ b/firebase.json @@ -11,6 +11,9 @@ ] } ], + "firestore": { + "rules": "firestore.rules" + }, "hosting": { "public": "app/dist", "ignore": [ 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/.env.example b/functions/.env.example deleted file mode 100644 index 1a1436e..0000000 --- a/functions/.env.example +++ /dev/null @@ -1,4 +0,0 @@ -# GitHub API Token -# GitHub Personal Access Token을 여기에 설정하세요 -# https://github.com/settings/tokens 에서 생성할 수 있습니다 -GITHUB_TOKEN=your_github_token_here \ No newline at end of file diff --git a/functions/src/github.ts b/functions/src/github.ts index a0f96d5..9cfcafd 100644 --- a/functions/src/github.ts +++ b/functions/src/github.ts @@ -1,17 +1,57 @@ import { onRequest } from 'firebase-functions/v2/https'; +import { getAuth } from 'firebase-admin/auth'; +import { getFirestore } from 'firebase-admin/firestore'; /** - * GitHub API 인증 헤더 생성 - * 클라이언트에서 전달받은 사용자의 GitHub OAuth 토큰 사용 + * Firebase ID Token 검증 및 사용자 정보 조회 */ -function getGitHubAuthHeader(req: any): string { - const userToken = req.headers['x-github-token']; +async function getUserData(req: any): Promise<{ + githubToken: string; + githubUsername: string; + repositoryName: string; +}> { + const authHeader = req.headers.authorization; - if (!userToken) { - throw new Error('GitHub token not provided. Please authenticate with GitHub.'); + if (!authHeader || !authHeader.startsWith('Bearer ')) { + throw new Error('Firebase ID token not provided. Please authenticate.'); } - return `Bearer ${userToken}`; + 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 @@ -26,11 +66,12 @@ export const getCommits = onRequest( return; } - const authHeader = getGitHubAuthHeader(req); + const userData = await getUserData(req); + const repoPath = `${userData.githubUsername}/${userData.repositoryName}`; - const response = await fetch(`https://api.github.com/repos/hssuh/TIL/commits?since=${since}&until=${until}`, { + const response = await fetch(`https://api.github.com/repos/${repoPath}/commits?since=${since}&until=${until}`, { headers: { - "Authorization": authHeader, + "Authorization": `Bearer ${userData.githubToken}`, "Accept": "application/vnd.github.v3+json", "X-GitHub-Api-Version": "2022-11-28" } @@ -69,11 +110,12 @@ export const getFilename = onRequest( return; } - const authHeader = getGitHubAuthHeader(req); + const userData = await getUserData(req); + const repoPath = `${userData.githubUsername}/${userData.repositoryName}`; - const response = await fetch(`https://api.github.com/repos/hssuh/TIL/commits/${commit_sha}`, { + const response = await fetch(`https://api.github.com/repos/${repoPath}/commits/${commit_sha}`, { headers: { - "Authorization": authHeader, + "Authorization": `Bearer ${userData.githubToken}`, "Accept": "application/vnd.github.v3+json", "X-GitHub-Api-Version": "2022-11-28" } @@ -112,12 +154,13 @@ export const getMarkdown = onRequest( return; } - const authHeader = getGitHubAuthHeader(req); + const userData = await getUserData(req); + const repoPath = `${userData.githubUsername}/${userData.repositoryName}`; - const response = await fetch(`https://api.github.com/repos/hssuh/TIL/contents/${filename}`, { + const response = await fetch(`https://api.github.com/repos/${repoPath}/contents/${filename}`, { headers: { "Accept": "application/vnd.github.raw", - "Authorization": authHeader, + "Authorization": `Bearer ${userData.githubToken}`, "X-GitHub-Api-Version": "2022-11-28" } }); diff --git a/package.json b/package.json index b56a74e..007e02d 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "scripts": { "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", "push": "firebase deploy --only hosting,functions" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 632a79c..8dc3439 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5270,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: @@ -6497,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