├── proxy ├── .gitignore ├── Dockerfile └── Cargo.toml ├── control-plane ├── CLAUDE.md ├── .eslintrc.json ├── public │ ├── logo.png │ └── ad │ │ ├── 0c88af5cb6aee0da1e19b8c7f75ee6a1fc11cda46729b5734f4cf2e45c65bede.png │ │ ├── 1339fc50a058b6d7f6a782c76d61839262459bd47c8e37c7421cc14b28bbfdba.png │ │ ├── 8eb6bc2c4a2b73696ad1788fb98a6d59c8a3c21a15ddd418b1bf38800c65f317.png │ │ └── 8f1572d356a332381c53e1f7e6b77afb0e64f1bdb6a4b46c76a6bb6f5a680a30.png ├── src │ ├── app │ │ ├── (board) │ │ │ └── board │ │ │ │ ├── page.tsx │ │ │ │ └── layout.tsx │ │ └── (main) │ │ │ ├── favicon.ico │ │ │ ├── files │ │ │ ├── edit │ │ │ │ ├── page.tsx │ │ │ │ └── [...paths] │ │ │ │ │ └── page.tsx │ │ │ └── page.tsx │ │ │ ├── global-error.jsx │ │ │ ├── api │ │ │ ├── files │ │ │ │ ├── tree │ │ │ │ │ └── route.ts │ │ │ │ ├── create-directory │ │ │ │ │ └── route.ts │ │ │ │ ├── save │ │ │ │ │ └── route.ts │ │ │ │ ├── create-file │ │ │ │ │ └── route.ts │ │ │ │ ├── delete │ │ │ │ │ └── route.ts │ │ │ │ ├── content │ │ │ │ │ └── route.ts │ │ │ │ └── move │ │ │ │ │ └── route.ts │ │ │ └── account │ │ │ │ ├── logout │ │ │ │ └── route.ts │ │ │ │ ├── set-discoverable │ │ │ │ └── route.ts │ │ │ │ ├── verify-email │ │ │ │ └── route.ts │ │ │ │ ├── reset-password │ │ │ │ └── route.ts │ │ │ │ ├── login │ │ │ │ └── route.ts │ │ │ │ ├── change-password │ │ │ │ └── route.ts │ │ │ │ ├── request-account-deletion │ │ │ │ └── route.ts │ │ │ │ ├── request-password-reset │ │ │ │ └── route.ts │ │ │ │ ├── associate-email │ │ │ │ └── route.ts │ │ │ │ ├── resend-verification-email │ │ │ │ └── route.ts │ │ │ │ ├── delete-account-immediately │ │ │ │ └── route.ts │ │ │ │ ├── signup │ │ │ │ └── route.ts │ │ │ │ └── confirm-account-deletion │ │ │ │ └── route.ts │ │ │ ├── account │ │ │ ├── LogoutButton.tsx │ │ │ ├── DownloadDirectoryButton.tsx │ │ │ ├── DeleteAccountButton.tsx │ │ │ ├── page.tsx │ │ │ ├── DiscoverabilityForm.tsx │ │ │ └── ChangePasswordForm.tsx │ │ │ ├── globals.css │ │ │ ├── open │ │ │ ├── user-growth-chart.tsx │ │ │ ├── active-sessions-chart.tsx │ │ │ ├── home-directory-sizes-chart.tsx │ │ │ └── average-home-directory-sizes-chart.tsx │ │ │ ├── verify-email │ │ │ └── page.tsx │ │ │ ├── forgot-password │ │ │ └── page.tsx │ │ │ └── login │ │ │ └── page.tsx │ ├── instrumentation.ts │ ├── components │ │ ├── theme-provider.tsx │ │ ├── ui │ │ │ ├── label.tsx │ │ │ ├── input.tsx │ │ │ ├── sonner.tsx │ │ │ ├── checkbox.tsx │ │ │ ├── badge.tsx │ │ │ ├── button.tsx │ │ │ ├── card.tsx │ │ │ ├── breadcrumb.tsx │ │ │ ├── table.tsx │ │ │ └── form.tsx │ │ ├── browser │ │ │ ├── DeleteButton.tsx │ │ │ ├── CreateFileButton.tsx │ │ │ ├── EditFilenameButton.tsx │ │ │ ├── CreateDirectoryButton.tsx │ │ │ ├── DirectoryBreadcrumb.tsx │ │ │ ├── UploadButton.tsx │ │ │ ├── ImageViewer.tsx │ │ │ ├── FileExplorerWithSelected.tsx │ │ │ └── DirectoryListing.tsx │ │ ├── ModeToggle.tsx │ │ ├── AdCard.tsx │ │ └── Editor.tsx │ ├── migrations │ │ ├── 1718271156_add_site_updated_at.ts │ │ ├── 1718271455_discoverable.ts │ │ ├── 1744511697284_add_site_rendered_at.ts │ │ ├── 1752477398300_change_email_verified_to_timestamp.ts │ │ ├── 1750366585000_add_home_directory_size.ts │ │ ├── 1717885412_init.ts │ │ ├── 1750366586000_add_home_directory_size_history.ts │ │ ├── 1752477398301_add_password_reset_tokens.ts │ │ ├── 1752477398302_add_account_deletion_tokens.ts │ │ └── 1752476803300_add_email_verification.ts │ ├── lib │ │ ├── database.ts │ │ ├── utils.ts │ │ ├── db.d.ts │ │ ├── const.ts │ │ ├── auth.ts │ │ └── fileUtils.ts │ ├── cli │ │ ├── migrate-down.ts │ │ ├── migrate.ts │ │ └── update-screenshots.tsx │ └── instrumentation-client.ts ├── .vscode │ ├── extensions.json │ └── settings.json ├── postcss.config.mjs ├── kysely.config.ts ├── .env.template ├── components.json ├── .gitignore ├── sentry.server.config.ts ├── tsconfig.json ├── sentry.edge.config.ts ├── .dockerignore ├── README.md ├── jest.config.js ├── next.config.mjs ├── Dockerfile ├── tailwind.config.ts ├── jest.setup.js └── package.json ├── .github ├── dependabot.yml └── workflows │ └── main.yml └── README.md /proxy/.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | -------------------------------------------------------------------------------- /control-plane/CLAUDE.md: -------------------------------------------------------------------------------- 1 | This project uses `pnpm`. Don't use `npm`. -------------------------------------------------------------------------------- /control-plane/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /control-plane/public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yangnaru/naru-pub/HEAD/control-plane/public/logo.png -------------------------------------------------------------------------------- /control-plane/src/app/(board)/board/page.tsx: -------------------------------------------------------------------------------- 1 | export default function BoardPage() { 2 | return
파일 관리를 위해 로그인이 필요합니다.
14 |파일 편집을 위해 로그인이 필요합니다.
25 |64 | {description} 65 |
66 |{subtitle}
67 |31 | 안녕하세요,{" "} 32 | {user.loginName}님! 33 |
34 |35 | 나루와 {user.createdAt.getFullYear()}년{" "} 36 | {user.createdAt.getMonth() + 1} 37 | 월부터 함께해 주셨어요. 38 |
39 |인증 중입니다...
59 |{message}
69 | 74 |{message}
84 | 89 |이미지를 불러올 수 없습니다
35 |70 | 비밀번호 재설정 링크가 입력하신 이메일로 발송되었습니다. 71 | 이메일을 확인하여 비밀번호를 재설정해주세요. 72 |
73 |74 | 이메일이 도착하지 않았다면 스팸 폴더를 확인해주세요. 75 |
76 |등록하신 이메일 주소를 입력해주세요
87 |왼쪽에서 파일을 선택하여 내용을 확인하세요
109 |나루 계정으로 로그인하세요
81 |163 | {body} 164 |
165 | ) 166 | }) 167 | FormMessage.displayName = "FormMessage" 168 | 169 | export { 170 | useFormField, 171 | Form, 172 | FormItem, 173 | FormLabel, 174 | FormControl, 175 | FormDescription, 176 | FormMessage, 177 | FormField, 178 | } 179 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 나루 (Naru) 2 | 3 | > 당신의 공간이 되는, 나루. 4 | 5 | 나루는 개인 웹사이트 호스팅 플랫폼으로, 사용자에게 1GB의 저장 공간을 제공하여 자신만의 갠홈페이지를 만들고 관리할 수 있게 해주는 서비스입니다. 6 | 7 | ## 🚀 주요 기능 8 | 9 | - **개인 웹사이트 호스팅**: 사용자별 서브도메인을 통한 개인 웹사이트 제공 10 | - **파일 관리**: 웹 기반 파일 브라우저를 통한 파일 업로드, 편집, 삭제 11 | - **실시간 편집**: CodeMirror 기반의 실시간 코드 편집기 12 | - **다양한 파일 형식 지원**: HTML, CSS, JavaScript, JSON, Markdown 등 13 | - **사용자 인증**: Lucia Auth를 통한 안전한 사용자 인증 시스템 14 | - **통계 대시보드**: 서비스 사용 현황 및 지표 모니터링 15 | 16 | ## 🏗️ 아키텍처 17 | 18 | 나루는 두 개의 주요 컴포넌트로 구성됩니다: 19 | 20 | ### 1. Control Plane (Next.js) 21 | - **위치**: `control-plane/` 22 | - **기술 스택**: Next.js 15, React 19, TypeScript, Tailwind CSS 23 | - **주요 기능**: 24 | - 사용자 인증 및 계정 관리 25 | - 웹 기반 파일 브라우저 26 | - 실시간 코드 편집기 27 | - 관리자 대시보드 28 | 29 | ### 2. Proxy Server (Rust) 30 | - **위치**: `proxy/` 31 | - **기술 스택**: Rust, Tokio, Hyper 32 | - **주요 기능**: 33 | - Cloudflare R2 스토리지 프록시 34 | - 서브도메인 기반 라우팅 35 | - 정적 파일 서빙 36 | 37 | ## 🛠️ 개발 환경 설정 38 | 39 | ### 사전 요구사항 40 | 41 | - **Node.js** 18+ 42 | - **Rust** 1.70+ 43 | - **PostgreSQL** 15+ 44 | - **Cloudflare R2** 계정 (또는 AWS S3) 45 | 46 | ### 1. 저장소 클론 47 | 48 | ```bash 49 | git clone https://github.com/your-username/naru-pub.git 50 | cd naru-pub 51 | ``` 52 | 53 | ### 2. Control Plane 설정 54 | 55 | ```bash 56 | cd control-plane 57 | 58 | # 의존성 설치 59 | npm install 60 | 61 | # 환경 변수 설정 62 | cp .env.example .env 63 | ``` 64 | 65 | `.env` 파일을 편집하여 다음 환경 변수들을 설정하세요: 66 | 67 | ```env 68 | # 데이터베이스 69 | DATABASE_URL=postgresql://username:password@localhost:5432/naru 70 | 71 | # 인증 72 | AUTH_SECRET=your-auth-secret-key 73 | 74 | # S3/R2 설정 75 | S3_BUCKET_NAME=your-bucket-name 76 | AWS_ACCESS_KEY_ID=your-access-key 77 | AWS_SECRET_ACCESS_KEY=your-secret-key 78 | AWS_REGION=auto 79 | 80 | # 기타 81 | NEXT_PUBLIC_APP_URL=http://localhost:3000 82 | ``` 83 | 84 | ### 3. 데이터베이스 설정 85 | 86 | ```bash 87 | # 데이터베이스 마이그레이션 실행 88 | npm run migrate 89 | 90 | # 타입 생성 (선택사항) 91 | npm run kysely-codegen 92 | ``` 93 | 94 | ### 4. Proxy Server 설정 95 | 96 | ```bash 97 | cd ../proxy 98 | 99 | # 의존성 설치 100 | cargo build 101 | 102 | # 환경 변수 설정 103 | export R2_BUCKET_NAME=your-bucket-name 104 | export R2_ACCOUNT_ID=your-cloudflare-account-id 105 | export AWS_ACCESS_KEY_ID=your-access-key 106 | export AWS_SECRET_ACCESS_KEY=your-secret-key 107 | export PORT=5000 108 | ``` 109 | 110 | ### 5. 개발 서버 실행 111 | 112 | **Control Plane:** 113 | ```bash 114 | cd control-plane 115 | npm run dev 116 | ``` 117 | 118 | **Proxy Server:** 119 | ```bash 120 | cd proxy 121 | cargo run 122 | ``` 123 | 124 | 이제 다음 주소에서 서비스에 접근할 수 있습니다: 125 | - Control Plane: http://localhost:3000 126 | - Proxy Server: http://localhost:5000 127 | 128 | ## 📁 프로젝트 구조 129 | 130 | ``` 131 | naru-pub/ 132 | ├── control-plane/ # Next.js 웹 애플리케이션 133 | │ ├── src/ 134 | │ │ ├── app/ # Next.js App Router 135 | │ │ ├── components/ # React 컴포넌트 136 | │ │ ├── lib/ # 유틸리티 및 설정 137 | │ │ └── migrations/ # 데이터베이스 마이그레이션 138 | │ └── package.json 139 | ├── proxy/ # Rust 프록시 서버 140 | │ ├── src/ 141 | │ │ └── main.rs # 메인 서버 로직 142 | │ └── Cargo.toml 143 | └── README.md 144 | ``` 145 | 146 | ## 🧪 테스트 147 | 148 | ```bash 149 | # Control Plane 테스트 150 | cd control-plane 151 | npm run test 152 | 153 | # E2E 테스트 (Playwright) 154 | npx playwright test 155 | ``` 156 | 157 | ## 📊 사용 가능한 스크립트 158 | 159 | ### Control Plane 160 | 161 | ```bash 162 | npm run dev # 개발 서버 실행 163 | npm run build # 프로덕션 빌드 164 | npm run start # 프로덕션 서버 실행 165 | npm run lint # 코드 린팅 166 | npm run migrate # 데이터베이스 마이그레이션 167 | npm run kysely-codegen # 데이터베이스 타입 생성 168 | ``` 169 | 170 | ### Proxy 171 | 172 | ```bash 173 | cargo build # 빌드 174 | cargo run # 실행 175 | cargo test # 테스트 176 | ``` 177 | 178 | ## 🤝 기여하기 179 | 180 | 나루 프로젝트에 기여하고 싶으시다면 다음과 같은 방법들이 있습니다: 181 | 182 | ### 1. 이슈 리포트 183 | - 버그 발견 시 [GitHub Issues](https://github.com/your-username/naru-pub/issues)에 리포트 184 | - 새로운 기능 제안도 환영합니다 185 | 186 | ### 2. 코드 기여 187 | 1. 이 저장소를 포크합니다 188 | 2. 새로운 브랜치를 생성합니다 (`git checkout -b feature/amazing-feature`) 189 | 3. 변경사항을 커밋합니다 (`git commit -m 'Add amazing feature'`) 190 | 4. 브랜치에 푸시합니다 (`git push origin feature/amazing-feature`) 191 | 5. Pull Request를 생성합니다 192 | 193 | ### 3. 개발 가이드라인 194 | - TypeScript/JavaScript 코드는 ESLint 규칙을 따릅니다 195 | - Rust 코드는 `cargo fmt`와 `cargo clippy`를 통과해야 합니다 196 | - 새로운 기능은 테스트 코드를 포함해야 합니다 197 | - 커밋 메시지는 명확하고 설명적이어야 합니다 198 | 199 | ## 📄 라이선스 200 | 201 | 이 프로젝트는 [GNU Affero General Public License v3.0](LICENSE) 하에 배포됩니다. 202 | 203 | ## 📞 문의 204 | 205 | - **트위터**: [@naru_pub](https://x.com/naru_pub) 206 | - **이슈**: [GitHub Issues](https://github.com/your-username/naru-pub/issues) 207 | 208 | ## 🙏 감사의 말 209 | 210 | 나루는 다음과 같은 오픈소스 프로젝트들에 의존하고 있습니다: 211 | 212 | - [Next.js](https://nextjs.org/) - React 프레임워크 213 | - [Lucia Auth](https://lucia-auth.com/) - 인증 라이브러리 214 | - [CodeMirror](https://codemirror.net/) - 코드 에디터 215 | - [Tailwind CSS](https://tailwindcss.com/) - CSS 프레임워크 216 | - [Kysely](https://kysely.dev/) - TypeScript SQL 쿼리 빌더 217 | 218 | --- 219 | 220 | **즐거운 코딩 되세요! 🚀** 221 | -------------------------------------------------------------------------------- /control-plane/src/app/(main)/api/files/move/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from "next/server"; 2 | import { 3 | HeadObjectCommand, 4 | CopyObjectCommand, 5 | DeleteObjectCommand, 6 | } from "@aws-sdk/client-s3"; 7 | import { validateRequest } from "@/lib/auth"; 8 | import { getUserHomeDirectory, s3Client } from "@/lib/utils"; 9 | import { revalidatePath } from "next/cache"; 10 | import { User } from "lucia"; 11 | import * as Sentry from "@sentry/nextjs"; 12 | import { db } from "@/lib/database"; 13 | 14 | async function invalidateCloudflareCacheSingleFile(user: User, filename: string) { 15 | const zoneId = process.env.CLOUDFLARE_ZONE_ID!; 16 | const userApiToken = process.env.CLOUDFLARE_USER_API_TOKEN!; 17 | const url = `https://api.cloudflare.com/client/v4/zones/${zoneId}/purge_cache`; 18 | 19 | const response = await fetch(url, { 20 | method: "POST", 21 | headers: { 22 | "Content-Type": "application/json", 23 | Authorization: `Bearer ${userApiToken}`, 24 | }, 25 | body: JSON.stringify({ 26 | files: [ 27 | `${getUserHomeDirectory(user.loginName)}/${filename}`.replaceAll( 28 | "//", 29 | "/" 30 | ), 31 | ], 32 | }), 33 | }); 34 | 35 | if (!response.ok) { 36 | Sentry.captureException(response); 37 | } 38 | } 39 | 40 | async function updateSiteUpdatedAt(user: User) { 41 | await db 42 | .updateTable("users") 43 | .set("site_updated_at", new Date()) 44 | .where("id", "=", user.id) 45 | .execute(); 46 | } 47 | 48 | export async function POST(request: NextRequest) { 49 | try { 50 | const body = await request.json(); 51 | const { sourcePath, targetDirectory } = body; 52 | 53 | if (!sourcePath) { 54 | return NextResponse.json( 55 | { success: false, message: "소스 파일 경로가 필요합니다." }, 56 | { status: 400 } 57 | ); 58 | } 59 | 60 | // Get filename from source path 61 | const fileName = sourcePath.split('/').pop(); 62 | if (!fileName) { 63 | return NextResponse.json( 64 | { success: false, message: "유효하지 않은 파일 경로입니다." }, 65 | { status: 400 } 66 | ); 67 | } 68 | 69 | // Calculate new path 70 | const newPath = targetDirectory ? `${targetDirectory}/${fileName}` : fileName; 71 | 72 | // If source and target are the same, no need to move 73 | if (sourcePath === newPath) { 74 | return NextResponse.json( 75 | { success: true, message: "파일이 이미 해당 위치에 있습니다." } 76 | ); 77 | } 78 | 79 | // Check if a file already exists at the target location 80 | const { user } = await validateRequest(); 81 | if (!user) { 82 | return NextResponse.json( 83 | { success: false, message: "로그인이 필요합니다." }, 84 | { status: 401 } 85 | ); 86 | } 87 | 88 | try { 89 | await s3Client.send( 90 | new HeadObjectCommand({ 91 | Bucket: process.env.S3_BUCKET_NAME!, 92 | Key: `${getUserHomeDirectory(user.loginName)}/${newPath}`, 93 | }) 94 | ); 95 | 96 | // If we reach here, the file exists 97 | return NextResponse.json( 98 | { 99 | success: false, 100 | message: `"${fileName}" 파일이 대상 위치에 이미 존재합니다.`, 101 | type: "FILE_EXISTS" 102 | }, 103 | { status: 409 } 104 | ); 105 | } catch (error: any) { 106 | // If error is NotFound, the file doesn't exist - we can proceed 107 | if (error.name !== "NotFound") { 108 | throw error; // Re-throw other errors 109 | } 110 | } 111 | 112 | // Inline the rename functionality 113 | try { 114 | // Copy the object to new location 115 | await s3Client.send( 116 | new CopyObjectCommand({ 117 | Bucket: process.env.S3_BUCKET_NAME!, 118 | CopySource: `${process.env.S3_BUCKET_NAME}/${getUserHomeDirectory( 119 | user.loginName 120 | )}/${sourcePath}`, 121 | Key: `${getUserHomeDirectory(user.loginName)}/${newPath}`, 122 | }) 123 | ); 124 | 125 | // Delete the old object 126 | await s3Client.send( 127 | new DeleteObjectCommand({ 128 | Bucket: process.env.S3_BUCKET_NAME!, 129 | Key: `${getUserHomeDirectory(user.loginName)}/${sourcePath}`, 130 | }) 131 | ); 132 | 133 | // Invalidate Cloudflare cache 134 | await Promise.all([ 135 | invalidateCloudflareCacheSingleFile(user, sourcePath), 136 | invalidateCloudflareCacheSingleFile(user, newPath) 137 | ]); 138 | 139 | revalidatePath("/files", "layout"); 140 | await updateSiteUpdatedAt(user); 141 | 142 | return NextResponse.json({ 143 | success: true, 144 | message: `파일이 성공적으로 이동되었습니다.`, 145 | newPath 146 | }); 147 | } catch (moveError) { 148 | Sentry.captureException(moveError); 149 | return NextResponse.json( 150 | { success: false, message: "파일 이동에 실패했습니다." }, 151 | { status: 500 } 152 | ); 153 | } 154 | } catch (error) { 155 | console.error("Move file error:", error); 156 | return NextResponse.json( 157 | { success: false, message: "파일 이동에 실패했습니다." }, 158 | { status: 500 } 159 | ); 160 | } 161 | } -------------------------------------------------------------------------------- /control-plane/src/app/(main)/account/ChangePasswordForm.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { zodResolver } from "@hookform/resolvers/zod"; 4 | import { useForm } from "react-hook-form"; 5 | import { z } from "zod"; 6 | 7 | import { Button } from "@/components/ui/button"; 8 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; 9 | import { Lock, Key } from "lucide-react"; 10 | import { 11 | Form, 12 | FormControl, 13 | FormDescription, 14 | FormField, 15 | FormItem, 16 | FormLabel, 17 | FormMessage, 18 | } from "@/components/ui/form"; 19 | import { Input } from "@/components/ui/input"; 20 | import { toast } from "sonner"; 21 | 22 | const formSchema = z 23 | .object({ 24 | originalPassword: z.string().min(8, { 25 | message: "비밀번호는 8자 이상이어야 합니다.", 26 | }), 27 | newPassword: z.string().min(8, { 28 | message: "비밀번호는 8자 이상이어야 합니다.", 29 | }), 30 | newPasswordConfirm: z.string().min(8, { 31 | message: "비밀번호는 8자 이상이어야 합니다.", 32 | }), 33 | }) 34 | .refine((data) => data.newPassword === data.newPasswordConfirm, { 35 | message: "비밀번호가 일치하지 않습니다.", 36 | path: ["passwordConfirm"], 37 | }); 38 | 39 | export default function ChangePasswordForm() { 40 | const form = useForm