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: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ SUPABASE_SERVICE_KEY=your_supabase_service_key
DATABASE_URL=postgresql://user:password@host:6543/database
DATABASE_URL_DIRECT=postgresql://user:password@host:5432/database

# Sentry (web only — SENTRY_AUTH_TOKEN은 Vercel/CI에서만 설정, 로컬 불필요)
NEXT_PUBLIC_SENTRY_DSN=https://your-dsn@o123.ingest.us.sentry.io/456
# SENTRY_AUTH_TOKEN=sntrys_xxx (CI/Vercel 환경변수로만 설정)

# Application
APP_URL=http://localhost:3000
NODE_ENV=development
Expand Down
16 changes: 12 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ deploy/
|------|------|
| Runtime | Node.js 22, TypeScript 5.x |
| Bot | discord.js v14, feedsmith (RSS 파서), pg-boss (PostgreSQL 잡 큐) |
| Web | Next.js 16 App Router, React 19, shadcn/ui, Tailwind CSS v4, Tiptap (리치 에디터), sonner (토스트), Framer Motion (랜딩 애니메이션) |
| Web | Next.js 16 App Router, React 19, shadcn/ui, Tailwind CSS v4, Tiptap (리치 에디터), sonner (토스트), Framer Motion (랜딩 애니메이션), Sentry (에러 모니터링) |
| DB | Supabase PostgreSQL + Drizzle ORM (Transaction Pooler, `prepare: false`) |
| Auth | Supabase Auth (Discord OAuth) + `@supabase/ssr` |
| 배포 | AWS EC2 Docker (bot), Vercel (web), Supabase (DB + Auth) |
Expand Down Expand Up @@ -108,6 +108,12 @@ pnpm --filter @blog-study/bot rss-collect # 수동 RSS 수집 (봇 없이)
| `packages/bot/Dockerfile` | 봇 Docker 이미지 (multi-stage, node:22-alpine) |
| `.github/workflows/bot-deploy.yml` | 봇 CI/CD (CI Gate → ECR 빌드/푸시 → SSH 배포) |
| `.github/workflows/ci.yml` | PR/push CI (lint, typecheck, test, build) |
| `packages/web/next.config.ts` | Next.js 설정 + Sentry `withSentryConfig` 래핑 |
| `packages/web/sentry.client.config.ts` | Sentry 클라이언트 SDK 초기화 (DSN 가드, PII 스크러빙) |
| `packages/web/sentry.server.config.ts` | Sentry 서버 SDK 초기화 |
| `packages/web/sentry.edge.config.ts` | Sentry Edge SDK 초기화 |
| `packages/web/instrumentation.ts` | Next.js instrumentation hook (Sentry 서버/엣지 등록) |
| `packages/web/src/app/global-error.tsx` | 전역 에러 바운더리 (Sentry 전송 + 다크모드 대응) |

## 인증 구조

Expand Down Expand Up @@ -150,9 +156,9 @@ pnpm --filter @blog-study/bot rss-collect # 수동 RSS 수집 (봇 없이)
- **랜딩 페이지**: Linear 스타일 다크 모드 원페이지 (큐시즘 블루 그라디언트 `#0091FF→#004DFF`, Framer Motion 풀 애니메이션, DB 스탯 ISR 60s, 인증 유저 `/dashboard` 리다이렉트)
- **로고**: 커스텀 SVG 픽토그램 (펜촉+화살표, 큐시즘 블루 그라디언트), `icon.svg`/`icon-192.png`/`icon-512.png`
- **토스트**: sonner (`<Toaster />` in root layout, `position="bottom-center"`, `richColors`)
- **에러 바운더리**: `(user)/error.tsx`, `(admin)/error.tsx` — 리셋 버튼 포함
- **에러 바운더리**: `(user)/error.tsx`, `(admin)/error.tsx` — Sentry 전송 + 리셋 버튼, `global-error.tsx` — 전역 폴백 (다크모드 인라인 스타일)
- **404 페이지**: `not-found.tsx` — 대시보드 링크 포함
- **CSP**: `next.config.js`에 Content-Security-Policy 헤더 설정
- **CSP**: `next.config.ts`에 Content-Security-Policy 헤더 설정
- **상세 스펙**: `docs/26-03-06-ui-design-system.md` 참조

