Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 서버리스 종료 방지)
Expand Down Expand Up @@ -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()`) |
Expand Down
4 changes: 2 additions & 2 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Blog Study Admin - 시스템 아키텍처

> 최종 업데이트: 2026-03-18 (v10)
> 최종 업데이트: 2026-03-18 (v11)

블로그 글쓰기 스터디 운영 자동화 플랫폼. 웹 대시보드에서 모든 관리/유저 기능을 제공하고, Discord 봇은 스케줄러(RSS 수집/출석/벌금/큐레이션)와 이벤트 핸들러만 담당한다.

Expand Down Expand Up @@ -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 |
Expand Down
34 changes: 30 additions & 4 deletions packages/web/src/app/(user)/profile/edit/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ interface ProfileData {
name: string;
nickname: string;
part: string;
blogUrl: string;
profileImageUrl: string | null;
bio: string | null;
interests: string[] | null;
Expand All @@ -82,14 +83,14 @@ export default function ProfileEditPage() {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);

// Form state
const [userId, setUserId] = useState('');
const [name, setName] = useState('');
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<string[]>([]);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -161,7 +163,6 @@ export default function ProfileEditPage() {
e.preventDefault();
setSaving(true);
setError(null);
setSuccess(false);

try {
const part = selectedPart === 'other' ? customPart.trim() : selectedPart;
Expand All @@ -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,
Expand All @@ -190,7 +192,6 @@ export default function ProfileEditPage() {
return;
}

setSuccess(true);
toast.success('프로필이 저장되었습니다.');
setTimeout(() => {
router.push('/profile');
Expand Down Expand Up @@ -400,6 +401,31 @@ export default function ProfileEditPage() {
</CardContent>
</Card>

{/* Blog URL */}
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Link2 className="h-5 w-5" />
<CardTitle>블로그</CardTitle>
</div>
<CardDescription>
블로그 주소를 변경하면 RSS URL이 자동으로 재감지됩니다.
</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
<Label htmlFor="blogUrl">
블로그 URL <span className="text-destructive">*</span>
</Label>
<Input
id="blogUrl"
placeholder="https://velog.io/@username"
value={blogUrl}
onChange={(e) => setBlogUrl(e.target.value)}
maxLength={500}
/>
</CardContent>
</Card>

{/* RSS 설정 */}
<Card>
<CardHeader>
Expand Down Expand Up @@ -473,7 +499,6 @@ export default function ProfileEditPage() {

{/* Messages */}
{error && <p className="text-sm text-destructive text-center">{error}</p>}
{success && <p className="text-sm text-success text-center">프로필이 수정되었습니다!</p>}

{/* Submit Button */}
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end sm:gap-4">
Expand All @@ -486,6 +511,7 @@ export default function ProfileEditPage() {
saving ||
!name.trim() ||
!nickname.trim() ||
!blogUrl.trim() ||
interests.length < 3 ||
bio.trim().length < 100
}
Expand Down
81 changes: 71 additions & 10 deletions packages/web/src/app/api/profile/edit/route.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -45,6 +46,7 @@ export async function PUT(request: NextRequest) {
name,
nickname,
part,
blogUrl,
profileImageUrl,
bio,
interests,
Expand All @@ -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();
}
}

Expand All @@ -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({
Expand All @@ -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,
Expand All @@ -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);
Expand Down
Loading