diff --git a/CLAUDE.md b/CLAUDE.md index 2521de5..13e248e 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 체크 +- **보안**: Tiptap content는 저장 전 `sanitizeTiptapContent()` 적용, 댓글 content는 `sanitizeDescription()` 적용, 외부 URL fetch/저장 시 `isSafeUrl()` SSRF 체크 (blogUrl, profileImageUrl 등 사용자 입력 URL 포함) +- **Discord 알림**: 웹에서 직접 Discord REST API 호출 시 `discord-notify.ts` 유틸 사용, 사용자 입력은 `escapeDiscordMarkdown()` 적용, `allowed_mentions: { parse: [] }` 필수 - **댓글 길이**: 최대 5000자 제한 (API에서 검증) - **백그라운드 작업**: API route에서 푸시 알림/점수 부여 등 fire-and-forget 작업은 `after()` from `next/server` 사용 (Vercel 서버리스 종료 방지) - **비밀댓글 알림**: 비밀댓글(`isSecret`)의 푸시 알림은 내용 마스킹 (`'비밀 댓글이 달렸습니다.'`), 포스트/게시판 댓글 모두 적용 @@ -123,7 +124,8 @@ pnpm --filter @blog-study/bot rss-collect # 수동 RSS 수집 (봇 없이) | `packages/bot/src/scripts/setup-channels.ts` | 디스코드 채널 일괄 생성 스크립트 | | `packages/bot/src/scripts/list-channels.ts` | 서버 채널 구조 조회 스크립트 | | `packages/bot/src/api-server.ts` | 봇 HTTP API 서버 (Express, 수동 트리거 엔드포인트, rate limit 10/min) | -| `packages/bot/src/services/round.service.ts` | 회차 관리 + ConfigKeys (announcement/notice/curation 채널) | +| `packages/web/src/lib/discord-notify.ts` | Discord REST API 채널 메시지 전송 유틸 (웹→Discord 직접 알림) | +| `packages/bot/src/services/round.service.ts` | 회차 관리 + ConfigKeys (announcement/notice/curation/admin_notification 채널) | | `packages/web/src/components/landing/landing-client.tsx` | 랜딩 페이지 클라이언트 (7섹션: Hero, Stats, Bento, HowItWorks, Marquee, CTA, Footer) | | `packages/web/src/components/landing/motion.tsx` | 랜딩 애니메이션 컴포넌트 (FadeUp, StaggerContainer, CountUp, DrawLine) | | `packages/web/src/app/opengraph-image.tsx` | OG 이미지 동적 생성 (Edge Runtime, `next/og` ImageResponse, 1200×630) | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8dee19f..8436628 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -25,6 +25,7 @@ graph TB subgraph DCH_OPS["🔧 운영 (관리자)"] CH_OPS["#운영-논의"] CH_LOG["#봇-로그"] + CH_ADMIN["#관리자-알림
admin_notification_channel_id"] CH_PR["#github-pr"] CH_ERR["#서버-에러"] CH_SUGGEST["#건의사항-알림"] @@ -69,6 +70,7 @@ graph TB Bot -->|service_role key| TABLES API -->|POST /api/trigger/*| BOT_API BOT_API --> SCH + API -->|Discord REST API| CH_ADMIN Web -->|HTTPS| DB MW --> AUTH @@ -471,7 +473,8 @@ 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/` | +| **SSRF** | 외부 URL fetch/저장 전 `isSafeUrl()` 체크 (private IP, localhost 차단) | `lib/rss-detect.ts` → `api/posts/manual/`, `api/admin/curation/crawl/`, `api/profile/onboarding/` | +| **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 | | **CSRF** | Supabase Auth 쿠키 `SameSite=Lax` | Supabase 기본 설정 | diff --git a/packages/web/next-env.d.ts b/packages/web/next-env.d.ts index c4b7818..1511519 100644 --- a/packages/web/next-env.d.ts +++ b/packages/web/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import './.next/types/routes.d.ts'; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/packages/web/src/app/(admin)/admin/settings/page.tsx b/packages/web/src/app/(admin)/admin/settings/page.tsx index 85fb359..2cbd88b 100644 --- a/packages/web/src/app/(admin)/admin/settings/page.tsx +++ b/packages/web/src/app/(admin)/admin/settings/page.tsx @@ -26,6 +26,7 @@ interface StudySettings { noticeChannelId: string | null; rankingChannelId: string | null; botLogChannelId: string | null; + adminNotificationChannelId: string | null; adminDiscordIds: string; studyRoleId: string | null; } @@ -72,6 +73,7 @@ export default function AdminSettingsPage() { noticeChannelId: null, rankingChannelId: null, botLogChannelId: null, + adminNotificationChannelId: null, adminDiscordIds: '', studyRoleId: null, }); @@ -344,6 +346,18 @@ export default function AdminSettingsPage() { 벌금 납부 알림 등 봇 운영 로그가 발송되는 채널 (#봇-로그)