## 에이전트 활용 가이드
Expand Down Expand Up @@ -192,6 +198,8 @@ pnpm --filter @blog-study/bot rss-collect # 수동 RSS 수집 (봇 없이)
- `SUPABASE_SERVICE_KEY`, `DATABASE_URL`, `DATABASE_URL_DIRECT` (DB)
- `DISCORD_TOKEN`, `DISCORD_CLIENT_ID`, `DISCORD_CLIENT_SECRET`, `DISCORD_GUILD_ID`
- `ADMIN_DISCORD_IDS` (관리자 Discord ID, 쉼표 구분)
- `NEXT_PUBLIC_SENTRY_DSN` (Sentry 에러 모니터링, web 전용)
- `SENTRY_AUTH_TOKEN` (소스맵 업로드, Vercel/CI에서만 설정)

**env 파일 위치** (2곳):
- `.env.local` — 루트 (shared/bot용)
Expand Down Expand Up @@ -230,7 +238,7 @@ npx drizzle-kit push --force
- **파이프라인**: `dev` push → CI Gate (lint+typecheck+test) → ECR 빌드(ARM64) → SSH 배포
- **트리거**: `packages/bot/**`, `packages/shared/**` 변경 시 + `workflow_dispatch`
- **EC2**: illdan-mgmt (t4g ARM64), `~/study-admin-bot/deploy.sh` + `.env`
- **ECR**: `101548339709.dkr.ecr.ap-northeast-2.amazonaws.com/study-admin-bot`
- **ECR**: `699475955307.dkr.ecr.ap-northeast-2.amazonaws.com/study-admin-bot`
- **deploy.sh**: ECR 로그인 → pull → 컨테이너 교체 → health check → Discord 웹훅 알림
- **주의**: `deploy/bot/deploy.sh`는 커밋하지 않음 (EC2에 직접 배치)

Expand Down
17 changes: 14 additions & 3 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Blog Study Admin - 시스템 아키텍처

> 최종 업데이트: 2026-03-12 (v4)
> 최종 업데이트: 2026-03-13 (v5)

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

Expand Down Expand Up @@ -51,6 +51,12 @@ graph TB
PWA["PWA<br/>manifest.json<br/>홈 화면 추가"]
API["API Routes<br/>/api/auth · /api/posts<br/>/api/admin · /api/board · ..."]
SUPA_CLIENT["Supabase SSR Client<br/>@supabase/ssr"]
SENTRY_WEB["Sentry SDK<br/>에러 모니터링 + PII 스크러빙"]
end

subgraph Monitoring["Sentry · Error Tracking"]
SENTRY["Sentry Cloud<br/>kusitms-pf org"]
DISCORD_WH["Discord Webhook<br/>#서버-에러"]
end

Discord <-->|WebSocket| Bot
Expand All @@ -64,6 +70,8 @@ graph TB
SUPA_CLIENT --> AUTH
API --> TABLES
PAGES --> API
SENTRY_WEB -->|tunnel /api/_sentry-tunnel| SENTRY
SENTRY -->|Alert| DISCORD_WH
```

## 기술 스택
Expand All @@ -83,6 +91,7 @@ mindmap
Tiptap Rich Editor
sonner Toast
Framer Motion 애니메이션
Sentry 에러 모니터링
PWA 홈 화면 추가
Supabase Auth
Discord OAuth
Expand Down Expand Up @@ -415,7 +424,7 @@ erDiagram
| **인가** | 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/` |
| **CSP** | Content-Security-Policy 헤더 (`frame-ancestors 'none'`, 허용 도메인 화이트리스트) | `next.config.js` |
| **CSP** | Content-Security-Policy 헤더 (`frame-ancestors 'none'`, 허용 도메인 화이트리스트) | `next.config.ts` |
| **SQL Injection** | Drizzle ORM 파라미터화 쿼리 (raw SQL 사용 안 함) | 전체 API Routes |
| **CSRF** | Supabase Auth 쿠키 `SameSite=Lax` | Supabase 기본 설정 |
| **입력 검증** | description 새니타이즈 (제어 문자/제로 너비 유니코드 제거, 300자 제한) | `lib/sanitize.ts` |
Expand All @@ -427,7 +436,8 @@ erDiagram
| **API 에러** | 표준 `ApiError` 클래스 + `Errors` 팩토리 (`401`/`403`/`404`/`400`) | `lib/api-error.ts` |
| **API 성공** | `successResponse(data, message?, status?)` 통일 | `lib/api-error.ts` |
| **캐시** | `withCache(response, maxAge, scope?)` — 읽기 API에 Cache-Control 적용 | `lib/api-error.ts` |
| **클라이언트 에러** | Error Boundary (`error.tsx`) — 사용자/관리자 그룹별 | `(user)/error.tsx`, `(admin)/error.tsx` |
| **클라이언트 에러** | Error Boundary → `Sentry.captureException()` — 사용자/관리자/전역 | `(user)/error.tsx`, `(admin)/error.tsx`, `global-error.tsx` |
| **에러 모니터링** | Sentry SDK (DSN 가드, `beforeSend` PII 스크러빙, tunnel route) | `sentry.*.config.ts`, `instrumentation.ts`, `next.config.ts` |
| **404** | 커스텀 Not Found 페이지 | `not-found.tsx` |
| **사용자 피드백** | sonner 토스트 (`toast.success()`, `toast.error()`) | `layout.tsx` (`<Toaster />`) |

