diff --git a/CLAUDE.md b/CLAUDE.md index 13e248e..f5756be 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -60,7 +60,8 @@ pnpm --filter @blog-study/bot rss-collect # 수동 RSS 수집 (봇 없이) - **토스트**: `sonner` 라이브러리 사용 (`toast.success()`, `toast.error()`) — inline 상태 관리 토스트 금지 - **API 응답**: 모든 API 라우트는 `Errors.*()` + `successResponse()` + `errorResponse()` 패턴 사용 (직접 `NextResponse.json` 금지) - **캐시**: 읽기 전용 API에 `withCache(response, maxAge)` 적용 (members: 60s, ranking: 30s) -- **보안**: Tiptap content는 저장 전 `sanitizeTiptapContent()` 적용, 댓글 content는 `sanitizeDescription()` 적용, 외부 URL fetch/저장 시 `isSafeUrl()` SSRF 체크 (blogUrl, profileImageUrl 등 사용자 입력 URL 포함) +- **보안**: Tiptap content는 저장 전 `sanitizeTiptapContent()` 적용, 댓글 content는 `sanitizeDescription()` 적용, 외부 URL fetch/저장 시 `isSafeUrl()` SSRF 체크 (blogUrl, profileImageUrl, 소셜 URL 등 사용자 입력 URL 포함) +- **블로그 URL 수정**: 프로필 수정 시 blogUrl 변경 가능, 변경 시 rssUrl을 null 초기화 후 `after()`로 RSS 비동기 재감지 - **Discord 알림**: 웹에서 직접 Discord REST API 호출 시 `discord-notify.ts` 유틸 사용, 사용자 입력은 `escapeDiscordMarkdown()` 적용, `allowed_mentions: { parse: [] }` 필수 - **댓글 길이**: 최대 5000자 제한 (API에서 검증) - **백그라운드 작업**: API route에서 푸시 알림/점수 부여 등 fire-and-forget 작업은 `after()` from `next/server` 사용 (Vercel 서버리스 종료 방지) @@ -110,6 +111,7 @@ pnpm --filter @blog-study/bot rss-collect # 수동 RSS 수집 (봇 없이) | `packages/web/src/app/(admin)/admin/bot-operations/page.tsx` | 봇 수동 실행 대시보드 (관리자 전용) | | `packages/web/src/app/api/admin/bot-operations/[operationId]/route.ts` | 봇 작업 트리거 프록시 (web → bot HTTP API, 30s 타임아웃) | | `packages/web/src/app/(admin)/admin/rounds/page.tsx` | 회차 관리 페이지 (CRUD + 현재 회차 설정) | +| `packages/web/src/app/api/profile/edit/route.ts` | 프로필 수정 API (blogUrl 변경 시 RSS 재감지, 소셜 URL SSRF 체크) | | `packages/web/src/app/api/posts/[id]/route.ts` | 포스트 삭제 API (본인/관리자, 댓글+조회+점수 일괄 삭제) | | `packages/web/src/app/api/profile/withdraw/route.ts` | 유저 자체 탈퇴 API | | `packages/web/src/lib/firebase/admin.ts` | Firebase Admin SDK (lazy 초기화, `getAdminMessaging()`) | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8436628..f426e3a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,6 +1,6 @@ # Blog Study Admin - 시스템 아키텍처 -> 최종 업데이트: 2026-03-18 (v10) +> 최종 업데이트: 2026-03-18 (v11) 블로그 글쓰기 스터디 운영 자동화 플랫폼. 웹 대시보드에서 모든 관리/유저 기능을 제공하고, Discord 봇은 스케줄러(RSS 수집/출석/벌금/큐레이션)와 이벤트 핸들러만 담당한다. @@ -473,7 +473,7 @@ erDiagram | **인증** | Supabase Auth (Discord OAuth PKCE) + 미들웨어 세션 검증 | `middleware.ts`, `lib/supabase/` | | **인가** | Discord ID 기반 관리자 체크 (`ADMIN_DISCORD_IDS`) | `lib/admin.ts` | | **XSS** | Tiptap JSON content 새니타이즈 (`javascript:`, `data:`, `vbscript:` 프로토콜 차단) | `lib/sanitize.ts` → `api/board/` | -| **SSRF** | 외부 URL fetch/저장 전 `isSafeUrl()` 체크 (private IP, localhost 차단) | `lib/rss-detect.ts` → `api/posts/manual/`, `api/admin/curation/crawl/`, `api/profile/onboarding/` | +| **SSRF** | 외부 URL fetch/저장 전 `isSafeUrl()` 체크 (private IP, localhost 차단) | `lib/rss-detect.ts` → `api/posts/manual/`, `api/admin/curation/crawl/`, `api/profile/onboarding/`, `api/profile/edit/` (blogUrl, profileImageUrl, 소셜 URL) | | **Discord 인젝션** | 사용자 입력 Discord embed에 `escapeDiscordMarkdown()` 적용 + `allowed_mentions: { parse: [] }` | `lib/discord-notify.ts` | | **CSP** | Content-Security-Policy 헤더 (`frame-ancestors 'none'`, 허용 도메인 화이트리스트) | `next.config.ts` | | **SQL Injection** | Drizzle ORM 파라미터화 쿼리 (raw SQL 사용 안 함) | 전체 API Routes | diff --git a/packages/web/src/app/(user)/profile/edit/page.tsx b/packages/web/src/app/(user)/profile/edit/page.tsx index dedee2b..453119b 100644 --- a/packages/web/src/app/(user)/profile/edit/page.tsx +++ b/packages/web/src/app/(user)/profile/edit/page.tsx @@ -65,6 +65,7 @@ interface ProfileData { name: string; nickname: string; part: string; + blogUrl: string; profileImageUrl: string | null; bio: string | null; interests: string[] | null; @@ -82,7 +83,6 @@ export default function ProfileEditPage() { const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); - const [success, setSuccess] = useState(false); // Form state const [userId, setUserId] = useState(''); @@ -90,6 +90,7 @@ export default function ProfileEditPage() { const [nickname, setNickname] = useState(''); const [selectedPart, setSelectedPart] = useState(''); const [customPart, setCustomPart] = useState(''); + const [blogUrl, setBlogUrl] = useState(''); const [profileImageUrl, setProfileImageUrl] = useState(''); const [bio, setBio] = useState(''); const [interests, setInterests] = useState([]); @@ -127,6 +128,7 @@ export default function ProfileEditPage() { setCustomPart(data.member.part); } } + if (data.member.blogUrl) setBlogUrl(data.member.blogUrl); if (data.member.profileImageUrl) setProfileImageUrl(data.member.profileImageUrl); if (data.member.bio) setBio(data.member.bio); if (data.member.interests) setInterests(data.member.interests); @@ -161,7 +163,6 @@ export default function ProfileEditPage() { e.preventDefault(); setSaving(true); setError(null); - setSuccess(false); try { const part = selectedPart === 'other' ? customPart.trim() : selectedPart; @@ -172,6 +173,7 @@ export default function ProfileEditPage() { name: name.trim() || null, nickname: nickname.trim() || null, part: part || null, + blogUrl: blogUrl.trim() || null, profileImageUrl: profileImageUrl || null, bio: bio || null, interests: interests.length > 0 ? interests : null, @@ -190,7 +192,6 @@ export default function ProfileEditPage() { return; } - setSuccess(true); toast.success('프로필이 저장되었습니다.'); setTimeout(() => { router.push('/profile'); @@ -400,6 +401,31 @@ export default function ProfileEditPage() { + {/* Blog URL */} + + +
+ + 블로그 +
+ + 블로그 주소를 변경하면 RSS URL이 자동으로 재감지됩니다. + +
+ + + setBlogUrl(e.target.value)} + maxLength={500} + /> + +
+ {/* RSS 설정 */} @@ -473,7 +499,6 @@ export default function ProfileEditPage() { {/* Messages */} {error &&

{error}

} - {success &&

프로필이 수정되었습니다!

} {/* Submit Button */}
@@ -486,6 +511,7 @@ export default function ProfileEditPage() { saving || !name.trim() || !nickname.trim() || + !blogUrl.trim() || interests.length < 3 || bio.trim().length < 100 } diff --git a/packages/web/src/app/api/profile/edit/route.ts b/packages/web/src/app/api/profile/edit/route.ts index 51a67b6..3c63e6c 100644 --- a/packages/web/src/app/api/profile/edit/route.ts +++ b/packages/web/src/app/api/profile/edit/route.ts @@ -1,9 +1,10 @@ -import { NextRequest } from 'next/server'; +import { after, NextRequest } from 'next/server'; import { eq } from 'drizzle-orm'; import { db } from '@/lib/db'; import { db as sharedDb } from '@blog-study/shared'; import { createClient } from '@/lib/supabase/server'; import { errorResponse, Errors, successResponse } from '@/lib/api-error'; +import { detectRssUrl, isSafeUrl } from '@/lib/rss-detect'; const { members } = sharedDb; @@ -45,6 +46,7 @@ export async function PUT(request: NextRequest) { name, nickname, part, + blogUrl, profileImageUrl, bio, interests, @@ -55,20 +57,52 @@ export async function PUT(request: NextRequest) { rssConsent, } = body; + // --- 검증 --- + + if (name && (typeof name !== 'string' || name.trim().length > 50)) { + return Errors.badRequest('이름은 50자 이내여야 합니다.').toResponse(); + } + + if (nickname && (typeof nickname !== 'string' || nickname.trim().length > 100)) { + return Errors.badRequest('닉네임은 100자 이내여야 합니다.').toResponse(); + } + if (part && (typeof part !== 'string' || part.length > 50)) { return Errors.badRequest('파트는 50자 이내의 문자열이어야 합니다.').toResponse(); } - if (profileImageUrl) { - try { - const url = new URL(profileImageUrl); - if (!['http:', 'https:'].includes(url.protocol)) { - return Errors.badRequest( - '프로필 이미지 URL은 http 또는 https만 허용됩니다.' - ).toResponse(); + // 블로그 URL 검증 + const trimmedBlogUrl = typeof blogUrl === 'string' ? blogUrl.trim() : null; + const blogUrlChanged = + typeof blogUrl === 'string' && + trimmedBlogUrl !== null && + trimmedBlogUrl.length > 0 && + trimmedBlogUrl !== memberData.blogUrl; + + if (blogUrlChanged) { + if (trimmedBlogUrl!.length > 500) { + return Errors.badRequest('블로그 URL은 500자 이내여야 합니다.').toResponse(); + } + if (!isSafeUrl(trimmedBlogUrl!)) { + return Errors.badRequest('유효하지 않은 블로그 URL입니다.').toResponse(); + } + } + + // 프로필 이미지 URL 검증 (SSRF 방지) + if (profileImageUrl && !isSafeUrl(profileImageUrl)) { + return Errors.badRequest('유효하지 않은 프로필 이미지 URL입니다.').toResponse(); + } + + // 소셜 URL 검증 (SSRF 방지) + const socialUrls = { githubUrl, linkedinUrl, instagramUrl }; + for (const [key, value] of Object.entries(socialUrls)) { + if (value && typeof value === 'string' && value.trim().length > 0) { + if (value.length > 500) { + return Errors.badRequest(`${key}은 500자 이내여야 합니다.`).toResponse(); + } + if (!isSafeUrl(value)) { + return Errors.badRequest(`유효하지 않은 ${key}입니다.`).toResponse(); } - } catch { - return Errors.badRequest('유효하지 않은 프로필 이미지 URL입니다.').toResponse(); } } @@ -93,6 +127,15 @@ export async function PUT(request: NextRequest) { } } + // --- DB 업데이트 --- + + // 블로그 URL 변경 시: 즉시 blogUrl 저장 + rssUrl null 초기화, RSS 감지는 after()로 비동기 처리 + const blogFields: { blogUrl?: string; rssUrl?: string | null } = {}; + if (blogUrlChanged) { + blogFields.blogUrl = trimmedBlogUrl!; + blogFields.rssUrl = null; + } + await database .update(members) .set({ @@ -103,6 +146,7 @@ export async function PUT(request: NextRequest) { ? { nickname: nickname.trim() } : {}), ...(part ? { part } : {}), + ...blogFields, profileImageUrl: profileImageUrl || null, bio: bio || null, interests: interests || null, @@ -115,6 +159,23 @@ export async function PUT(request: NextRequest) { }) .where(eq(members.id, memberData.id)); + // RSS URL 비동기 감지 (fire-and-forget) + if (blogUrlChanged) { + after(async () => { + try { + const newRssUrl = await detectRssUrl(trimmedBlogUrl!); + if (newRssUrl) { + await db() + .update(members) + .set({ rssUrl: newRssUrl, updatedAt: new Date() }) + .where(eq(members.id, memberData.id)); + } + } catch (err) { + console.error('[profile-edit] RSS 재감지 실패:', err); + } + }); + } + return successResponse(null, '프로필이 수정되었습니다.'); } catch (error) { console.error('Profile edit API error:', error);