+
+ + handleInputChange('adminNotificationChannelId', e.target.value)} + placeholder="예: 1234567890123456789" + /> +

+ 신규 가입 승인대기 등 관리자 알림이 발송되는 채널 +

+
diff --git a/packages/web/src/app/api/admin/settings/route.ts b/packages/web/src/app/api/admin/settings/route.ts index a12fd9b..ddb9c0c 100644 --- a/packages/web/src/app/api/admin/settings/route.ts +++ b/packages/web/src/app/api/admin/settings/route.ts @@ -74,6 +74,7 @@ export const GET = withAdminAuth(async (_request: NextRequest, adminAuth) => { noticeChannelId: settings['notice_channel_id'] || null, rankingChannelId: settings['ranking_channel_id'] || null, botLogChannelId: settings['bot_log_channel_id'] || null, + adminNotificationChannelId: settings['admin_notification_channel_id'] || null, adminDiscordIds: settings['admin_discord_ids'] || '', studyRoleId: settings['study_role_id'] || null, }, @@ -107,6 +108,7 @@ export const PATCH = withAdminAuth(async (request: NextRequest, _adminAuth) => { noticeChannelId: 'notice_channel_id', rankingChannelId: 'ranking_channel_id', botLogChannelId: 'bot_log_channel_id', + adminNotificationChannelId: 'admin_notification_channel_id', adminDiscordIds: 'admin_discord_ids', studyRoleId: 'study_role_id', }; @@ -118,6 +120,7 @@ export const PATCH = withAdminAuth(async (request: NextRequest, _adminAuth) => { 'notice_channel_id', 'ranking_channel_id', 'bot_log_channel_id', + 'admin_notification_channel_id', 'study_role_id', ]); diff --git a/packages/web/src/app/api/profile/onboarding/route.ts b/packages/web/src/app/api/profile/onboarding/route.ts index 13d1913..991026e 100644 --- a/packages/web/src/app/api/profile/onboarding/route.ts +++ b/packages/web/src/app/api/profile/onboarding/route.ts @@ -1,8 +1,11 @@ -import { NextRequest, NextResponse } from 'next/server'; +import { after, NextRequest, NextResponse } from 'next/server'; import { eq } from 'drizzle-orm'; import { db } from '@/lib/db'; import { db as sharedDb } from '@blog-study/shared'; +import { config } from '@blog-study/shared/db'; import { createClient } from '@/lib/supabase/server'; +import { notifyNewMemberPendingApproval } from '@/lib/discord-notify'; +import { isSafeUrl } from '@/lib/rss-detect'; const { members } = sharedDb; @@ -14,18 +17,16 @@ const { members } = sharedDb; export async function POST(request: NextRequest) { try { const supabase = await createClient(); - const { data: { user }, error } = await supabase.auth.getUser(); + const { + data: { user }, + error, + } = await supabase.auth.getUser(); if (error || !user) { - return NextResponse.json( - { message: '인증이 필요합니다.' }, - { status: 401 } - ); + return NextResponse.json({ message: '인증이 필요합니다.' }, { status: 401 }); } - const discordIdentity = user.identities?.find( - (identity) => identity.provider === 'discord' - ); + const discordIdentity = user.identities?.find((identity) => identity.provider === 'discord'); const discordId = discordIdentity?.id as string | undefined; if (!discordId) { return NextResponse.json( @@ -35,65 +36,49 @@ export async function POST(request: NextRequest) { } const body = await request.json(); - const { name, nickname, part, blogUrl, profileImageUrl, bio, interests, resolution, githubUrl, linkedinUrl, instagramUrl, rssConsent } = body; + const { + name, + nickname, + part, + blogUrl, + profileImageUrl, + bio, + interests, + resolution, + githubUrl, + linkedinUrl, + instagramUrl, + rssConsent, + } = body; // 필수 필드 검증 if (!name || typeof name !== 'string' || name.trim().length === 0) { - return NextResponse.json( - { message: '이름(실명)은 필수입니다.' }, - { status: 400 } - ); + return NextResponse.json({ message: '이름(실명)은 필수입니다.' }, { status: 400 }); } if (!nickname || typeof nickname !== 'string' || nickname.trim().length === 0) { - return NextResponse.json( - { message: '닉네임은 필수입니다.' }, - { status: 400 } - ); + return NextResponse.json({ message: '닉네임은 필수입니다.' }, { status: 400 }); } if (!part || typeof part !== 'string') { - return NextResponse.json( - { message: '파트는 필수입니다.' }, - { status: 400 } - ); + return NextResponse.json({ message: '파트는 필수입니다.' }, { status: 400 }); } if (!blogUrl || typeof blogUrl !== 'string') { - return NextResponse.json( - { message: '블로그 URL은 필수입니다.' }, - { status: 400 } - ); + return NextResponse.json({ message: '블로그 URL은 필수입니다.' }, { status: 400 }); } - // 블로그 URL 검증 - try { - const url = new URL(blogUrl); - if (!['http:', 'https:'].includes(url.protocol)) { - return NextResponse.json( - { message: '블로그 URL은 http 또는 https만 허용됩니다.' }, - { status: 400 } - ); - } - } catch { - return NextResponse.json( - { message: '유효하지 않은 블로그 URL입니다.' }, - { status: 400 } - ); + // 블로그 URL 검증 (SSRF 방지 포함) + if (!isSafeUrl(blogUrl)) { + return NextResponse.json({ message: '유효하지 않은 블로그 URL입니다.' }, { status: 400 }); } if (!bio || typeof bio !== 'string' || bio.trim().length < 100) { - return NextResponse.json( - { message: '자기소개는 100자 이상 작성해주세요.' }, - { status: 400 } - ); + return NextResponse.json({ message: '자기소개는 100자 이상 작성해주세요.' }, { status: 400 }); } if (!interests || !Array.isArray(interests) || interests.length < 1) { - return NextResponse.json( - { message: '관심사를 1개 이상 선택해주세요.' }, - { status: 400 } - ); + return NextResponse.json({ message: '관심사를 1개 이상 선택해주세요.' }, { status: 400 }); } if (interests.length > 6) { @@ -111,30 +96,17 @@ export async function POST(request: NextRequest) { } if (!resolution || typeof resolution !== 'string' || resolution.trim().length === 0) { + return NextResponse.json({ message: '다짐은 필수입니다.' }, { status: 400 }); + } + + // 프로필 이미지 URL 검증 (선택, SSRF 방지 포함) + if (profileImageUrl && !isSafeUrl(profileImageUrl)) { return NextResponse.json( - { message: '다짐은 필수입니다.' }, + { message: '유효하지 않은 프로필 이미지 URL입니다.' }, { status: 400 } ); } - // 프로필 이미지 URL 검증 (선택) - if (profileImageUrl) { - try { - const url = new URL(profileImageUrl); - if (!['http:', 'https:'].includes(url.protocol)) { - return NextResponse.json( - { message: '프로필 이미지 URL은 http 또는 https만 허용됩니다.' }, - { status: 400 } - ); - } - } catch { - return NextResponse.json( - { message: '유효하지 않은 프로필 이미지 URL입니다.' }, - { status: 400 } - ); - } - } - const database = db(); const rawUsername = user.user_metadata?.name || user.user_metadata?.full_name || ''; // Discord 새 유저네임 시스템에서 discriminator가 0이면 #0 제거 @@ -170,26 +142,50 @@ export async function POST(request: NextRequest) { .where(eq(members.id, existingMember.id)); } else { // 신규 유저: INSERT - await database - .insert(members) - .values({ - discordId, - discordUsername, - name: name.trim(), - nickname: nickname.trim(), - part, - blogUrl, - profileImageUrl: profileImageUrl || null, - bio: bio.trim(), - interests, - resolution: resolution.trim(), - githubUrl: githubUrl || null, - linkedinUrl: linkedinUrl || null, - instagramUrl: instagramUrl || null, - rssConsent: rssConsent !== false, - onboardingCompleted: true, - status: 'pending_approval', - }); + await database.insert(members).values({ + discordId, + discordUsername, + name: name.trim(), + nickname: nickname.trim(), + part, + blogUrl, + profileImageUrl: profileImageUrl || null, + bio: bio.trim(), + interests, + resolution: resolution.trim(), + githubUrl: githubUrl || null, + linkedinUrl: linkedinUrl || null, + instagramUrl: instagramUrl || null, + rssConsent: rssConsent !== false, + onboardingCompleted: true, + status: 'pending_approval', + }); + + // 관리자 채널에 승인대기 알림 (fire-and-forget) + after(async () => { + try { + const [channelConfig] = await database + .select() + .from(config) + .where(eq(config.key, 'admin_notification_channel_id')) + .limit(1); + + if (!channelConfig?.value) return; + + await notifyNewMemberPendingApproval({ + channelId: channelConfig.value, + nickname: nickname.trim(), + name: name.trim(), + discordUsername, + part, + blogUrl, + bio: bio.trim(), + adminDashboardUrl: 'https://kusting-web.vercel.app/admin/members', + }); + } catch (error) { + console.error('[onboarding] 관리자 알림 전송 실패:', error); + } + }); } return NextResponse.json({ @@ -197,9 +193,6 @@ export async function POST(request: NextRequest) { }); } catch (error) { console.error('Onboarding API error:', error); - return NextResponse.json( - { message: '서버 오류가 발생했습니다.' }, - { status: 500 } - ); + return NextResponse.json({ message: '서버 오류가 발생했습니다.' }, { status: 500 }); } } diff --git a/packages/web/src/app/globals.css b/packages/web/src/app/globals.css index e1c00c9..c031603 100644 --- a/packages/web/src/app/globals.css +++ b/packages/web/src/app/globals.css @@ -243,6 +243,11 @@ font-size: 0.85em; } +.tiptap code::before, +.tiptap code::after { + content: none; +} + .tiptap blockquote { border-left: 3px solid hsl(var(--border)); padding-left: 1rem; diff --git a/packages/web/src/app/layout.tsx b/packages/web/src/app/layout.tsx index e867542..d00452c 100644 --- a/packages/web/src/app/layout.tsx +++ b/packages/web/src/app/layout.tsx @@ -22,7 +22,7 @@ export const metadata: Metadata = { title: '큐스팅 4th', description: '함께 쓰고, 함께 성장하다. 블로그 스터디 자동화 플랫폼', siteName: '큐스팅 4th', - url: 'https://cusiting.com', + url: 'https://kusting.com', locale: 'ko_KR', type: 'website', }, diff --git a/packages/web/src/app/opengraph-image.tsx b/packages/web/src/app/opengraph-image.tsx index 219bc27..10cc4a0 100644 --- a/packages/web/src/app/opengraph-image.tsx +++ b/packages/web/src/app/opengraph-image.tsx @@ -7,273 +7,269 @@ export const contentType = 'image/png'; export default function OGImage() { return new ImageResponse( - ( +
+ {/* Gradient glow */}
- {/* Gradient glow */} -
+ /> - {/* Left: Text */} + {/* Left: Text */} +
+ {/* Logo + badge */}
- {/* Logo + badge */} + + + + + + + + + + + + + + +
- - - - - - - - - - - - - - -
-
- 큐스팅 4th -
+ /> + 큐스팅 4th
+
- {/* Headline */} -
+ 함께 쓰고, + - 함께 쓰고, - - 함께 성장하다. - -
+ 함께 성장하다. + +
- {/* Subtext */} -
- 2주에 한 편, 서로의 글에 응원을 나누며 - 꾸준함을 만들어가는 블로그 스터디 -
+ {/* Subtext */} +
+ 2주에 한 편, 서로의 글에 응원을 나누며 + 꾸준함을 만들어가는 블로그 스터디
+
- {/* Right: Mock UI cards */} + {/* Right: Mock UI cards */} +
+ {/* Ranking card */}
- {/* Ranking card */} -
-
- 🏆 - 랭킹 -
- {[ - { rank: 1, name: 'alice', score: 420, bg: '#fbbf24' }, - { rank: 2, name: 'bob', score: 385, bg: '#94a3b8' }, - { rank: 3, name: 'charlie', score: 310, bg: '#fb923c' }, - ].map((r) => ( +
+ 🏆 + 랭킹 +
+ {[ + { rank: 1, name: 'alice', score: 420, bg: '#fbbf24' }, + { rank: 2, name: 'bob', score: 385, bg: '#94a3b8' }, + { rank: 3, name: 'charlie', score: 310, bg: '#fb923c' }, + ].map((r) => ( +
-
- {r.rank} -
- {r.name} - - {r.score}pt - + {r.rank}
- ))} -
- - {/* Posts card */} -
-
- 📝 - - 최근 포스트 + {r.name} + + {r.score}pt
- {[ - { title: 'React 19의 새로운 기능 정리', author: 'alice' }, - { title: 'Docker 멀티스테이지 빌드 최적화', author: 'bob' }, - ].map((p) => ( + ))} +
+ + {/* Posts card */} +
+
+ 📝 + 최근 포스트 +
+ {[ + { title: 'React 19의 새로운 기능 정리', author: 'alice' }, + { title: 'Docker 멀티스테이지 빌드 최적화', author: 'bob' }, + ].map((p) => ( +
-
- {p.author.charAt(0).toUpperCase()} -
-
- {p.title} - by {p.author} -
+ {p.author.charAt(0).toUpperCase()}
- ))} -
+
+ {p.title} + by {p.author} +
+
+ ))}
+
- {/* Bottom URL */} -
- cusiting.com -
+ {/* Bottom URL */} +
+ kusting.com
- ), - { ...size }, +
, + { ...size } ); } diff --git a/packages/web/src/lib/discord-notify.ts b/packages/web/src/lib/discord-notify.ts new file mode 100644 index 0000000..6ed58c1 --- /dev/null +++ b/packages/web/src/lib/discord-notify.ts @@ -0,0 +1,139 @@ +/** + * Discord REST API를 통한 채널 메시지 전송 유틸 + * 봇 의존 없이 웹에서 직접 Discord 채널에 메시지를 보낼 때 사용 + */ + +const DISCORD_API_BASE = 'https://discord.com/api/v10'; +const SNOWFLAKE_RE = /^\d{17,20}$/; + +interface EmbedField { + name: string; + value: string; + inline?: boolean; +} + +interface DiscordEmbed { + title?: string; + description?: string; + color?: number; + fields?: EmbedField[]; + timestamp?: string; + footer?: { text: string }; +} + +interface SendChannelMessageOptions { + channelId: string; + content?: string; + embeds?: DiscordEmbed[]; +} + +/** + * Discord Markdown 특수문자 이스케이프 + * 사용자 입력을 embed에 넣기 전에 적용하여 마크다운 인젝션 방지 + */ +function escapeDiscordMarkdown(text: string): string { + return text.replace(/([*_~|`>[\]()@\\])/g, '\\$1'); +} + +/** + * Discord 채널에 메시지 전송 (REST API) + * DISCORD_TOKEN 환경변수 필요 + */ +export async function sendDiscordChannelMessage( + options: SendChannelMessageOptions +): Promise { + const token = process.env.DISCORD_TOKEN; + if (!token) { + console.error('[discord-notify] DISCORD_TOKEN이 설정되지 않았습니다.'); + return false; + } + + if (!SNOWFLAKE_RE.test(options.channelId)) { + console.error('[discord-notify] 유효하지 않은 channelId:', options.channelId); + return false; + } + + try { + const response = await fetch( + `${DISCORD_API_BASE}/channels/${options.channelId}/messages`, + { + method: 'POST', + headers: { + Authorization: `Bot ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + content: options.content, + embeds: options.embeds, + allowed_mentions: { parse: [] }, + }), + } + ); + + if (!response.ok) { + console.error(`[discord-notify] 메시지 전송 실패 (${response.status})`); + return false; + } + + return true; + } catch (error) { + console.error('[discord-notify] 메시지 전송 중 오류:', error); + return false; + } +} + +/** 큐시즘 블루 (#0091FF) → Discord embed 색상 */ +const CUSISM_BLUE = 0x0091ff; + +/** + * 신규 가입 승인대기 알림을 관리자 채널에 전송 + */ +export async function notifyNewMemberPendingApproval(opts: { + channelId: string; + nickname: string; + name: string; + discordUsername: string; + part: string; + blogUrl: string; + bio: string; + adminDashboardUrl?: string; +}): Promise { + const { channelId, nickname, name, discordUsername, part, blogUrl, bio, adminDashboardUrl } = opts; + + const safeName = escapeDiscordMarkdown(name); + const safeNickname = escapeDiscordMarkdown(nickname); + const safeUsername = escapeDiscordMarkdown(discordUsername); + const safePart = escapeDiscordMarkdown(part); + const safeBio = escapeDiscordMarkdown(bio.length > 100 ? `${bio.slice(0, 100)}…` : bio); + + const fields: EmbedField[] = [ + { name: '닉네임', value: safeNickname, inline: true }, + { name: '이름', value: safeName, inline: true }, + { name: 'Discord', value: `@${safeUsername}`, inline: true }, + { name: '분야', value: safePart, inline: true }, + { name: '블로그', value: blogUrl, inline: false }, + { name: '자기소개', value: safeBio || '-', inline: false }, + ]; + + if (adminDashboardUrl) { + fields.push({ + name: '승인하러 가기', + value: `[관리자 대시보드 →](${adminDashboardUrl})`, + inline: false, + }); + } + + return sendDiscordChannelMessage({ + channelId, + embeds: [ + { + title: '🆕 새로운 스터디원이 가입했어요!', + description: `**${safeNickname}** 님이 온보딩을 완료하고 승인을 기다리고 있습니다.`, + color: CUSISM_BLUE, + fields, + timestamp: new Date().toISOString(), + footer: { text: '큐스팅 블로그 스터디' }, + }, + ], + }); +}