Expand Down Expand Up @@ -492,3 +502,4 @@ graph LR
| Tiptap | 3.20 | Rich text editor |
| shadcn/ui | latest | UI components |
| Framer Motion | 12.x | Landing page animations |
| @sentry/nextjs | 10.43 | Error monitoring + source maps |
13 changes: 13 additions & 0 deletions packages/web/instrumentation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import * as Sentry from '@sentry/nextjs';

export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') {
await import('./sentry.server.config');
}

if (process.env.NEXT_RUNTIME === 'edge') {
await import('./sentry.edge.config');
}
}

export const onRequestError = Sentry.captureRequestError;
24 changes: 20 additions & 4 deletions packages/web/next.config.js → packages/web/next.config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
import type { NextConfig } from 'next';
import { withSentryConfig } from '@sentry/nextjs';

const nextConfig: NextConfig = {
transpilePackages: ['@blog-study/shared'],

// Production optimizations
Expand Down Expand Up @@ -43,7 +45,7 @@ const nextConfig = {
"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net",
"img-src 'self' https: data: blob:",
"font-src 'self' https://cdn.jsdelivr.net",
"connect-src 'self' https://*.supabase.co",
"connect-src 'self' https://*.supabase.co https://o4511035097481216.ingest.us.sentry.io",
"frame-ancestors 'none'",
].join('; '),
},
Expand All @@ -53,4 +55,18 @@ const nextConfig = {
},
};

module.exports = nextConfig;
export default withSentryConfig(nextConfig, {
org: 'kusitms-pf',
project: 'javascript-nextjs',
authToken: process.env.SENTRY_AUTH_TOKEN,
silent: !process.env.CI,

// Route Sentry requests through your server (avoids ad-blockers)
tunnelRoute: '/api/_sentry-tunnel',

// Upload source maps for readable stack traces
widenClientFileUpload: true,

// Disable Sentry telemetry
telemetry: false,
});
1 change: 1 addition & 0 deletions packages/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@sentry/nextjs": "^10.43.0",
"@supabase/ssr": "^0.8.0",
"@supabase/supabase-js": "^2.97.0",
"@tailwindcss/typography": "^0.5.19",
Expand Down
30 changes: 30 additions & 0 deletions packages/web/sentry.client.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import * as Sentry from '@sentry/nextjs';

const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN;

if (dsn) {
Sentry.init({
dsn,
environment: process.env.NEXT_PUBLIC_VERCEL_ENV ?? process.env.NODE_ENV ?? 'development',
tracesSampleRate: process.env.NODE_ENV === 'production' ? 0.1 : 0,
debug: false,
beforeSend(event) {
if (event.request?.cookies) {
delete event.request.cookies;
}
if (event.request?.query_string) {
event.request.query_string = '[Filtered]';
}
return event;
},
beforeBreadcrumb(breadcrumb) {
if (breadcrumb.category === 'fetch' && breadcrumb.data?.url) {
const url = breadcrumb.data.url as string;
if (url.includes('supabase') || url.includes('token')) {
breadcrumb.data.url = '[Filtered]';
}
}
return breadcrumb;
},
});
}
21 changes: 21 additions & 0 deletions packages/web/sentry.edge.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import * as Sentry from '@sentry/nextjs';

