fix: security hardening and performance improvements
Security: - Add token+secret auth to /setup-webhook and /webhook-info endpoints - Disable /api/test in production environment (ENVIRONMENT=production) Performance: - Add retryWithBackoff to weather-tool (maxRetries: 2) - Add KV caching to executeLookupDocs (1h TTL) Code Quality: - Centralize error messages in src/constants/messages.ts - Update 5 files to use centralized error constants Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
13
CLAUDE.md
13
CLAUDE.md
@@ -213,8 +213,11 @@ wrangler secret put DEPOSIT_API_SECRET # Deposit API 인증 키
|
||||
|
||||
**Webhook 설정:**
|
||||
```bash
|
||||
curl https://telegram-summary-bot.kappa-d8e.workers.dev/setup-webhook
|
||||
curl https://telegram-summary-bot.kappa-d8e.workers.dev/webhook-info
|
||||
# Webhook 설정 (token + secret 필요)
|
||||
curl "https://telegram-summary-bot.kappa-d8e.workers.dev/setup-webhook?token=${BOT_TOKEN}&secret=${WEBHOOK_SECRET}"
|
||||
|
||||
# Webhook 정보 조회 (token + secret 필요)
|
||||
curl "https://telegram-summary-bot.kappa-d8e.workers.dev/webhook-info?token=${BOT_TOKEN}&secret=${WEBHOOK_SECRET}"
|
||||
```
|
||||
|
||||
**Database Migrations:**
|
||||
@@ -377,8 +380,8 @@ curl -X POST http://localhost:8787/webhook \
|
||||
# 로그 스트리밍
|
||||
npm run tail
|
||||
|
||||
# Webhook 상태 확인
|
||||
curl https://telegram-summary-bot.kappa-d8e.workers.dev/webhook-info
|
||||
# Webhook 상태 확인 (token + secret 필요)
|
||||
curl "https://telegram-summary-bot.kappa-d8e.workers.dev/webhook-info?token=${BOT_TOKEN}&secret=${WEBHOOK_SECRET}"
|
||||
```
|
||||
|
||||
**수동 테스트 예제** (자동화 예정):
|
||||
@@ -423,7 +426,7 @@ wrangler d1 execute telegram-conversations --command "SELECT * FROM users LIMIT
|
||||
| 엔드포인트 | 보안 수준 | 설명 |
|
||||
|-----------|----------|------|
|
||||
| `/health` | 최소 정보만 | status, timestamp만 반환 (DB 정보 미노출) |
|
||||
| `/webhook-info` | BOT_TOKEN 필요 | Telegram Webhook 상태 조회 |
|
||||
| `/webhook-info` | BOT_TOKEN + WEBHOOK_SECRET 필요 | Telegram Webhook 상태 조회 |
|
||||
| `/setup-webhook` | BOT_TOKEN + WEBHOOK_SECRET 필요 | Webhook 설정 |
|
||||
|
||||
**인증 필요 엔드포인트:**
|
||||
|
||||
@@ -135,8 +135,8 @@ npm run deploy
|
||||
# Health check
|
||||
curl https://telegram-summary-bot.kappa-d8e.workers.dev/health
|
||||
|
||||
# Webhook 상태
|
||||
curl https://telegram-summary-bot.kappa-d8e.workers.dev/webhook-info
|
||||
# Webhook 상태 (token + secret 필요)
|
||||
curl "https://telegram-summary-bot.kappa-d8e.workers.dev/webhook-info?token=${BOT_TOKEN}&secret=${WEBHOOK_SECRET}"
|
||||
|
||||
# 실시간 로그
|
||||
npm run tail
|
||||
@@ -145,8 +145,8 @@ npm run tail
|
||||
### 6. Webhook 연결
|
||||
|
||||
```bash
|
||||
# 웹훅 설정 (배포된 URL 사용)
|
||||
curl https://<YOUR_WORKER_URL>/setup-webhook
|
||||
# 웹훅 설정 (token + secret 필요)
|
||||
curl "https://<YOUR_WORKER_URL>/setup-webhook?token=${BOT_TOKEN}&secret=${WEBHOOK_SECRET}"
|
||||
```
|
||||
|
||||
#### ⚠️ 주의사항
|
||||
|
||||
31
src/constants/messages.ts
Normal file
31
src/constants/messages.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 에러 메시지 상수
|
||||
*
|
||||
* 사용자 친화적 에러 메시지를 중앙에서 관리합니다.
|
||||
* 모든 에러 메시지는 이 파일에서 정의하고 가져와 사용하세요.
|
||||
*/
|
||||
|
||||
export const ERROR_MESSAGES = {
|
||||
// 일반 서비스 에러
|
||||
SERVICE_UNAVAILABLE: '죄송합니다. 일시적으로 서비스를 이용할 수 없습니다. 잠시 후 다시 시도해주세요.',
|
||||
AI_RESPONSE_FAILED: '죄송합니다. AI 응답 생성에 실패했습니다. 잠시 후 다시 시도해주세요.',
|
||||
UNEXPECTED_ERROR: '죄송합니다. 예상치 못한 오류가 발생했습니다.',
|
||||
TEMPORARY_ERROR: '⚠️ 일시적인 오류가 발생했습니다. 잠시 후 다시 시도해주세요.',
|
||||
|
||||
// 프로필 생성 관련
|
||||
PROFILE_GENERATION_FAILED: '프로필 생성 실패: 일시적으로 서비스를 이용할 수 없습니다.',
|
||||
PROFILE_GENERATION_UNEXPECTED: '프로필 생성 실패: 예상치 못한 오류',
|
||||
|
||||
// 검색 관련
|
||||
SEARCH_SERVICE_UNAVAILABLE: '🔍 검색 서비스에 일시적으로 접근할 수 없습니다. 잠시 후 다시 시도해주세요.',
|
||||
DOCS_SERVICE_UNAVAILABLE: '📚 문서 조회 서비스에 일시적으로 접근할 수 없습니다. 잠시 후 다시 시도해주세요.',
|
||||
|
||||
// 도메인 관련
|
||||
WHOIS_SERVICE_UNAVAILABLE: 'WHOIS 조회 서비스에 일시적으로 접근할 수 없습니다.',
|
||||
DOMAIN_SERVICE_UNAVAILABLE: '🚫 도메인 추천 서비스에 일시적으로 접근할 수 없습니다. 잠시 후 다시 시도해주세요.',
|
||||
|
||||
// 날씨 관련
|
||||
WEATHER_SERVICE_UNAVAILABLE: '날씨 정보를 가져올 수 없습니다',
|
||||
} as const;
|
||||
|
||||
export type ErrorMessageKey = keyof typeof ERROR_MESSAGES;
|
||||
24
src/index.ts
24
src/index.ts
@@ -21,6 +21,16 @@ export default {
|
||||
return Response.json({ error: 'WEBHOOK_SECRET not configured' }, { status: 500 });
|
||||
}
|
||||
|
||||
// 인증: token + secret 검증
|
||||
const token = url.searchParams.get('token');
|
||||
const secret = url.searchParams.get('secret');
|
||||
if (!token || token !== env.BOT_TOKEN) {
|
||||
return new Response('Unauthorized: Invalid or missing token', { status: 401 });
|
||||
}
|
||||
if (!secret || secret !== env.WEBHOOK_SECRET) {
|
||||
return new Response('Unauthorized: Invalid or missing secret', { status: 401 });
|
||||
}
|
||||
|
||||
const webhookUrl = `${url.origin}/webhook`;
|
||||
const result = await setWebhook(env.BOT_TOKEN, webhookUrl, env.WEBHOOK_SECRET);
|
||||
return Response.json(result);
|
||||
@@ -31,6 +41,20 @@ export default {
|
||||
if (!env.BOT_TOKEN) {
|
||||
return Response.json({ error: 'BOT_TOKEN not configured' }, { status: 500 });
|
||||
}
|
||||
if (!env.WEBHOOK_SECRET) {
|
||||
return Response.json({ error: 'WEBHOOK_SECRET not configured' }, { status: 500 });
|
||||
}
|
||||
|
||||
// 인증: token + secret 검증
|
||||
const token = url.searchParams.get('token');
|
||||
const secret = url.searchParams.get('secret');
|
||||
if (!token || token !== env.BOT_TOKEN) {
|
||||
return new Response('Unauthorized: Invalid or missing token', { status: 401 });
|
||||
}
|
||||
if (!secret || secret !== env.WEBHOOK_SECRET) {
|
||||
return new Response('Unauthorized: Invalid or missing secret', { status: 401 });
|
||||
}
|
||||
|
||||
const result = await getWebhookInfo(env.BOT_TOKEN);
|
||||
return Response.json(result);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { CircuitBreaker, CircuitBreakerError } from './utils/circuit-breaker';
|
||||
import { createLogger } from './utils/logger';
|
||||
import { metrics } from './utils/metrics';
|
||||
import { getOpenAIUrl } from './utils/api-urls';
|
||||
import { ERROR_MESSAGES } from './constants/messages';
|
||||
|
||||
const logger = createLogger('openai');
|
||||
|
||||
@@ -175,17 +176,17 @@ export async function generateOpenAIResponse(
|
||||
// 에러 처리
|
||||
if (error instanceof CircuitBreakerError) {
|
||||
logger.error('Circuit breaker open', error as Error);
|
||||
return '죄송합니다. 일시적으로 서비스를 이용할 수 없습니다. 잠시 후 다시 시도해주세요.';
|
||||
return ERROR_MESSAGES.SERVICE_UNAVAILABLE;
|
||||
}
|
||||
|
||||
if (error instanceof RetryError) {
|
||||
logger.error('All retry attempts failed', error as Error);
|
||||
return '죄송합니다. AI 응답 생성에 실패했습니다. 잠시 후 다시 시도해주세요.';
|
||||
return ERROR_MESSAGES.AI_RESPONSE_FAILED;
|
||||
}
|
||||
|
||||
// 기타 에러
|
||||
logger.error('Unexpected error', error as Error);
|
||||
return '죄송합니다. 예상치 못한 오류가 발생했습니다.';
|
||||
return ERROR_MESSAGES.UNEXPECTED_ERROR;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,16 +217,16 @@ export async function generateProfileWithOpenAI(
|
||||
// 에러 처리
|
||||
if (error instanceof CircuitBreakerError) {
|
||||
logger.error('Profile - Circuit breaker open', error as Error);
|
||||
return '프로필 생성 실패: 일시적으로 서비스를 이용할 수 없습니다.';
|
||||
return ERROR_MESSAGES.PROFILE_GENERATION_FAILED;
|
||||
}
|
||||
|
||||
if (error instanceof RetryError) {
|
||||
logger.error('Profile - All retry attempts failed', error as Error);
|
||||
return '프로필 생성 실패: 재시도 횟수 초과';
|
||||
return ERROR_MESSAGES.PROFILE_GENERATION_FAILED;
|
||||
}
|
||||
|
||||
// 기타 에러
|
||||
logger.error('Profile - Unexpected error', error as Error);
|
||||
return '프로필 생성 실패: 예상치 못한 오류';
|
||||
return ERROR_MESSAGES.PROFILE_GENERATION_UNEXPECTED;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,11 +270,18 @@ async function handleDepositDeduct(request: Request, env: Env): Promise<Response
|
||||
/**
|
||||
* POST /api/test - 테스트 API (메시지 처리 후 응답 직접 반환)
|
||||
*
|
||||
* ⚠️ 개발 환경 전용 - 프로덕션에서는 비활성화
|
||||
*
|
||||
* @param request - HTTP Request with body
|
||||
* @param env - Environment bindings
|
||||
* @returns JSON response with AI response
|
||||
*/
|
||||
async function handleTestApi(request: Request, env: Env): Promise<Response> {
|
||||
// 프로덕션 환경에서는 비활성화
|
||||
if (env.ENVIRONMENT === 'production') {
|
||||
return new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
try {
|
||||
// JSON 파싱 (별도 에러 핸들링)
|
||||
let jsonData: unknown;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { executeDomainRegister } from '../domain-register';
|
||||
import { handleCommand } from '../commands';
|
||||
import { UserService } from '../services/user-service';
|
||||
import { ConversationService } from '../services/conversation-service';
|
||||
import { ERROR_MESSAGES } from '../constants/messages';
|
||||
|
||||
/**
|
||||
* Safely parse integer with range validation
|
||||
@@ -61,7 +62,7 @@ async function handleMessage(
|
||||
await sendMessage(
|
||||
env.BOT_TOKEN,
|
||||
chatId,
|
||||
'⚠️ 일시적인 오류가 발생했습니다. 잠시 후 다시 시도해주세요.'
|
||||
ERROR_MESSAGES.TEMPORARY_ERROR
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
import { retryWithBackoff, RetryError } from '../utils/retry';
|
||||
import { createLogger, maskUserId } from '../utils/logger';
|
||||
import { getOpenAIUrl } from '../utils/api-urls';
|
||||
import { ERROR_MESSAGES } from '../constants/messages';
|
||||
|
||||
const logger = createLogger('domain-tool');
|
||||
|
||||
@@ -405,7 +406,7 @@ async function callNamecheapApi(
|
||||
} catch (error) {
|
||||
logger.error('오류', error as Error, { domain: funcArgs.domain });
|
||||
if (error instanceof RetryError) {
|
||||
return { error: 'WHOIS 조회 서비스에 일시적으로 접근할 수 없습니다.' };
|
||||
return { error: ERROR_MESSAGES.WHOIS_SERVICE_UNAVAILABLE };
|
||||
}
|
||||
return { error: `WHOIS 조회 오류: ${String(error)}` };
|
||||
}
|
||||
@@ -1063,7 +1064,7 @@ ${excludeList ? `- 다음 도메인은 제외하세요: ${excludeList}` : ''}
|
||||
} catch (error) {
|
||||
logger.error('오류', error as Error, { keywords });
|
||||
if (error instanceof RetryError) {
|
||||
return `🚫 도메인 추천 서비스에 일시적으로 접근할 수 없습니다. 잠시 후 다시 시도해주세요.`;
|
||||
return ERROR_MESSAGES.DOMAIN_SERVICE_UNAVAILABLE;
|
||||
}
|
||||
return `🚫 도메인 추천 중 오류가 발생했습니다: ${String(error)}`;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
import { retryWithBackoff, RetryError } from '../utils/retry';
|
||||
import { createLogger } from '../utils/logger';
|
||||
import { getOpenAIUrl } from '../utils/api-urls';
|
||||
import { ERROR_MESSAGES } from '../constants/messages';
|
||||
|
||||
const logger = createLogger('search-tool');
|
||||
|
||||
@@ -163,7 +164,7 @@ export async function executeSearchWeb(args: { query: string }, env?: Env): Prom
|
||||
} catch (error) {
|
||||
logger.error('오류', error as Error);
|
||||
if (error instanceof RetryError) {
|
||||
return `🔍 검색 서비스에 일시적으로 접근할 수 없습니다. 잠시 후 다시 시도해주세요.`;
|
||||
return ERROR_MESSAGES.SEARCH_SERVICE_UNAVAILABLE;
|
||||
}
|
||||
return `검색 중 오류가 발생했습니다: ${String(error)}`;
|
||||
}
|
||||
@@ -172,8 +173,20 @@ export async function executeSearchWeb(args: { query: string }, env?: Env): Prom
|
||||
export async function executeLookupDocs(args: { library: string; query: string }, env?: Env): Promise<string> {
|
||||
const { library, query } = args;
|
||||
try {
|
||||
// 캐시 키 생성
|
||||
const cacheKey = `docs:${library}:${query}`;
|
||||
|
||||
// 1. 캐시 확인
|
||||
if (env?.RATE_LIMIT_KV) {
|
||||
const cached = await env.RATE_LIMIT_KV.get(cacheKey);
|
||||
if (cached) {
|
||||
logger.info('문서 캐시 히트', { library, query });
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
// Context7 REST API 직접 호출
|
||||
// 1. 라이브러리 검색
|
||||
// 2. 라이브러리 검색
|
||||
const searchUrl = `${env?.CONTEXT7_API_BASE || 'https://context7.com/api/v2'}/libs/search?libraryName=${encodeURIComponent(library)}&query=${encodeURIComponent(query)}`;
|
||||
const searchResponse = await retryWithBackoff(
|
||||
() => fetch(searchUrl),
|
||||
@@ -187,7 +200,7 @@ export async function executeLookupDocs(args: { library: string; query: string }
|
||||
|
||||
const libraryId = searchData.libraries[0].id;
|
||||
|
||||
// 2. 문서 조회
|
||||
// 3. 문서 조회
|
||||
const docsUrl = `${env?.CONTEXT7_API_BASE || 'https://context7.com/api/v2'}/context?libraryId=${encodeURIComponent(libraryId)}&query=${encodeURIComponent(query)}`;
|
||||
const docsResponse = await retryWithBackoff(
|
||||
() => fetch(docsUrl),
|
||||
@@ -200,11 +213,19 @@ export async function executeLookupDocs(args: { library: string; query: string }
|
||||
}
|
||||
|
||||
const content = docsData.context || docsData.content || JSON.stringify(docsData, null, 2);
|
||||
return `📚 ${library} 문서 (${query}):\n\n${content.slice(0, 1500)}`;
|
||||
const result = `📚 ${library} 문서 (${query}):\n\n${content.slice(0, 1500)}`;
|
||||
|
||||
// 4. 결과 캐싱 (1시간 TTL)
|
||||
if (env?.RATE_LIMIT_KV) {
|
||||
await env.RATE_LIMIT_KV.put(cacheKey, result, { expirationTtl: 3600 });
|
||||
logger.info('문서 캐싱', { library, query });
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error('오류', error as Error);
|
||||
if (error instanceof RetryError) {
|
||||
return `📚 문서 조회 서비스에 일시적으로 접근할 수 없습니다. 잠시 후 다시 시도해주세요.`;
|
||||
return ERROR_MESSAGES.DOCS_SERVICE_UNAVAILABLE;
|
||||
}
|
||||
return `📚 문서 조회 중 오류: ${String(error)}`;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// Weather Tool - wttr.in integration
|
||||
import type { Env } from '../types';
|
||||
import { retryWithBackoff } from '../utils/retry';
|
||||
import { ERROR_MESSAGES } from '../constants/messages';
|
||||
|
||||
// wttr.in API 응답 타입 정의
|
||||
interface WttrCurrentCondition {
|
||||
@@ -56,8 +58,9 @@ export async function executeWeather(args: { city: string }, env?: Env): Promise
|
||||
const city = args.city || 'Seoul';
|
||||
try {
|
||||
const wttrUrl = env?.WTTR_IN_URL || 'https://wttr.in';
|
||||
const response = await fetch(
|
||||
`${wttrUrl}/${encodeURIComponent(city)}?format=j1`
|
||||
const response = await retryWithBackoff(
|
||||
() => fetch(`${wttrUrl}/${encodeURIComponent(city)}?format=j1`),
|
||||
{ maxRetries: 2, initialDelayMs: 500 }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -68,7 +71,7 @@ export async function executeWeather(args: { city: string }, env?: Env): Promise
|
||||
|
||||
// 안전한 접근 - 데이터 유효성 확인
|
||||
if (!data.current_condition?.[0]) {
|
||||
return `날씨 정보를 가져올 수 없습니다: ${city}`;
|
||||
return `${ERROR_MESSAGES.WEATHER_SERVICE_UNAVAILABLE}: ${city}`;
|
||||
}
|
||||
|
||||
const current = data.current_condition[0];
|
||||
@@ -84,6 +87,6 @@ export async function executeWeather(args: { city: string }, env?: Env): Promise
|
||||
습도: ${current.humidity}%
|
||||
풍속: ${current.windspeedKmph} km/h`;
|
||||
} catch (error) {
|
||||
return `날씨 정보를 가져올 수 없습니다: ${city}`;
|
||||
return `${ERROR_MESSAGES.WEATHER_SERVICE_UNAVAILABLE}: ${city}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ export interface Env {
|
||||
AI: Ai;
|
||||
BOT_TOKEN: string;
|
||||
WEBHOOK_SECRET: string;
|
||||
ENVIRONMENT?: string;
|
||||
SUMMARY_THRESHOLD?: string;
|
||||
MAX_SUMMARIES_PER_USER?: string;
|
||||
N8N_WEBHOOK_URL?: string;
|
||||
|
||||
@@ -6,6 +6,7 @@ compatibility_date = "2024-01-01"
|
||||
binding = "AI"
|
||||
|
||||
[vars]
|
||||
ENVIRONMENT = "production" # 환경 설정 (production | development)
|
||||
SUMMARY_THRESHOLD = "20" # 프로필 업데이트 주기 (메시지 수)
|
||||
MAX_SUMMARIES_PER_USER = "3" # 유지할 프로필 버전 수 (슬라이딩 윈도우)
|
||||
N8N_WEBHOOK_URL = "https://n8n.anvil.it.com" # n8n 연동 (선택)
|
||||
|
||||
Reference in New Issue
Block a user