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
6 changes: 4 additions & 2 deletions 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 체크
- **보안**: 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`)의 푸시 알림은 내용 마스킹 (`'비밀 댓글이 달렸습니다.'`), 포스트/게시판 댓글 모두 적용
Expand Down Expand Up @@ -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) |
Expand Down
5 changes: 4 additions & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ graph TB
subgraph DCH_OPS["🔧 운영 (관리자)"]
CH_OPS["#운영-논의"]
CH_LOG["#봇-로그"]
CH_ADMIN["#관리자-알림<br/>admin_notification_channel_id"]
CH_PR["#github-pr"]
CH_ERR["#서버-에러"]
CH_SUGGEST["#건의사항-알림"]
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 기본 설정 |
Expand Down
2 changes: 1 addition & 1 deletion packages/web/next-env.d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
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.
14 changes: 14 additions & 0 deletions packages/web/src/app/(admin)/admin/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ interface StudySettings {
noticeChannelId: string | null;
rankingChannelId: string | null;
botLogChannelId: string | null;
adminNotificationChannelId: string | null;
adminDiscordIds: string;
studyRoleId: string | null;
}
Expand Down Expand Up @@ -72,6 +73,7 @@ export default function AdminSettingsPage() {
noticeChannelId: null,
rankingChannelId: null,
botLogChannelId: null,
adminNotificationChannelId: null,
adminDiscordIds: '',
studyRoleId: null,
});
Expand Down Expand Up @@ -344,6 +346,18 @@ export default function AdminSettingsPage() {
벌금 납부 알림 등 봇 운영 로그가 발송되는 채널 (#봇-로그)
</p>
</div>
<div className="space-y-2">
<Label htmlFor="adminNotificationChannelId">관리자 알림 채널 ID</Label>
<Input
id="adminNotificationChannelId"
value={formData.adminNotificationChannelId || ''}
onChange={(e) => handleInputChange('adminNotificationChannelId', e.target.value)}
placeholder="예: 1234567890123456789"
/>
<p className="text-xs text-muted-foreground">
신규 가입 승인대기 등 관리자 알림이 발송되는 채널
</p>
</div>
</CardContent>
</Card>

Expand Down
3 changes: 3 additions & 0 deletions packages/web/src/app/api/admin/settings/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand Down Expand Up @@ -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',
};
Expand All @@ -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',
]);

Expand Down
175 changes: 84 additions & 91 deletions packages/web/src/app/api/profile/onboarding/route.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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(
Expand All @@ -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) {
Expand All @@ -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 제거
Expand Down Expand Up @@ -170,36 +142,57 @@ 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({
message: '온보딩이 완료되었습니다.',
});
} catch (error) {
console.error('Onboarding API error:', error);
return NextResponse.json(
{ message: '서버 오류가 발생했습니다.' },
{ status: 500 }
);
return NextResponse.json({ message: '서버 오류가 발생했습니다.' }, { status: 500 });
}
}
5 changes: 5 additions & 0 deletions packages/web/src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion packages/web/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
Expand Down
Loading
Loading