const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN;

if (dsn) {
Sentry.init({
dsn,
environment: process.env.VERCEL_ENV ?? process.env.NODE_ENV ?? 'development',
tracesSampleRate: process.env.NODE_ENV === 'production' ? 0.1 : 0,
debug: false,
beforeSend(event) {
if (event.request?.cookies) {
delete event.request.cookies;
}
if (event.request?.query_string) {
event.request.query_string = '[Filtered]';
}
return event;
},
});
}
22 changes: 22 additions & 0 deletions packages/web/sentry.server.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import * as Sentry from '@sentry/nextjs';

const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN;

if (dsn) {
Sentry.init({
dsn,
environment: process.env.VERCEL_ENV ?? process.env.NODE_ENV ?? 'development',
tracesSampleRate: process.env.NODE_ENV === 'production' ? 0.1 : 0,
sendDefaultPii: false,
debug: false,
beforeSend(event) {
if (event.request?.cookies) {
delete event.request.cookies;
}
if (event.request?.query_string) {
event.request.query_string = '[Filtered]';
}
return event;
},
});
}
3 changes: 2 additions & 1 deletion packages/web/src/app/(admin)/error.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
'use client';

import * as Sentry from '@sentry/nextjs';
import { useEffect } from 'react';
import { AlertCircle } from 'lucide-react';

Expand All @@ -11,7 +12,7 @@ export default function AdminError({
reset: () => void;
}) {
useEffect(() => {
console.error('Admin page error:', error);
Sentry.captureException(error);
}, [error]);

return (
Expand Down
3 changes: 2 additions & 1 deletion packages/web/src/app/(user)/error.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
'use client';

import * as Sentry from '@sentry/nextjs';
import { useEffect } from 'react';
import { AlertCircle } from 'lucide-react';

Expand All @@ -11,7 +12,7 @@ export default function UserError({
reset: () => void;
}) {
useEffect(() => {
console.error('User page error:', error);
Sentry.captureException(error);
}, [error]);

return (
Expand Down
78 changes: 78 additions & 0 deletions packages/web/src/app/global-error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
'use client';

import * as Sentry from '@sentry/nextjs';
import { useEffect } from 'react';

export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
Sentry.captureException(error);
}, [error]);

return (
<html lang="ko">
<body
style={{
margin: 0,
fontFamily:
'"Pretendard Variable", Pretendard, -apple-system, BlinkMacSystemFont, system-ui, sans-serif',
backgroundColor: 'var(--bg, #fff)',
color: 'var(--fg, #111)',
}}
>
<style
dangerouslySetInnerHTML={{
__html: `
:root { --bg: #fff; --fg: #111; --muted: #666; --border: #ddd; --hover-bg: #f5f5f5; }
@media (prefers-color-scheme: dark) {
:root { --bg: #0a0a0a; --fg: #ededed; --muted: #999; --border: #333; --hover-bg: #1a1a1a; }
}
.reset-btn:hover { background-color: var(--hover-bg); }
.reset-btn:focus-visible { outline: 2px solid #0091ff; outline-offset: 2px; }
`,
}}
/>
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: '100vh',
padding: '2rem',
textAlign: 'center',
gap: '1rem',
}}
>
<h1 style={{ fontSize: '1.125rem', fontWeight: 600, margin: 0 }}>
오류가 발생했습니다
</h1>
<p style={{ color: 'var(--muted)', margin: 0, fontSize: '0.875rem' }}>
문제가 지속되면 관리자에게 문의해주세요.
</p>
<button
className="reset-btn"
onClick={() => reset()}
style={{
padding: '0.5rem 1rem',
borderRadius: '0.375rem',
border: '1px solid var(--border)',
backgroundColor: 'transparent',
color: 'var(--fg)',
cursor: 'pointer',
fontSize: '0.875rem',
fontWeight: 500,
}}
>
다시 시도
</button>
</div>
</body>
</html>
);
}
Loading
Loading