├── 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
BoardPage
; 3 | } 4 | -------------------------------------------------------------------------------- /control-plane/src/app/(main)/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yangnaru/naru-pub/HEAD/control-plane/src/app/(main)/favicon.ico -------------------------------------------------------------------------------- /control-plane/src/instrumentation.ts: -------------------------------------------------------------------------------- 1 | import * as Sentry from "@sentry/nextjs"; 2 | export const onRequestError = Sentry.captureRequestError; 3 | -------------------------------------------------------------------------------- /control-plane/.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "esbenp.prettier-vscode", 4 | "bradlc.vscode-tailwindcss", 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /control-plane/postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /proxy/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM rust:1.89 2 | 3 | WORKDIR /app 4 | 5 | COPY Cargo.toml Cargo.lock ./ 6 | COPY src/ ./src/ 7 | 8 | RUN cargo build --release 9 | 10 | EXPOSE 5000 11 | 12 | CMD ["cargo", "run", "--release"] -------------------------------------------------------------------------------- /control-plane/public/ad/0c88af5cb6aee0da1e19b8c7f75ee6a1fc11cda46729b5734f4cf2e45c65bede.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yangnaru/naru-pub/HEAD/control-plane/public/ad/0c88af5cb6aee0da1e19b8c7f75ee6a1fc11cda46729b5734f4cf2e45c65bede.png -------------------------------------------------------------------------------- /control-plane/public/ad/1339fc50a058b6d7f6a782c76d61839262459bd47c8e37c7421cc14b28bbfdba.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yangnaru/naru-pub/HEAD/control-plane/public/ad/1339fc50a058b6d7f6a782c76d61839262459bd47c8e37c7421cc14b28bbfdba.png -------------------------------------------------------------------------------- /control-plane/public/ad/8eb6bc2c4a2b73696ad1788fb98a6d59c8a3c21a15ddd418b1bf38800c65f317.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yangnaru/naru-pub/HEAD/control-plane/public/ad/8eb6bc2c4a2b73696ad1788fb98a6d59c8a3c21a15ddd418b1bf38800c65f317.png -------------------------------------------------------------------------------- /control-plane/public/ad/8f1572d356a332381c53e1f7e6b77afb0e64f1bdb6a4b46c76a6bb6f5a680a30.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yangnaru/naru-pub/HEAD/control-plane/public/ad/8f1572d356a332381c53e1f7e6b77afb0e64f1bdb6a4b46c76a6bb6f5a680a30.png -------------------------------------------------------------------------------- /control-plane/kysely.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "kysely-ctl"; 2 | import { db } from "./src/lib/database"; 3 | 4 | export default defineConfig({ 5 | kysely: db, 6 | migrations: { 7 | migrationFolder: "./src/migrations", 8 | }, 9 | }); 10 | -------------------------------------------------------------------------------- /control-plane/src/app/(main)/files/edit/page.tsx: -------------------------------------------------------------------------------- 1 | import DirectoryListing from "@/components/browser/DirectoryListing"; 2 | 3 | export default async function Files() { 4 | return ( 5 |
6 | 7 |
8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /control-plane/.env.template: -------------------------------------------------------------------------------- 1 | BASE_URL=http://localhost:3000 2 | NEXT_PUBLIC_DOMAIN= 3 | 4 | DATABASE_URL= 5 | 6 | S3_BUCKET_NAME= 7 | S3_BUCKET_NAME_SCREENSHOTS= 8 | 9 | AWS_ACCESS_KEY_ID= 10 | AWS_SECRET_ACCESS_KEY= 11 | 12 | R2_ACCOUNT_ID= 13 | CLOUDFLARE_ZONE_ID= 14 | CLOUDFLARE_USER_API_TOKEN= 15 | 16 | MAILGUN_API_KEY= 17 | MAILGUN_DOMAIN= -------------------------------------------------------------------------------- /control-plane/src/components/theme-provider.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import { ThemeProvider as NextThemesProvider } from "next-themes" 5 | 6 | export function ThemeProvider({ 7 | children, 8 | ...props 9 | }: React.ComponentProps) { 10 | return {children} 11 | } -------------------------------------------------------------------------------- /proxy/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "naru-pub-proxy" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | tokio = { version = "1.47", features = ["full"] } 8 | hyper = { version = "1.2", features = ["full"] } 9 | hyper-util = { version = "0.1", features = ["full"] } 10 | http-body-util = "0.1" 11 | aws-config = "1.1" 12 | aws-sdk-s3 = "1.17" 13 | anyhow = "1.0" 14 | bytes = "1.10" 15 | percent-encoding = "2.3" 16 | -------------------------------------------------------------------------------- /control-plane/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "src/app/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils" 16 | } 17 | } -------------------------------------------------------------------------------- /control-plane/src/app/(main)/global-error.jsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as Sentry from "@sentry/nextjs"; 4 | import Error from "next/error"; 5 | import { useEffect } from "react"; 6 | 7 | export default function GlobalError({ error }) { 8 | useEffect(() => { 9 | Sentry.captureException(error); 10 | }, [error]); 11 | 12 | return ( 13 | 14 | 15 | 16 | 17 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /control-plane/src/migrations/1718271156_add_site_updated_at.ts: -------------------------------------------------------------------------------- 1 | import { Kysely, sql } from "kysely"; 2 | 3 | export async function up(db: Kysely): Promise { 4 | await db.schema 5 | .alterTable("users") 6 | .addColumn("site_updated_at", "timestamptz", (col) => col.defaultTo(null)) 7 | .execute(); 8 | } 9 | 10 | export async function down(db: Kysely): Promise { 11 | await db.schema.alterTable("users").dropColumn("site_updated_at").execute(); 12 | } 13 | -------------------------------------------------------------------------------- /control-plane/src/migrations/1718271455_discoverable.ts: -------------------------------------------------------------------------------- 1 | import { Kysely, sql } from "kysely"; 2 | 3 | export async function up(db: Kysely): Promise { 4 | await db.schema 5 | .alterTable("users") 6 | .addColumn("discoverable", "boolean", (col) => 7 | col.defaultTo(false).notNull() 8 | ) 9 | .execute(); 10 | } 11 | 12 | export async function down(db: Kysely): Promise { 13 | await db.schema.alterTable("users").dropColumn("discoverable").execute(); 14 | } 15 | -------------------------------------------------------------------------------- /control-plane/src/lib/database.ts: -------------------------------------------------------------------------------- 1 | import { NodePostgresAdapter } from "@lucia-auth/adapter-postgresql"; 2 | 3 | import { Pool } from "pg"; 4 | import { Kysely, PostgresDialect } from "kysely"; 5 | import { DB } from "./db"; 6 | 7 | const pool = new Pool({ 8 | connectionString: process.env.DATABASE_URL, 9 | }); 10 | 11 | export const db = new Kysely({ 12 | dialect: new PostgresDialect({ 13 | pool, 14 | }), 15 | }); 16 | 17 | export const adapter = new NodePostgresAdapter(pool, { 18 | session: "sessions", 19 | user: "users", 20 | }); 21 | -------------------------------------------------------------------------------- /control-plane/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env 30 | .env*.local 31 | 32 | # vercel 33 | .vercel 34 | 35 | # typescript 36 | *.tsbuildinfo 37 | next-env.d.ts 38 | 39 | # Sentry Config File 40 | .env.sentry-build-plugin 41 | 42 | -------------------------------------------------------------------------------- /control-plane/src/app/(board)/board/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { IBM_Plex_Sans_KR } from "next/font/google"; 3 | 4 | const korean = IBM_Plex_Sans_KR({ 5 | subsets: ["latin"], 6 | weight: "400", 7 | }); 8 | 9 | export const metadata: Metadata = { 10 | title: "나루", 11 | description: "당신의 공간이 되는, 나루.", 12 | }; 13 | 14 | export default async function RootLayout({ 15 | children, 16 | }: Readonly<{ 17 | children: React.ReactNode; 18 | }>) { 19 | return ( 20 | 21 | 22 |
{children}
23 | 24 | 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /control-plane/src/migrations/1744511697284_add_site_rendered_at.ts: -------------------------------------------------------------------------------- 1 | import type { Kysely } from "kysely"; 2 | 3 | // `any` is required here since migrations should be frozen in time. alternatively, keep a "snapshot" db interface. 4 | export async function up(db: Kysely): Promise { 5 | await db.schema 6 | .alterTable("users") 7 | .addColumn("site_rendered_at", "timestamp", (col) => col.defaultTo(null)) 8 | .execute(); 9 | } 10 | 11 | // `any` is required here since migrations should be frozen in time. alternatively, keep a "snapshot" db interface. 12 | export async function down(db: Kysely): Promise { 13 | await db.schema.alterTable("users").dropColumn("site_rendered_at").execute(); 14 | } 15 | -------------------------------------------------------------------------------- /control-plane/sentry.server.config.ts: -------------------------------------------------------------------------------- 1 | // This file configures the initialization of Sentry on the server. 2 | // The config you add here will be used whenever the server handles a request. 3 | // https://docs.sentry.io/platforms/javascript/guides/nextjs/ 4 | 5 | import * as Sentry from "@sentry/nextjs"; 6 | 7 | Sentry.init({ 8 | dsn: "https://abb87d1e259356c7c14ecfee98d1c709@o4504757655764992.ingest.us.sentry.io/4509137772609536", 9 | 10 | // Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control. 11 | tracesSampleRate: 1, 12 | 13 | // Setting this option to true will print useful information to the console while you're setting up Sentry. 14 | debug: false, 15 | }); 16 | -------------------------------------------------------------------------------- /control-plane/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[json]": { 3 | "editor.defaultFormatter": "vscode.json-language-features", 4 | "editor.formatOnSave": true, 5 | "editor.indentSize": 2 6 | }, 7 | "[jsonc]": { 8 | "editor.defaultFormatter": "vscode.json-language-features", 9 | "editor.formatOnSave": true, 10 | "editor.indentSize": 2 11 | }, 12 | "[typescript]": { 13 | "editor.formatOnSave": true, 14 | "editor.defaultFormatter": "esbenp.prettier-vscode" 15 | }, 16 | "[typescriptreact]": { 17 | "editor.formatOnSave": true, 18 | "editor.defaultFormatter": "esbenp.prettier-vscode" 19 | }, 20 | "[javascript]": { 21 | "editor.formatOnSave": true, 22 | "editor.defaultFormatter": "esbenp.prettier-vscode" 23 | }, 24 | } -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" # See documentation for possible values 9 | directory: "/control-plane" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | - package-ecosystem: "cargo" # See documentation for possible values 13 | directory: "/proxy" # Location of package manifests 14 | schedule: 15 | interval: "weekly" 16 | -------------------------------------------------------------------------------- /control-plane/src/app/(main)/api/files/tree/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | import { validateRequest } from "@/lib/auth"; 3 | import { buildFileTree } from "@/lib/fileUtils"; 4 | 5 | export async function GET() { 6 | try { 7 | const { user } = await validateRequest(); 8 | 9 | if (!user) { 10 | return NextResponse.json({ success: false, message: "로그인이 필요합니다." }, { status: 401 }); 11 | } 12 | 13 | const fileTree = await buildFileTree(user.loginName); 14 | 15 | return NextResponse.json({ success: true, files: fileTree }); 16 | } catch (error) { 17 | console.error("File tree error:", error); 18 | return NextResponse.json( 19 | { success: false, message: "파일 목록을 불러올 수 없습니다." }, 20 | { status: 500 } 21 | ); 22 | } 23 | } -------------------------------------------------------------------------------- /control-plane/src/components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as LabelPrimitive from "@radix-ui/react-label" 5 | import { cva, type VariantProps } from "class-variance-authority" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const labelVariants = cva( 10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 11 | ) 12 | 13 | const Label = React.forwardRef< 14 | React.ElementRef, 15 | React.ComponentPropsWithoutRef & 16 | VariantProps 17 | >(({ className, ...props }, ref) => ( 18 | 23 | )) 24 | Label.displayName = LabelPrimitive.Root.displayName 25 | 26 | export { Label } 27 | -------------------------------------------------------------------------------- /control-plane/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": [ 4 | "dom", 5 | "dom.iterable", 6 | "esnext" 7 | ], 8 | "allowJs": true, 9 | "skipLibCheck": true, 10 | "strict": true, 11 | "noEmit": true, 12 | "esModuleInterop": true, 13 | "module": "esnext", 14 | "moduleResolution": "bundler", 15 | "resolveJsonModule": true, 16 | "isolatedModules": true, 17 | "jsx": "preserve", 18 | "incremental": true, 19 | "plugins": [ 20 | { 21 | "name": "next" 22 | } 23 | ], 24 | "paths": { 25 | "@/*": [ 26 | "./src/*" 27 | ] 28 | }, 29 | "target": "ES2017" 30 | }, 31 | "include": [ 32 | "next-env.d.ts", 33 | "**/*.ts", 34 | "**/*.tsx", 35 | ".next/types/**/*.ts" 36 | ], 37 | "exclude": [ 38 | "node_modules" 39 | ] 40 | } 41 | -------------------------------------------------------------------------------- /control-plane/sentry.edge.config.ts: -------------------------------------------------------------------------------- 1 | // This file configures the initialization of Sentry for edge features (middleware, edge routes, and so on). 2 | // The config you add here will be used whenever one of the edge features is loaded. 3 | // Note that this config is unrelated to the Vercel Edge Runtime and is also required when running locally. 4 | // https://docs.sentry.io/platforms/javascript/guides/nextjs/ 5 | 6 | import * as Sentry from "@sentry/nextjs"; 7 | 8 | Sentry.init({ 9 | dsn: "https://abb87d1e259356c7c14ecfee98d1c709@o4504757655764992.ingest.us.sentry.io/4509137772609536", 10 | 11 | // Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control. 12 | tracesSampleRate: 1, 13 | 14 | // Setting this option to true will print useful information to the console while you're setting up Sentry. 15 | debug: false, 16 | }); 17 | -------------------------------------------------------------------------------- /control-plane/src/app/(main)/account/LogoutButton.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import { LogOut } from "lucide-react"; 5 | 6 | export default function LogoutButton() { 7 | const handleLogout = async () => { 8 | try { 9 | const response = await fetch("/api/account/logout", { 10 | method: "POST", 11 | headers: { 12 | "Content-Type": "application/json", 13 | }, 14 | }); 15 | 16 | if (response.ok) { 17 | window.location.href = "/"; 18 | } 19 | } catch (error) { 20 | console.error("Logout error:", error); 21 | } 22 | }; 23 | 24 | return ( 25 | 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /control-plane/src/migrations/1752477398300_change_email_verified_to_timestamp.ts: -------------------------------------------------------------------------------- 1 | import { Kysely, sql } from "kysely"; 2 | 3 | export async function up(db: Kysely): Promise { 4 | // Drop the boolean column and add the timestamp column 5 | await db.schema 6 | .alterTable("users") 7 | .dropColumn("email_verified") 8 | .execute(); 9 | 10 | await db.schema 11 | .alterTable("users") 12 | .addColumn("email_verified_at", "timestamptz", (col) => col) 13 | .execute(); 14 | } 15 | 16 | export async function down(db: Kysely): Promise { 17 | // Revert back to boolean column 18 | await db.schema 19 | .alterTable("users") 20 | .dropColumn("email_verified_at") 21 | .execute(); 22 | 23 | await db.schema 24 | .alterTable("users") 25 | .addColumn("email_verified", "boolean", (col) => col.defaultTo(false).notNull()) 26 | .execute(); 27 | } -------------------------------------------------------------------------------- /control-plane/src/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Input = React.forwardRef>( 6 | ({ className, type, ...props }, ref) => { 7 | return ( 8 | 17 | ) 18 | } 19 | ) 20 | Input.displayName = "Input" 21 | 22 | export { Input } 23 | -------------------------------------------------------------------------------- /control-plane/src/app/(main)/files/page.tsx: -------------------------------------------------------------------------------- 1 | import { validateRequest } from "@/lib/auth"; 2 | import FileExplorer from "@/components/browser/FileExplorer"; 3 | import { buildFileTree } from "@/lib/fileUtils"; 4 | 5 | export default async function File() { 6 | const { user } = await validateRequest(); 7 | 8 | if (!user) { 9 | return ( 10 |
11 |
12 |

로그인 필요

13 |

파일 관리를 위해 로그인이 필요합니다.

14 |
15 |
16 | ); 17 | } 18 | 19 | const fileTree = await buildFileTree(user.loginName); 20 | 21 | return ( 22 |
23 | 24 |
25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /control-plane/src/migrations/1750366585000_add_home_directory_size.ts: -------------------------------------------------------------------------------- 1 | import type { Kysely } from "kysely"; 2 | 3 | // `any` is required here since migrations should be frozen in time. alternatively, keep a "snapshot" db interface. 4 | export async function up(db: Kysely): Promise { 5 | await db.schema 6 | .alterTable("users") 7 | .addColumn("home_directory_size_bytes", "integer", (col) => 8 | col.defaultTo(0) 9 | ) 10 | .addColumn("home_directory_size_bytes_updated_at", "timestamp", (col) => 11 | col.defaultTo(null) 12 | ) 13 | .execute(); 14 | } 15 | 16 | // `any` is required here since migrations should be frozen in time. alternatively, keep a "snapshot" db interface. 17 | export async function down(db: Kysely): Promise { 18 | await db.schema 19 | .alterTable("users") 20 | .dropColumn("home_directory_size_bytes") 21 | .dropColumn("home_directory_size_bytes_updated_at") 22 | .execute(); 23 | } 24 | -------------------------------------------------------------------------------- /control-plane/src/components/ui/sonner.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useTheme } from "next-themes" 4 | import { Toaster as Sonner } from "sonner" 5 | 6 | type ToasterProps = React.ComponentProps 7 | 8 | const Toaster = ({ ...props }: ToasterProps) => { 9 | const { theme = "system" } = useTheme() 10 | 11 | return ( 12 | 28 | ) 29 | } 30 | 31 | export { Toaster } 32 | -------------------------------------------------------------------------------- /control-plane/src/cli/migrate-down.ts: -------------------------------------------------------------------------------- 1 | import * as path from "path"; 2 | import { promises as fs } from "fs"; 3 | import { Migrator, FileMigrationProvider } from "kysely"; 4 | import { db } from "@/lib/database"; 5 | 6 | async function migrateToLatest() { 7 | const migrator = new Migrator({ 8 | db, 9 | provider: new FileMigrationProvider({ 10 | fs, 11 | path, 12 | // This needs to be an absolute path. 13 | migrationFolder: path.join(__dirname, "../migrations"), 14 | }), 15 | }); 16 | 17 | const { error, results } = await migrator.migrateDown(); 18 | 19 | results?.forEach((it) => { 20 | if (it.status === "Success") { 21 | console.log(`migration "${it.migrationName}" was reverted successfully`); 22 | } else if (it.status === "Error") { 23 | console.error(`failed to execute migration "${it.migrationName}"`); 24 | } 25 | }); 26 | 27 | if (error) { 28 | console.error("failed to migrate"); 29 | console.error(error); 30 | process.exit(1); 31 | } 32 | 33 | await db.destroy(); 34 | } 35 | 36 | migrateToLatest(); 37 | -------------------------------------------------------------------------------- /control-plane/src/cli/migrate.ts: -------------------------------------------------------------------------------- 1 | import * as path from "path"; 2 | import { promises as fs } from "fs"; 3 | import { Migrator, FileMigrationProvider } from "kysely"; 4 | import { db } from "@/lib/database"; 5 | 6 | async function migrateToLatest() { 7 | const migrator = new Migrator({ 8 | db, 9 | provider: new FileMigrationProvider({ 10 | fs, 11 | path, 12 | // This needs to be an absolute path. 13 | migrationFolder: path.join(__dirname, "../migrations"), 14 | }), 15 | }); 16 | 17 | const { error, results } = await migrator.migrateToLatest(); 18 | 19 | results?.forEach((it) => { 20 | if (it.status === "Success") { 21 | console.log(`migration "${it.migrationName}" was executed successfully`); 22 | } else if (it.status === "Error") { 23 | console.error(`failed to execute migration "${it.migrationName}"`); 24 | } 25 | }); 26 | 27 | if (error) { 28 | console.error("failed to migrate"); 29 | console.error(error); 30 | process.exit(1); 31 | } 32 | 33 | await db.destroy(); 34 | } 35 | 36 | migrateToLatest(); 37 | -------------------------------------------------------------------------------- /control-plane/src/app/(main)/api/account/logout/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from "next/server"; 2 | import { cookies } from "next/headers"; 3 | import { lucia, validateRequest } from "@/lib/auth"; 4 | 5 | export async function POST(request: NextRequest) { 6 | try { 7 | const { session } = await validateRequest(); 8 | if (!session) { 9 | return NextResponse.json( 10 | { success: false, message: "로그인이 필요합니다." }, 11 | { status: 401 } 12 | ); 13 | } 14 | 15 | await lucia.invalidateSession(session.id); 16 | 17 | const sessionCookie = lucia.createBlankSessionCookie(); 18 | (await cookies()).set( 19 | sessionCookie.name, 20 | sessionCookie.value, 21 | sessionCookie.attributes 22 | ); 23 | 24 | return NextResponse.json({ 25 | success: true, 26 | message: "로그아웃되었습니다.", 27 | }); 28 | } catch (error) { 29 | console.error("Logout error:", error); 30 | return NextResponse.json( 31 | { success: false, message: "로그아웃 중 오류가 발생했습니다." }, 32 | { status: 500 } 33 | ); 34 | } 35 | } -------------------------------------------------------------------------------- /control-plane/.dockerignore: -------------------------------------------------------------------------------- 1 | # Build output 2 | .next/ 3 | out/ 4 | build/ 5 | dist/ 6 | .swc/ 7 | 8 | # Dependencies 9 | node_modules/ 10 | 11 | # Environment files 12 | .env* 13 | !.env.example 14 | 15 | # Logs 16 | npm-debug.log* 17 | pnpm-debug.log* 18 | yarn-debug.log* 19 | yarn-error.log* 20 | *.log 21 | 22 | # Runtime data 23 | pids 24 | *.pid 25 | *.seed 26 | *.pid.lock 27 | 28 | # Coverage directory used by tools like istanbul 29 | coverage/ 30 | *.lcov 31 | .nyc_output 32 | 33 | # ESLint cache 34 | .eslintcache 35 | 36 | # IDE and editor files 37 | .vscode/ 38 | .idea/ 39 | *.swp 40 | *.swo 41 | *~ 42 | 43 | # OS generated files 44 | .DS_Store 45 | .DS_Store? 46 | ._* 47 | .Spotlight-V100 48 | .Trashes 49 | ehthumbs.db 50 | Thumbs.db 51 | 52 | # Git 53 | .git/ 54 | .gitignore 55 | 56 | # Docker 57 | Dockerfile* 58 | docker-compose*.yml 59 | .dockerignore 60 | 61 | # CI/CD 62 | .github/ 63 | 64 | # Testing 65 | jest.config.js 66 | jest.setup.js 67 | 68 | # Documentation 69 | README.md 70 | LICENSE 71 | CHANGELOG.md 72 | *.md 73 | 74 | # Temporary files 75 | tmp/ 76 | temp/ 77 | 78 | # Development tools 79 | .turbo -------------------------------------------------------------------------------- /control-plane/src/migrations/1717885412_init.ts: -------------------------------------------------------------------------------- 1 | import { Kysely, sql } from "kysely"; 2 | 3 | export async function up(db: Kysely): Promise { 4 | await db.schema 5 | .createTable("users") 6 | .addColumn("id", "serial", (col) => col.primaryKey()) 7 | .addColumn("login_name", "text", (col) => col.notNull().unique()) 8 | .addColumn("password_hash", "text", (col) => col.notNull()) 9 | .addColumn("email", "text", (col) => col.unique()) 10 | .addColumn("created_at", "timestamptz", (col) => 11 | col.defaultTo(sql`now()`).notNull() 12 | ) 13 | .execute(); 14 | 15 | await db.schema 16 | .createTable("sessions") 17 | .addColumn("id", "text", (col) => col.primaryKey()) 18 | .addColumn("user_id", "integer", (col) => 19 | col.notNull().references("users.id").onDelete("cascade").notNull() 20 | ) 21 | .addColumn("expires_at", "timestamptz", (col) => col.notNull()) 22 | .execute(); 23 | } 24 | 25 | export async function down(db: Kysely): Promise { 26 | await db.schema.dropTable("sessions").execute(); 27 | await db.schema.dropTable("users").execute(); 28 | } 29 | -------------------------------------------------------------------------------- /control-plane/src/components/ui/checkbox.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as CheckboxPrimitive from "@radix-ui/react-checkbox" 5 | import { Check } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const Checkbox = React.forwardRef< 10 | React.ElementRef, 11 | React.ComponentPropsWithoutRef 12 | >(({ className, ...props }, ref) => ( 13 | 21 | 24 | 25 | 26 | 27 | )) 28 | Checkbox.displayName = CheckboxPrimitive.Root.displayName 29 | 30 | export { Checkbox } 31 | -------------------------------------------------------------------------------- /control-plane/src/components/browser/DeleteButton.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import { toast } from "sonner"; 5 | 6 | export default function DeleteButton({ filename }: { filename: string }) { 7 | return ( 8 | 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /control-plane/src/components/browser/CreateFileButton.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import { toast } from "sonner"; 5 | 6 | export function CreateFileButton({ baseDirectory }: { baseDirectory: string }) { 7 | return ( 8 | 40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /control-plane/src/instrumentation-client.ts: -------------------------------------------------------------------------------- 1 | // This file configures the initialization of Sentry on the client. 2 | // The added config here will be used whenever a users loads a page in their browser. 3 | // https://docs.sentry.io/platforms/javascript/guides/nextjs/ 4 | 5 | import * as Sentry from "@sentry/nextjs"; 6 | 7 | Sentry.init({ 8 | dsn: "https://abb87d1e259356c7c14ecfee98d1c709@o4504757655764992.ingest.us.sentry.io/4509137772609536", 9 | 10 | // Add optional integrations for additional features 11 | integrations: [Sentry.replayIntegration()], 12 | 13 | // Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control. 14 | tracesSampleRate: 1, 15 | 16 | // Define how likely Replay events are sampled. 17 | // This sets the sample rate to be 10%. You may want this to be 100% while 18 | // in development and sample at a lower rate in production 19 | replaysSessionSampleRate: 0.1, 20 | 21 | // Define how likely Replay events are sampled when an error occurs. 22 | replaysOnErrorSampleRate: 1.0, 23 | 24 | // Setting this option to true will print useful information to the console while you're setting up Sentry. 25 | debug: false, 26 | }); 27 | 28 | export const onRouterTransitionStart = Sentry.captureRouterTransitionStart; 29 | -------------------------------------------------------------------------------- /control-plane/src/components/browser/EditFilenameButton.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import { toast } from "sonner"; 5 | 6 | export default function DeleteButton({ filename }: { filename: string }) { 7 | return ( 8 | 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /control-plane/src/components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cva, type VariantProps } from "class-variance-authority" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const badgeVariants = cva( 7 | "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", 8 | { 9 | variants: { 10 | variant: { 11 | default: 12 | "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", 13 | secondary: 14 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", 15 | destructive: 16 | "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", 17 | outline: "text-foreground", 18 | }, 19 | }, 20 | defaultVariants: { 21 | variant: "default", 22 | }, 23 | } 24 | ) 25 | 26 | export interface BadgeProps 27 | extends React.HTMLAttributes, 28 | VariantProps {} 29 | 30 | function Badge({ className, variant, ...props }: BadgeProps) { 31 | return ( 32 |
33 | ) 34 | } 35 | 36 | export { Badge, badgeVariants } 37 | -------------------------------------------------------------------------------- /control-plane/src/migrations/1750366586000_add_home_directory_size_history.ts: -------------------------------------------------------------------------------- 1 | import type { Kysely } from "kysely"; 2 | import { sql } from "kysely"; 3 | 4 | // `any` is required here since migrations should be frozen in time. alternatively, keep a "snapshot" db interface. 5 | export async function up(db: Kysely): Promise { 6 | await db.schema 7 | .createTable("home_directory_size_history") 8 | .addColumn("id", "serial", (col) => col.primaryKey()) 9 | .addColumn("user_id", "integer", (col) => 10 | col.references("users.id").onDelete("cascade") 11 | ) 12 | .addColumn("size_bytes", "integer", (col) => col.notNull()) 13 | .addColumn("recorded_at", "timestamp", (col) => 14 | col.notNull().defaultTo(sql`now()`) 15 | ) 16 | .execute(); 17 | 18 | // Add index for efficient queries 19 | await db.schema 20 | .createIndex("home_directory_size_history_user_id_recorded_at_idx") 21 | .on("home_directory_size_history") 22 | .columns(["user_id", "recorded_at"]) 23 | .execute(); 24 | } 25 | 26 | // `any` is required here since migrations should be frozen in time. alternatively, keep a "snapshot" db interface. 27 | export async function down(db: Kysely): Promise { 28 | await db.schema.dropTable("home_directory_size_history").execute(); 29 | } 30 | -------------------------------------------------------------------------------- /control-plane/src/migrations/1752477398301_add_password_reset_tokens.ts: -------------------------------------------------------------------------------- 1 | import { Kysely, sql } from "kysely"; 2 | 3 | export async function up(db: Kysely): Promise { 4 | // Create password_reset_tokens table 5 | await db.schema 6 | .createTable("password_reset_tokens") 7 | .addColumn("id", "text", (col) => col.primaryKey()) 8 | .addColumn("user_id", "integer", (col) => 9 | col.notNull().references("users.id").onDelete("cascade") 10 | ) 11 | .addColumn("email", "text", (col) => col.notNull()) 12 | .addColumn("expires_at", "timestamptz", (col) => col.notNull()) 13 | .addColumn("created_at", "timestamptz", (col) => 14 | col.defaultTo(sql`now()`).notNull() 15 | ) 16 | .execute(); 17 | 18 | // Create index on user_id for faster lookups 19 | await db.schema 20 | .createIndex("password_reset_tokens_user_id_idx") 21 | .on("password_reset_tokens") 22 | .column("user_id") 23 | .execute(); 24 | 25 | // Create index on email for faster lookups by email 26 | await db.schema 27 | .createIndex("password_reset_tokens_email_idx") 28 | .on("password_reset_tokens") 29 | .column("email") 30 | .execute(); 31 | } 32 | 33 | export async function down(db: Kysely): Promise { 34 | await db.schema.dropTable("password_reset_tokens").execute(); 35 | } -------------------------------------------------------------------------------- /control-plane/src/migrations/1752477398302_add_account_deletion_tokens.ts: -------------------------------------------------------------------------------- 1 | import { Kysely, sql } from "kysely"; 2 | 3 | export async function up(db: Kysely): Promise { 4 | // Create account_deletion_tokens table 5 | await db.schema 6 | .createTable("account_deletion_tokens") 7 | .addColumn("id", "text", (col) => col.primaryKey()) 8 | .addColumn("user_id", "integer", (col) => 9 | col.notNull().references("users.id").onDelete("cascade") 10 | ) 11 | .addColumn("email", "text", (col) => col.notNull()) 12 | .addColumn("expires_at", "timestamptz", (col) => col.notNull()) 13 | .addColumn("created_at", "timestamptz", (col) => 14 | col.defaultTo(sql`now()`).notNull() 15 | ) 16 | .execute(); 17 | 18 | // Create index on user_id for faster lookups 19 | await db.schema 20 | .createIndex("account_deletion_tokens_user_id_idx") 21 | .on("account_deletion_tokens") 22 | .column("user_id") 23 | .execute(); 24 | 25 | // Create index on email for faster lookups by email 26 | await db.schema 27 | .createIndex("account_deletion_tokens_email_idx") 28 | .on("account_deletion_tokens") 29 | .column("email") 30 | .execute(); 31 | } 32 | 33 | export async function down(db: Kysely): Promise { 34 | await db.schema.dropTable("account_deletion_tokens").execute(); 35 | } -------------------------------------------------------------------------------- /control-plane/src/components/browser/CreateDirectoryButton.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import { toast } from "sonner"; 5 | 6 | export function CreateDirectoryButton({ 7 | baseDirectory, 8 | }: { 9 | baseDirectory: string; 10 | }) { 11 | return ( 12 | 45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /control-plane/src/components/ModeToggle.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import { Moon, Sun } from "lucide-react" 5 | import { useTheme } from "next-themes" 6 | 7 | import { Button } from "@/components/ui/button" 8 | import { 9 | DropdownMenu, 10 | DropdownMenuContent, 11 | DropdownMenuItem, 12 | DropdownMenuTrigger, 13 | } from "@/components/ui/dropdown-menu" 14 | 15 | export function ModeToggle() { 16 | const { setTheme } = useTheme() 17 | 18 | return ( 19 | 20 | 21 | 26 | 27 | 28 | setTheme("light")}> 29 | Light 30 | 31 | setTheme("dark")}> 32 | Dark 33 | 34 | setTheme("system")}> 35 | System 36 | 37 | 38 | 39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /control-plane/src/migrations/1752476803300_add_email_verification.ts: -------------------------------------------------------------------------------- 1 | import { Kysely, sql } from "kysely"; 2 | 3 | export async function up(db: Kysely): Promise { 4 | // Add email_verified column to users table 5 | await db.schema 6 | .alterTable("users") 7 | .addColumn("email_verified", "boolean", (col) => col.defaultTo(false).notNull()) 8 | .execute(); 9 | 10 | // Create email_verification_tokens table 11 | await db.schema 12 | .createTable("email_verification_tokens") 13 | .addColumn("id", "text", (col) => col.primaryKey()) 14 | .addColumn("user_id", "integer", (col) => 15 | col.notNull().references("users.id").onDelete("cascade") 16 | ) 17 | .addColumn("email", "text", (col) => col.notNull()) 18 | .addColumn("expires_at", "timestamptz", (col) => col.notNull()) 19 | .addColumn("created_at", "timestamptz", (col) => 20 | col.defaultTo(sql`now()`).notNull() 21 | ) 22 | .execute(); 23 | 24 | // Create index on user_id for faster lookups 25 | await db.schema 26 | .createIndex("email_verification_tokens_user_id_idx") 27 | .on("email_verification_tokens") 28 | .column("user_id") 29 | .execute(); 30 | } 31 | 32 | export async function down(db: Kysely): Promise { 33 | await db.schema.dropTable("email_verification_tokens").execute(); 34 | await db.schema 35 | .alterTable("users") 36 | .dropColumn("email_verified") 37 | .execute(); 38 | } -------------------------------------------------------------------------------- /control-plane/src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from "clsx"; 2 | import { twMerge } from "tailwind-merge"; 3 | import { S3Client } from "@aws-sdk/client-s3"; 4 | 5 | export function cn(...inputs: ClassValue[]) { 6 | return twMerge(clsx(inputs)); 7 | } 8 | 9 | export function getPublicAssetUrl(username: string, filename: string) { 10 | const pathname = filename.replaceAll("//", "/").replace(/^\//, ""); 11 | 12 | return process.env.NODE_ENV === "production" 13 | ? `https://${username}.${process.env.NEXT_PUBLIC_DOMAIN}/${pathname}` 14 | : `http://${username}.${process.env.NEXT_PUBLIC_DOMAIN}/${pathname}`; 15 | } 16 | 17 | export function getHomepageUrl(username: string) { 18 | return process.env.NODE_ENV === "production" 19 | ? `https://${username}.${process.env.NEXT_PUBLIC_DOMAIN}` 20 | : `http://${username}.${process.env.NEXT_PUBLIC_DOMAIN}`; 21 | } 22 | 23 | export function getRenderedSiteUrl(username: string) { 24 | return `https://r2-screenshots.${process.env.NEXT_PUBLIC_DOMAIN}/${username}.png`; 25 | } 26 | 27 | export function getUserHomeDirectory(loginName: string) { 28 | return `${loginName}`; 29 | } 30 | 31 | export const s3Client = new S3Client({ 32 | region: "auto", 33 | endpoint: process.env.R2_ACCOUNT_ID 34 | ? `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com` 35 | : undefined, 36 | credentials: { 37 | accessKeyId: process.env.AWS_ACCESS_KEY_ID!, 38 | secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!, 39 | }, 40 | }); 41 | -------------------------------------------------------------------------------- /control-plane/src/components/browser/DirectoryBreadcrumb.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Breadcrumb, 3 | BreadcrumbItem, 4 | BreadcrumbLink, 5 | BreadcrumbList, 6 | BreadcrumbPage, 7 | BreadcrumbSeparator, 8 | } from "@/components/ui/breadcrumb"; 9 | 10 | export default function DirectoryBreadcrumb({ paths }: { paths: string[] }) { 11 | const currentFilename = paths[paths.length - 1]; 12 | 13 | return ( 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | {paths.length > 0 && 22 | paths.slice(0, -1).map((dir, index) => { 23 | const parentDirectory = paths.slice(0, index + 1).join("/"); 24 | 25 | if (parentDirectory === "/") { 26 | return null; 27 | } 28 | 29 | return ( 30 | <> 31 | 32 | 33 | {dir} 34 | 35 | 36 | 37 | 38 | ); 39 | })} 40 | 41 | {currentFilename && ( 42 | 43 | {paths[paths.length - 1]} 44 | 45 | )} 46 | 47 | 48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /control-plane/README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | # or 12 | pnpm dev 13 | # or 14 | bun dev 15 | ``` 16 | 17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 18 | 19 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 20 | 21 | This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. 22 | 23 | ## Learn More 24 | 25 | To learn more about Next.js, take a look at the following resources: 26 | 27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 29 | 30 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 31 | 32 | ## Deploy on Vercel 33 | 34 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 35 | 36 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 37 | -------------------------------------------------------------------------------- /control-plane/src/app/(main)/api/account/set-discoverable/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from "next/server"; 2 | import { validateRequest } from "@/lib/auth"; 3 | import { db } from "@/lib/database"; 4 | 5 | export async function POST(request: NextRequest) { 6 | try { 7 | const { user } = await validateRequest(); 8 | if (!user) { 9 | return NextResponse.json( 10 | { success: false, message: "로그인이 필요합니다." }, 11 | { status: 401 } 12 | ); 13 | } 14 | 15 | const { discoverable } = await request.json(); 16 | 17 | if (typeof discoverable !== "boolean") { 18 | return NextResponse.json( 19 | { success: false, message: "유효하지 않은 설정값입니다." }, 20 | { status: 400 } 21 | ); 22 | } 23 | 24 | await db.transaction().execute(async (trx) => { 25 | await trx 26 | .updateTable("users") 27 | .set("discoverable", discoverable) 28 | .where("id", "=", user.id) 29 | .execute(); 30 | 31 | await trx 32 | .updateTable("users") 33 | .set("site_updated_at", new Date()) 34 | .where("id", "=", user.id) 35 | .execute(); 36 | }); 37 | 38 | return NextResponse.json({ 39 | success: true, 40 | message: `공개 설정이 ${discoverable ? "활성화" : "비활성화"}되었습니다.`, 41 | }); 42 | } catch (error) { 43 | console.error("Set discoverable error:", error); 44 | return NextResponse.json( 45 | { success: false, message: "설정 변경 중 오류가 발생했습니다." }, 46 | { status: 500 } 47 | ); 48 | } 49 | } -------------------------------------------------------------------------------- /control-plane/jest.config.js: -------------------------------------------------------------------------------- 1 | const nextJest = require('next/jest'); 2 | 3 | const createJestConfig = nextJest({ 4 | // Provide the path to your Next.js app to load next.config.js and .env files 5 | dir: './', 6 | }); 7 | 8 | // Add any custom config to be passed to Jest 9 | const customJestConfig = { 10 | // Add more setup options before each test is run 11 | setupFilesAfterEnv: ['/jest.setup.js'], 12 | 13 | // Test environment 14 | testEnvironment: 'jest-environment-jsdom', 15 | 16 | // Test file patterns 17 | testMatch: [ 18 | '**/__tests__/**/*.(ts|tsx|js)', 19 | '**/*.(test|spec).(ts|tsx|js)' 20 | ], 21 | 22 | // Coverage settings 23 | collectCoverageFrom: [ 24 | 'src/**/*.{ts,tsx}', 25 | '!src/**/*.d.ts', 26 | '!src/**/*.stories.{ts,tsx}', 27 | '!src/**/*.test.{ts,tsx}', 28 | '!src/**/__tests__/**', 29 | ], 30 | 31 | // Extensions to resolve 32 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], 33 | 34 | // Ignore patterns 35 | testPathIgnorePatterns: ['/.next/', '/node_modules/'], 36 | 37 | // Mock static assets and modules 38 | moduleNameMapper: { 39 | '^@/(.*)$': '/src/$1', 40 | '\\.(css|less|scss|sass)$': 'identity-obj-proxy', 41 | }, 42 | 43 | // Transform ES modules 44 | transformIgnorePatterns: [ 45 | 'node_modules/(?!(lucia|@lucia-auth)/)' 46 | ], 47 | }; 48 | 49 | // createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async 50 | module.exports = createJestConfig(customJestConfig); -------------------------------------------------------------------------------- /control-plane/next.config.mjs: -------------------------------------------------------------------------------- 1 | import { withSentryConfig } from "@sentry/nextjs"; 2 | /** @type {import('next').NextConfig} */ 3 | const nextConfig = { 4 | serverExternalPackages: ["@node-rs/argon2"], 5 | images: { 6 | remotePatterns: [new URL("https://r2-screenshots.naru.pub/*")], 7 | }, 8 | }; 9 | 10 | export default withSentryConfig( 11 | withSentryConfig(nextConfig, { 12 | // For all available options, see: 13 | // https://www.npmjs.com/package/@sentry/webpack-plugin#options 14 | 15 | org: "jihyeok-seo", 16 | project: "naru-pub", 17 | 18 | // Only print logs for uploading source maps in CI 19 | silent: !process.env.CI, 20 | 21 | // For all available options, see: 22 | // https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/ 23 | 24 | // Upload a larger set of source maps for prettier stack traces (increases build time) 25 | widenClientFileUpload: true, 26 | 27 | // Route browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers. 28 | // This can increase your server load as well as your hosting bill. 29 | // Note: Check that the configured route will not match with your Next.js middleware, otherwise reporting of client- 30 | // side errors will fail. 31 | tunnelRoute: "/monitoring", 32 | 33 | // Automatically tree-shake Sentry logger statements to reduce bundle size 34 | disableLogger: true, 35 | 36 | // Enables automatic instrumentation of Vercel Cron Monitors. (Does not yet work with App Router route handlers.) 37 | // See the following for more information: 38 | // https://docs.sentry.io/product/crons/ 39 | // https://vercel.com/docs/cron-jobs 40 | automaticVercelMonitors: true, 41 | }) 42 | ); 43 | -------------------------------------------------------------------------------- /control-plane/src/app/(main)/api/account/verify-email/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from "next/server"; 2 | import { db } from "@/lib/database"; 3 | 4 | export async function POST(request: NextRequest) { 5 | try { 6 | const { token } = await request.json(); 7 | 8 | if (!token) { 9 | return NextResponse.json( 10 | { success: false, message: "인증 토큰이 필요합니다." }, 11 | { status: 400 } 12 | ); 13 | } 14 | 15 | const verificationToken = await db 16 | .selectFrom("email_verification_tokens") 17 | .selectAll() 18 | .where("id", "=", token) 19 | .where("expires_at", ">", new Date()) 20 | .executeTakeFirst(); 21 | 22 | if (!verificationToken) { 23 | return NextResponse.json( 24 | { success: false, message: "유효하지 않거나 만료된 인증 토큰입니다." }, 25 | { status: 400 } 26 | ); 27 | } 28 | 29 | await db.transaction().execute(async (trx) => { 30 | // Mark email as verified with current timestamp 31 | await trx 32 | .updateTable("users") 33 | .set({ 34 | email: verificationToken.email, 35 | email_verified_at: new Date(), 36 | }) 37 | .where("id", "=", verificationToken.user_id) 38 | .execute(); 39 | 40 | // Delete the verification token 41 | await trx 42 | .deleteFrom("email_verification_tokens") 43 | .where("id", "=", token) 44 | .execute(); 45 | }); 46 | 47 | return NextResponse.json({ 48 | success: true, 49 | message: "이메일이 성공적으로 인증되었습니다.", 50 | }); 51 | } catch (error) { 52 | console.error("Email verification error:", error); 53 | return NextResponse.json( 54 | { success: false, message: "이메일 인증 중 오류가 발생했습니다." }, 55 | { status: 500 } 56 | ); 57 | } 58 | } -------------------------------------------------------------------------------- /control-plane/src/components/browser/UploadButton.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState } from "react"; 4 | import { Input } from "@/components/ui/input"; 5 | import { Button } from "@/components/ui/button"; 6 | import { toast } from "sonner"; 7 | 8 | export default function UploadButton({ directory }: { directory: string }) { 9 | const [isUploading, setIsUploading] = useState(false); 10 | 11 | const handleSubmit = async (e: React.FormEvent) => { 12 | e.preventDefault(); 13 | setIsUploading(true); 14 | 15 | const formData = new FormData(e.currentTarget); 16 | const files = formData.getAll("file") as File[]; 17 | 18 | if (files.length === 0) { 19 | toast.error("파일을 선택해주세요."); 20 | setIsUploading(false); 21 | return; 22 | } 23 | 24 | try { 25 | const uploadFormData = new FormData(); 26 | uploadFormData.append("directory", directory); 27 | files.forEach(file => uploadFormData.append("file", file)); 28 | 29 | const response = await fetch("/api/files/upload", { 30 | method: "POST", 31 | body: uploadFormData, 32 | }); 33 | 34 | const res = await response.json(); 35 | if (res.success) { 36 | toast.success(res.message); 37 | window.location.reload(); 38 | } else { 39 | toast.error(res.message); 40 | } 41 | } catch (error) { 42 | toast.error("파일 업로드에 실패했습니다."); 43 | } finally { 44 | setIsUploading(false); 45 | } 46 | }; 47 | 48 | return ( 49 |
50 | 51 | 54 |
55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /control-plane/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:24-slim 2 | 3 | # Install system dependencies for Playwright in single layer 4 | RUN apt-get update && apt-get install -y \ 5 | wget \ 6 | gnupg \ 7 | ca-certificates \ 8 | fonts-liberation \ 9 | libappindicator3-1 \ 10 | libasound2 \ 11 | libatk-bridge2.0-0 \ 12 | libatk1.0-0 \ 13 | libcups2 \ 14 | libdbus-1-3 \ 15 | libdrm2 \ 16 | libgbm1 \ 17 | libgtk-3-0 \ 18 | libnspr4 \ 19 | libnss3 \ 20 | libx11-xcb1 \ 21 | libxcomposite1 \ 22 | libxdamage1 \ 23 | libxrandr2 \ 24 | libxss1 \ 25 | libxtst6 \ 26 | xvfb \ 27 | && rm -rf /var/lib/apt/lists/* 28 | 29 | WORKDIR /app 30 | 31 | # Setup pnpm in single layer 32 | RUN npm install --global corepack@latest && \ 33 | corepack enable pnpm && \ 34 | corepack use pnpm@latest-10 35 | 36 | # Copy dependency files first for better caching 37 | COPY package.json pnpm-lock.yaml ./ 38 | 39 | # Install dependencies (this layer will be cached unless package files change) 40 | RUN pnpm install --frozen-lockfile && \ 41 | npx playwright install chromium 42 | 43 | # Set build arguments and environment early 44 | ARG NEXT_PUBLIC_DOMAIN=naru.pub 45 | ENV NEXT_PUBLIC_DOMAIN=$NEXT_PUBLIC_DOMAIN 46 | 47 | # Copy config files (less frequently changed) 48 | COPY components.json \ 49 | kysely.config.ts \ 50 | next.config.mjs \ 51 | postcss.config.mjs \ 52 | sentry.edge.config.ts \ 53 | sentry.server.config.ts \ 54 | tailwind.config.ts \ 55 | tsconfig.json \ 56 | ./ 57 | 58 | # Copy static assets (less frequently changed) 59 | COPY public/ public/ 60 | 61 | # Copy source code (most frequently changed - should be last) 62 | COPY src/ src/ 63 | 64 | # Build the application 65 | RUN pnpm run build 66 | 67 | EXPOSE 3000 68 | 69 | CMD ["pnpm", "start"] -------------------------------------------------------------------------------- /control-plane/src/app/(main)/account/DownloadDirectoryButton.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import { Download } from "lucide-react"; 5 | import { useState } from "react"; 6 | import { toast } from "sonner"; 7 | 8 | export default function DownloadDirectoryButton() { 9 | const [isDownloading, setIsDownloading] = useState(false); 10 | 11 | const handleDownload = async () => { 12 | setIsDownloading(true); 13 | try { 14 | const response = await fetch("/api/user/download-directory"); 15 | 16 | if (!response.ok) { 17 | const errorData = await response.json(); 18 | toast.error(errorData.error || "갠홈 다운로드 중 오류가 발생했습니다."); 19 | return; 20 | } 21 | 22 | // Get the filename from the response headers 23 | const contentDisposition = response.headers.get("content-disposition"); 24 | const filename = contentDisposition?.match(/filename="(.+)"/)?.[1] || "directory.zip"; 25 | 26 | // Create blob and download 27 | const blob = await response.blob(); 28 | const url = URL.createObjectURL(blob); 29 | 30 | const a = document.createElement("a"); 31 | a.href = url; 32 | a.download = filename; 33 | document.body.appendChild(a); 34 | a.click(); 35 | document.body.removeChild(a); 36 | URL.revokeObjectURL(url); 37 | 38 | } catch (error) { 39 | console.error("Download error:", error); 40 | toast.error("갠홈 다운로드 중 오류가 발생했습니다."); 41 | } finally { 42 | setIsDownloading(false); 43 | } 44 | }; 45 | 46 | return ( 47 | 55 | ); 56 | } -------------------------------------------------------------------------------- /control-plane/src/lib/db.d.ts: -------------------------------------------------------------------------------- 1 | import type { ColumnType } from "kysely"; 2 | 3 | export type Generated = T extends ColumnType 4 | ? ColumnType 5 | : ColumnType; 6 | 7 | export type Timestamp = ColumnType; 8 | 9 | export interface EmailVerificationTokens { 10 | created_at: Generated; 11 | email: string; 12 | expires_at: Timestamp; 13 | id: string; 14 | user_id: number; 15 | } 16 | 17 | export interface PasswordResetTokens { 18 | created_at: Generated; 19 | email: string; 20 | expires_at: Timestamp; 21 | id: string; 22 | user_id: number; 23 | } 24 | 25 | export interface AccountDeletionTokens { 26 | created_at: Generated; 27 | email: string; 28 | expires_at: Timestamp; 29 | id: string; 30 | user_id: number; 31 | } 32 | 33 | export interface HomeDirectorySizeHistory { 34 | id: Generated; 35 | recorded_at: Generated; 36 | size_bytes: number; 37 | user_id: number | null; 38 | } 39 | 40 | export interface Sessions { 41 | expires_at: Timestamp; 42 | id: string; 43 | user_id: number; 44 | } 45 | 46 | export interface Users { 47 | created_at: Generated; 48 | discoverable: Generated; 49 | email: string | null; 50 | email_verified_at: Timestamp | null; 51 | home_directory_size_bytes: Generated; 52 | home_directory_size_bytes_updated_at: Timestamp | null; 53 | id: Generated; 54 | login_name: string; 55 | password_hash: string; 56 | site_rendered_at: Timestamp | null; 57 | site_updated_at: Timestamp | null; 58 | } 59 | 60 | export interface DB { 61 | account_deletion_tokens: AccountDeletionTokens; 62 | email_verification_tokens: EmailVerificationTokens; 63 | home_directory_size_history: HomeDirectorySizeHistory; 64 | password_reset_tokens: PasswordResetTokens; 65 | sessions: Sessions; 66 | users: Users; 67 | } 68 | -------------------------------------------------------------------------------- /control-plane/src/app/(main)/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 0 0% 3.9%; 9 | --card: 0 0% 100%; 10 | --card-foreground: 0 0% 3.9%; 11 | --popover: 0 0% 100%; 12 | --popover-foreground: 0 0% 3.9%; 13 | --primary: 0 0% 9%; 14 | --primary-foreground: 0 0% 98%; 15 | --secondary: 0 0% 96.1%; 16 | --secondary-foreground: 0 0% 9%; 17 | --muted: 0 0% 96.1%; 18 | --muted-foreground: 0 0% 45.1%; 19 | --accent: 0 0% 96.1%; 20 | --accent-foreground: 0 0% 9%; 21 | --destructive: 0 84.2% 60.2%; 22 | --destructive-foreground: 0 0% 98%; 23 | --border: 0 0% 89.8%; 24 | --input: 0 0% 89.8%; 25 | --ring: 0 0% 3.9%; 26 | --radius: 0rem; 27 | --chart-1: 12 76% 61%; 28 | --chart-2: 173 58% 39%; 29 | --chart-3: 197 37% 24%; 30 | --chart-4: 43 74% 66%; 31 | --chart-5: 27 87% 67%; 32 | 33 | --logo-filter: none; 34 | } 35 | 36 | .dark { 37 | --background: 0 0% 3.9%; 38 | --foreground: 0 0% 98%; 39 | --card: 0 0% 3.9%; 40 | --card-foreground: 0 0% 98%; 41 | --popover: 0 0% 3.9%; 42 | --popover-foreground: 0 0% 98%; 43 | --primary: 0 0% 98%; 44 | --primary-foreground: 0 0% 9%; 45 | --secondary: 0 0% 14.9%; 46 | --secondary-foreground: 0 0% 98%; 47 | --muted: 0 0% 14.9%; 48 | --muted-foreground: 0 0% 63.9%; 49 | --accent: 0 0% 14.9%; 50 | --accent-foreground: 0 0% 98%; 51 | --destructive: 0 62.8% 30.6%; 52 | --destructive-foreground: 0 0% 98%; 53 | --border: 0 0% 20%; 54 | --input: 0 0% 14.9%; 55 | --ring: 0 0% 83.1%; 56 | --chart-1: 220 70% 50%; 57 | --chart-2: 160 60% 45%; 58 | --chart-3: 30 80% 55%; 59 | --chart-4: 280 65% 60%; 60 | --chart-5: 340 75% 55%; 61 | 62 | --logo-filter: drop-shadow(0 0 1px white) drop-shadow(1px 0 1px white) drop-shadow(-1px 0 1px white) drop-shadow(0 1px 1px white) drop-shadow(0 -1px 1px white); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /control-plane/src/app/(main)/api/account/reset-password/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from "next/server"; 2 | import { db } from "@/lib/database"; 3 | import { hash } from "@node-rs/argon2"; 4 | 5 | export async function POST(request: NextRequest) { 6 | try { 7 | const { token, newPassword } = await request.json(); 8 | 9 | if (!token || !newPassword) { 10 | return NextResponse.json( 11 | { success: false, message: "토큰과 새 비밀번호가 필요합니다." }, 12 | { status: 400 } 13 | ); 14 | } 15 | 16 | const resetToken = await db 17 | .selectFrom("password_reset_tokens") 18 | .selectAll() 19 | .where("id", "=", token) 20 | .where("expires_at", ">", new Date()) 21 | .executeTakeFirst(); 22 | 23 | if (!resetToken) { 24 | return NextResponse.json( 25 | { success: false, message: "유효하지 않거나 만료된 비밀번호 재설정 토큰입니다." }, 26 | { status: 400 } 27 | ); 28 | } 29 | 30 | const newPasswordHash = await hash(newPassword); 31 | 32 | await db.transaction().execute(async (trx) => { 33 | // Update user password 34 | await trx 35 | .updateTable("users") 36 | .set("password_hash", newPasswordHash) 37 | .where("id", "=", resetToken.user_id) 38 | .execute(); 39 | 40 | // Delete the password reset token 41 | await trx 42 | .deleteFrom("password_reset_tokens") 43 | .where("id", "=", token) 44 | .execute(); 45 | 46 | // Invalidate all existing sessions for security 47 | await trx 48 | .deleteFrom("sessions") 49 | .where("user_id", "=", resetToken.user_id) 50 | .execute(); 51 | }); 52 | 53 | return NextResponse.json({ 54 | success: true, 55 | message: "비밀번호가 성공적으로 변경되었습니다. 다시 로그인해주세요.", 56 | }); 57 | } catch (error) { 58 | console.error("Password reset error:", error); 59 | return NextResponse.json( 60 | { success: false, message: "비밀번호 재설정 중 오류가 발생했습니다." }, 61 | { status: 500 } 62 | ); 63 | } 64 | } -------------------------------------------------------------------------------- /control-plane/src/app/(main)/api/account/login/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from "next/server"; 2 | import { cookies } from "next/headers"; 3 | import { lucia, validateRequest } from "@/lib/auth"; 4 | import { db } from "@/lib/database"; 5 | import { verify } from "@node-rs/argon2"; 6 | 7 | export async function POST(request: NextRequest) { 8 | try { 9 | const { user } = await validateRequest(); 10 | 11 | if (user) { 12 | return NextResponse.redirect(new URL("/", request.url)); 13 | } 14 | 15 | const { login_name, password } = await request.json(); 16 | 17 | if (!login_name || !password) { 18 | return NextResponse.json( 19 | { success: false, message: "아이디와 비밀번호를 입력해주세요." }, 20 | { status: 400 } 21 | ); 22 | } 23 | 24 | const existingUser = await db 25 | .selectFrom("users") 26 | .selectAll() 27 | .where("login_name", "=", login_name) 28 | .executeTakeFirst(); 29 | 30 | if (!existingUser) { 31 | return NextResponse.json( 32 | { success: false, message: "아이디 또는 비밀번호가 일치하지 않습니다." }, 33 | { status: 400 } 34 | ); 35 | } 36 | 37 | const passwordVerified = await verify(existingUser.password_hash, password); 38 | if (!passwordVerified) { 39 | return NextResponse.json( 40 | { success: false, message: "아이디 또는 비밀번호가 일치하지 않습니다." }, 41 | { status: 400 } 42 | ); 43 | } 44 | 45 | const session = await lucia.createSession(existingUser.id, {}); 46 | const sessionCookie = lucia.createSessionCookie(session.id); 47 | 48 | (await cookies()).set( 49 | sessionCookie.name, 50 | sessionCookie.value, 51 | sessionCookie.attributes 52 | ); 53 | 54 | return NextResponse.json({ 55 | success: true, 56 | message: "로그인되었습니다.", 57 | }); 58 | } catch (error) { 59 | console.error("Login error:", error); 60 | return NextResponse.json( 61 | { success: false, message: "로그인 중 오류가 발생했습니다." }, 62 | { status: 500 } 63 | ); 64 | } 65 | } -------------------------------------------------------------------------------- /control-plane/src/app/(main)/files/edit/[...paths]/page.tsx: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import { validateRequest } from "@/lib/auth"; 3 | import { 4 | HeadObjectCommand, 5 | NotFound, 6 | } from "@aws-sdk/client-s3"; 7 | import { getUserHomeDirectory, s3Client } from "@/lib/utils"; 8 | import { buildFileTree } from "@/lib/fileUtils"; 9 | import FileExplorerWithSelected from "@/components/browser/FileExplorerWithSelected"; 10 | 11 | export default async function EditPage( 12 | props: { 13 | params: Promise<{ paths: string[] }>; 14 | } 15 | ) { 16 | const params = await props.params; 17 | const { user } = await validateRequest(); 18 | 19 | if (!user) { 20 | return ( 21 |
22 |
23 |

로그인 필요

24 |

파일 편집을 위해 로그인이 필요합니다.

25 |
26 |
27 | ); 28 | } 29 | 30 | const decodedPaths = params.paths.map((p) => decodeURIComponent(p)); 31 | const filename = decodedPaths.join("/"); 32 | const actualFilename = path 33 | .join(getUserHomeDirectory(user.loginName), ...decodedPaths) 34 | .replaceAll("//", "/"); 35 | 36 | // Check if file exists 37 | const headCommand = new HeadObjectCommand({ 38 | Bucket: process.env.S3_BUCKET_NAME, 39 | Key: actualFilename, 40 | }); 41 | 42 | let fileExists = true; 43 | try { 44 | await s3Client.send(headCommand); 45 | } catch (e) { 46 | if (e instanceof NotFound) { 47 | fileExists = false; 48 | } else { 49 | throw e; 50 | } 51 | } 52 | 53 | const fileTree = await buildFileTree(user.loginName); 54 | 55 | return ( 56 |
57 | 62 |
63 | ); 64 | } 65 | -------------------------------------------------------------------------------- /control-plane/src/app/(main)/api/account/change-password/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from "next/server"; 2 | import { validateRequest } from "@/lib/auth"; 3 | import { db } from "@/lib/database"; 4 | import { hash, verify } from "@node-rs/argon2"; 5 | 6 | export async function POST(request: NextRequest) { 7 | try { 8 | const { user } = await validateRequest(); 9 | if (!user) { 10 | return NextResponse.json( 11 | { success: false, message: "로그인이 필요합니다." }, 12 | { status: 401 } 13 | ); 14 | } 15 | 16 | const { originalPassword, newPassword } = await request.json(); 17 | 18 | if (!originalPassword || !newPassword) { 19 | return NextResponse.json( 20 | { success: false, message: "기존 비밀번호와 새 비밀번호를 입력해주세요." }, 21 | { status: 400 } 22 | ); 23 | } 24 | 25 | const databaseUser = await db 26 | .selectFrom("users") 27 | .selectAll() 28 | .where("id", "=", user.id) 29 | .executeTakeFirst(); 30 | 31 | if (!databaseUser) { 32 | return NextResponse.json( 33 | { success: false, message: "사용자가 존재하지 않습니다." }, 34 | { status: 404 } 35 | ); 36 | } 37 | 38 | const passwordVerified = await verify( 39 | databaseUser.password_hash, 40 | originalPassword 41 | ); 42 | 43 | if (!passwordVerified) { 44 | return NextResponse.json( 45 | { success: false, message: "기존 비밀번호가 일치하지 않습니다." }, 46 | { status: 400 } 47 | ); 48 | } 49 | 50 | const newPasswordHash = await hash(newPassword); 51 | 52 | await db 53 | .updateTable("users") 54 | .set("password_hash", newPasswordHash) 55 | .where("id", "=", user.id) 56 | .execute(); 57 | 58 | return NextResponse.json({ 59 | success: true, 60 | message: "비밀번호가 변경되었습니다.", 61 | }); 62 | } catch (error) { 63 | console.error("Change password error:", error); 64 | return NextResponse.json( 65 | { success: false, message: "비밀번호 변경 중 오류가 발생했습니다." }, 66 | { status: 500 } 67 | ); 68 | } 69 | } -------------------------------------------------------------------------------- /control-plane/src/cli/update-screenshots.tsx: -------------------------------------------------------------------------------- 1 | import { db } from "@/lib/database"; 2 | import { getHomepageUrl, s3Client } from "@/lib/utils"; 3 | import { PutObjectCommand } from "@aws-sdk/client-s3"; 4 | import { chromium } from "playwright"; 5 | 6 | async function main() { 7 | const recentlyPublishedAndNotRecentlyRenderedUsers = await db 8 | .selectFrom("users") 9 | .selectAll() 10 | .where("discoverable", "=", true) 11 | .orderBy("site_updated_at", "desc") 12 | .where((eb) => 13 | eb.or([ 14 | eb("site_rendered_at", "is", null), 15 | eb("site_rendered_at", "<", eb.ref("site_updated_at")), 16 | ]) 17 | ) 18 | .execute(); 19 | 20 | const browser = await chromium.launch(); 21 | const context = await browser.newContext({ deviceScaleFactor: 2 }); 22 | 23 | for (const user of recentlyPublishedAndNotRecentlyRenderedUsers) { 24 | try { 25 | const homepageUrl = getHomepageUrl(user.login_name); 26 | 27 | const page = await context.newPage(); 28 | page.setViewportSize({ width: 640, height: 480 }); 29 | await page.goto(homepageUrl, { 30 | timeout: 10 * 1000, 31 | }); 32 | await page.waitForTimeout(10 * 1000); 33 | const screenshot = await page.screenshot(); 34 | 35 | if (screenshot.length === 0) { 36 | console.log(`Skipping ${user.login_name}: screenshot is 0 bytes`); 37 | continue; 38 | } 39 | 40 | await s3Client.send( 41 | new PutObjectCommand({ 42 | Bucket: process.env.S3_BUCKET_NAME_SCREENSHOTS!, 43 | Key: `${user.login_name}.png`, 44 | Body: screenshot, 45 | ContentType: "image/png", 46 | }) 47 | ); 48 | console.log(`Uploaded screenshot for ${user.login_name}`); 49 | 50 | await db 51 | .updateTable("users") 52 | .set({ site_rendered_at: new Date() }) 53 | .where("id", "=", user.id) 54 | .executeTakeFirst(); 55 | } catch (error) { 56 | console.error(`Failed to render ${user.login_name}: ${error}`); 57 | } 58 | } 59 | 60 | await context.close(); 61 | await browser.close(); 62 | } 63 | 64 | main(); 65 | -------------------------------------------------------------------------------- /control-plane/src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", 9 | { 10 | variants: { 11 | variant: { 12 | default: "bg-primary text-primary-foreground hover:bg-primary/90", 13 | destructive: 14 | "bg-destructive text-destructive-foreground hover:bg-destructive/90", 15 | outline: 16 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground", 17 | secondary: 18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80", 19 | ghost: "hover:bg-accent hover:text-accent-foreground", 20 | link: "text-primary underline-offset-4 hover:underline", 21 | }, 22 | size: { 23 | default: "h-10 px-4 py-2", 24 | sm: "h-9 rounded-md px-3", 25 | lg: "h-11 rounded-md px-8", 26 | icon: "h-10 w-10", 27 | }, 28 | }, 29 | defaultVariants: { 30 | variant: "default", 31 | size: "default", 32 | }, 33 | } 34 | ) 35 | 36 | export interface ButtonProps 37 | extends React.ButtonHTMLAttributes, 38 | VariantProps { 39 | asChild?: boolean 40 | } 41 | 42 | const Button = React.forwardRef( 43 | ({ className, variant, size, asChild = false, ...props }, ref) => { 44 | const Comp = asChild ? Slot : "button" 45 | return ( 46 | 51 | ) 52 | } 53 | ) 54 | Button.displayName = "Button" 55 | 56 | export { Button, buttonVariants } 57 | -------------------------------------------------------------------------------- /control-plane/src/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Card = React.forwardRef< 6 | HTMLDivElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
17 | )) 18 | Card.displayName = "Card" 19 | 20 | const CardHeader = React.forwardRef< 21 | HTMLDivElement, 22 | React.HTMLAttributes 23 | >(({ className, ...props }, ref) => ( 24 |
29 | )) 30 | CardHeader.displayName = "CardHeader" 31 | 32 | const CardTitle = React.forwardRef< 33 | HTMLDivElement, 34 | React.HTMLAttributes 35 | >(({ className, ...props }, ref) => ( 36 |
44 | )) 45 | CardTitle.displayName = "CardTitle" 46 | 47 | const CardDescription = React.forwardRef< 48 | HTMLDivElement, 49 | React.HTMLAttributes 50 | >(({ className, ...props }, ref) => ( 51 |
56 | )) 57 | CardDescription.displayName = "CardDescription" 58 | 59 | const CardContent = React.forwardRef< 60 | HTMLDivElement, 61 | React.HTMLAttributes 62 | >(({ className, ...props }, ref) => ( 63 |
64 | )) 65 | CardContent.displayName = "CardContent" 66 | 67 | const CardFooter = React.forwardRef< 68 | HTMLDivElement, 69 | React.HTMLAttributes 70 | >(({ className, ...props }, ref) => ( 71 |
76 | )) 77 | CardFooter.displayName = "CardFooter" 78 | 79 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } 80 | -------------------------------------------------------------------------------- /control-plane/src/app/(main)/api/account/request-account-deletion/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from "next/server"; 2 | import { validateRequest } from "@/lib/auth"; 3 | import { db } from "@/lib/database"; 4 | import { sendAccountDeletionEmail, generateAccountDeletionToken } from "@/lib/email"; 5 | 6 | export async function POST(request: NextRequest) { 7 | try { 8 | const { user } = await validateRequest(); 9 | if (!user) { 10 | return NextResponse.json( 11 | { success: false, message: "로그인이 필요합니다." }, 12 | { status: 401 } 13 | ); 14 | } 15 | 16 | // If user has no verified email, require immediate deletion with JavaScript confirmation 17 | if (!user.email || !user.emailVerifiedAt) { 18 | return NextResponse.json({ 19 | success: true, 20 | requiresImmediateConfirmation: true, 21 | message: "이메일 인증이 없어 즉시 삭제됩니다. 확인하시겠습니까?", 22 | }); 23 | } 24 | 25 | // Generate deletion token for users with verified email 26 | const token = generateAccountDeletionToken(); 27 | const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour 28 | 29 | await db.transaction().execute(async (trx) => { 30 | // Delete any existing account deletion tokens for this user 31 | await trx 32 | .deleteFrom("account_deletion_tokens") 33 | .where("user_id", "=", user.id) 34 | .execute(); 35 | 36 | // Insert new account deletion token 37 | await trx 38 | .insertInto("account_deletion_tokens") 39 | .values({ 40 | id: token, 41 | user_id: user.id, 42 | email: user.email!, 43 | expires_at: expiresAt, 44 | }) 45 | .execute(); 46 | }); 47 | 48 | // Send account deletion confirmation email 49 | await sendAccountDeletionEmail(user.email!, token); 50 | 51 | return NextResponse.json({ 52 | success: true, 53 | requiresImmediateConfirmation: false, 54 | message: "계정 삭제 확인 이메일이 발송되었습니다. 이메일을 확인해주세요.", 55 | }); 56 | } catch (error) { 57 | console.error("Account deletion request error:", error); 58 | return NextResponse.json( 59 | { success: false, message: "계정 삭제 요청 중 오류가 발생했습니다." }, 60 | { status: 500 } 61 | ); 62 | } 63 | } -------------------------------------------------------------------------------- /control-plane/src/app/(main)/api/account/request-password-reset/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from "next/server"; 2 | import { db } from "@/lib/database"; 3 | import { sendPasswordResetEmail, generatePasswordResetToken } from "@/lib/email"; 4 | 5 | export async function POST(request: NextRequest) { 6 | try { 7 | const { email } = await request.json(); 8 | 9 | if (!email) { 10 | return NextResponse.json( 11 | { success: false, message: "이메일을 입력해주세요." }, 12 | { status: 400 } 13 | ); 14 | } 15 | 16 | // Find user with verified email 17 | const user = await db 18 | .selectFrom("users") 19 | .select(["id", "login_name", "email", "email_verified_at"]) 20 | .where("email", "=", email) 21 | .where("email_verified_at", "is not", null) 22 | .executeTakeFirst(); 23 | 24 | if (!user) { 25 | // Don't reveal whether email exists for security 26 | return NextResponse.json({ 27 | success: true, 28 | message: "비밀번호 재설정 링크가 이메일로 발송되었습니다.", 29 | }); 30 | } 31 | 32 | // Generate reset token 33 | const token = generatePasswordResetToken(); 34 | const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour 35 | 36 | await db.transaction().execute(async (trx) => { 37 | // Delete any existing password reset tokens for this user 38 | await trx 39 | .deleteFrom("password_reset_tokens") 40 | .where("user_id", "=", user.id) 41 | .execute(); 42 | 43 | // Insert new password reset token 44 | await trx 45 | .insertInto("password_reset_tokens") 46 | .values({ 47 | id: token, 48 | user_id: user.id, 49 | email: user.email!, 50 | expires_at: expiresAt, 51 | }) 52 | .execute(); 53 | }); 54 | 55 | // Send password reset email 56 | await sendPasswordResetEmail(user.email!, token); 57 | 58 | return NextResponse.json({ 59 | success: true, 60 | message: "비밀번호 재설정 링크가 이메일로 발송되었습니다.", 61 | }); 62 | } catch (error) { 63 | console.error("Password reset request error:", error); 64 | return NextResponse.json( 65 | { success: false, message: "비밀번호 재설정 요청 중 오류가 발생했습니다." }, 66 | { status: 500 } 67 | ); 68 | } 69 | } -------------------------------------------------------------------------------- /control-plane/src/lib/const.ts: -------------------------------------------------------------------------------- 1 | import { html } from "@codemirror/lang-html"; 2 | import { javascript } from "@codemirror/lang-javascript"; 3 | import { json } from "@codemirror/lang-json"; 4 | import { less } from "@codemirror/lang-less"; 5 | import { markdown } from "@codemirror/lang-markdown"; 6 | 7 | export const LOGIN_NAME_REGEX = /^[a-z0-9]+(-[a-z0-9]+)*$/; 8 | 9 | export const EDITABLE_FILE_EXTENSION_MAP: Record = { 10 | html: html, 11 | htm: html, 12 | xml: html, 13 | xhtml: html, 14 | svg: html, 15 | md: markdown, 16 | markdown: markdown, 17 | mdx: markdown, 18 | css: less, 19 | txt: null, 20 | js: javascript, 21 | json: json, 22 | }; 23 | 24 | export const FILE_EXTENSION_MIMETYPE_MAP: Record< 25 | | keyof typeof EDITABLE_FILE_EXTENSION_MAP 26 | | (typeof AUDIO_FILE_EXTENSIONS)[number] 27 | | (typeof IMAGE_FILE_EXTENSIONS)[number], 28 | string 29 | > = { 30 | html: "text/html", 31 | htm: "text/html", 32 | xml: "application/xml", 33 | xhtml: "application/xhtml+xml", 34 | svg: "image/svg+xml", 35 | md: "text/markdown", 36 | markdown: "text/markdown", 37 | mdx: "text/markdown", 38 | css: "text/css", 39 | txt: "text/plain", 40 | js: "application/javascript", 41 | json: "application/json", 42 | png: "image/png", 43 | jpg: "image/jpeg", 44 | jpeg: "image/jpeg", 45 | gif: "image/gif", 46 | webp: "image/webp", 47 | ico: "image/x-icon", 48 | ogg: "audio/ogg", 49 | wav: "audio/wav", 50 | mp3: "audio/mpeg", 51 | opus: "audio/opus", 52 | mid: "audio/midi", 53 | midi: "audio/midi", 54 | }; 55 | 56 | export const EDITABLE_FILE_EXTENSIONS = Object.keys( 57 | EDITABLE_FILE_EXTENSION_MAP 58 | ); 59 | 60 | export const AUDIO_FILE_EXTENSIONS = [ 61 | "ogg", 62 | "wav", 63 | "mp3", 64 | "opus", 65 | "mid", 66 | "midi", 67 | ]; 68 | 69 | export const IMAGE_FILE_EXTENSIONS = [ 70 | "png", 71 | "jpg", 72 | "jpeg", 73 | "gif", 74 | "webp", 75 | "ico", 76 | ]; 77 | 78 | export const ALLOWED_FILE_EXTENSIONS = [ 79 | ...EDITABLE_FILE_EXTENSIONS, 80 | ...AUDIO_FILE_EXTENSIONS, 81 | ...IMAGE_FILE_EXTENSIONS, 82 | ]; 83 | 84 | export const DEFAULT_INDEX_HTML = ` 85 | 86 | 87 | 88 | 89 | 나루 90 | 91 | 92 |

안녕, 세상?

93 | 94 | 95 | `; 96 | -------------------------------------------------------------------------------- /control-plane/src/app/(main)/api/account/associate-email/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from "next/server"; 2 | import { validateRequest } from "@/lib/auth"; 3 | import { db } from "@/lib/database"; 4 | import { sendVerificationEmail, generateVerificationToken } from "@/lib/email"; 5 | 6 | export async function POST(request: NextRequest) { 7 | try { 8 | const { user } = await validateRequest(); 9 | if (!user) { 10 | return NextResponse.json( 11 | { success: false, message: "로그인이 필요합니다." }, 12 | { status: 401 } 13 | ); 14 | } 15 | 16 | const { email } = await request.json(); 17 | 18 | if (!email) { 19 | return NextResponse.json( 20 | { success: false, message: "이메일을 입력해주세요." }, 21 | { status: 400 } 22 | ); 23 | } 24 | 25 | // Check if email is already associated with another user 26 | const existingUser = await db 27 | .selectFrom("users") 28 | .select("id") 29 | .where("email", "=", email) 30 | .where("id", "!=", user.id) 31 | .executeTakeFirst(); 32 | 33 | if (existingUser) { 34 | return NextResponse.json( 35 | { success: false, message: "이미 다른 계정에서 사용 중인 이메일입니다." }, 36 | { status: 400 } 37 | ); 38 | } 39 | 40 | // Generate verification token 41 | const token = generateVerificationToken(); 42 | const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours 43 | 44 | await db.transaction().execute(async (trx) => { 45 | // Delete any existing verification tokens for this user 46 | await trx 47 | .deleteFrom("email_verification_tokens") 48 | .where("user_id", "=", user.id) 49 | .execute(); 50 | 51 | // Insert new verification token 52 | await trx 53 | .insertInto("email_verification_tokens") 54 | .values({ 55 | id: token, 56 | user_id: user.id, 57 | email, 58 | expires_at: expiresAt, 59 | }) 60 | .execute(); 61 | }); 62 | 63 | // Send verification email 64 | await sendVerificationEmail(email, token); 65 | 66 | return NextResponse.json({ 67 | success: true, 68 | message: "인증 이메일이 발송되었습니다. 이메일을 확인해주세요.", 69 | }); 70 | } catch (error) { 71 | console.error("Email association error:", error); 72 | return NextResponse.json( 73 | { success: false, message: "이메일 연결 중 오류가 발생했습니다." }, 74 | { status: 500 } 75 | ); 76 | } 77 | } -------------------------------------------------------------------------------- /control-plane/src/lib/auth.ts: -------------------------------------------------------------------------------- 1 | import { Lucia, Session, User } from "lucia"; 2 | import { adapter } from "./database"; 3 | import { cache } from "react"; 4 | import { cookies } from "next/headers"; 5 | 6 | export const lucia = new Lucia(adapter, { 7 | sessionCookie: { 8 | // this sets cookies with super long expiration 9 | // since Next.js doesn't allow Lucia to extend cookie expiration when rendering pages 10 | expires: false, 11 | attributes: { 12 | // set to `true` when using HTTPS 13 | secure: process.env.NODE_ENV === "production", 14 | }, 15 | }, 16 | getUserAttributes: (attributes) => { 17 | return { 18 | // we don't need to expose the hashed password! 19 | loginName: attributes.login_name, 20 | createdAt: attributes.created_at, 21 | email: attributes.email, 22 | emailVerifiedAt: attributes.email_verified_at, 23 | discoverable: attributes.discoverable, 24 | }; 25 | }, 26 | }); 27 | 28 | export const validateRequest = cache( 29 | async (): Promise< 30 | { user: User; session: Session } | { user: null; session: null } 31 | > => { 32 | const sessionId = (await cookies()).get(lucia.sessionCookieName)?.value ?? null; 33 | if (!sessionId) { 34 | return { 35 | user: null, 36 | session: null, 37 | }; 38 | } 39 | 40 | const result = await lucia.validateSession(sessionId); 41 | // next.js throws when you attempt to set cookie when rendering page 42 | try { 43 | if (result.session && result.session.fresh) { 44 | const sessionCookie = lucia.createSessionCookie(result.session.id); 45 | (await cookies()).set( 46 | sessionCookie.name, 47 | sessionCookie.value, 48 | sessionCookie.attributes 49 | ); 50 | } 51 | if (!result.session) { 52 | const sessionCookie = lucia.createBlankSessionCookie(); 53 | (await cookies()).set( 54 | sessionCookie.name, 55 | sessionCookie.value, 56 | sessionCookie.attributes 57 | ); 58 | } 59 | } catch {} 60 | return result; 61 | } 62 | ); 63 | 64 | // IMPORTANT! 65 | declare module "lucia" { 66 | interface Register { 67 | Lucia: typeof lucia; 68 | UserId: number; 69 | DatabaseUserAttributes: { 70 | login_name: string; 71 | email: string; 72 | email_verified_at: Date | null; 73 | created_at: Date; 74 | discoverable: boolean; 75 | }; 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /control-plane/src/app/(main)/api/account/resend-verification-email/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from "next/server"; 2 | import { validateRequest } from "@/lib/auth"; 3 | import { db } from "@/lib/database"; 4 | import { sendVerificationEmail, generateVerificationToken } from "@/lib/email"; 5 | 6 | export async function POST(request: NextRequest) { 7 | try { 8 | const { user } = await validateRequest(); 9 | if (!user) { 10 | return NextResponse.json( 11 | { success: false, message: "로그인이 필요합니다." }, 12 | { status: 401 } 13 | ); 14 | } 15 | 16 | if (user.email && user.emailVerifiedAt) { 17 | return NextResponse.json( 18 | { success: false, message: "이미 인증된 이메일입니다." }, 19 | { status: 400 } 20 | ); 21 | } 22 | 23 | // Check for pending verification token since email might not be set yet 24 | const existingToken = await db 25 | .selectFrom("email_verification_tokens") 26 | .selectAll() 27 | .where("user_id", "=", user.id) 28 | .executeTakeFirst(); 29 | 30 | if (!existingToken) { 31 | return NextResponse.json( 32 | { success: false, message: "연결된 이메일이 없습니다." }, 33 | { status: 400 } 34 | ); 35 | } 36 | 37 | // Generate new verification token 38 | const token = generateVerificationToken(); 39 | const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours 40 | 41 | await db.transaction().execute(async (trx) => { 42 | // Delete any existing verification tokens for this user 43 | await trx 44 | .deleteFrom("email_verification_tokens") 45 | .where("user_id", "=", user.id) 46 | .execute(); 47 | 48 | // Insert new verification token 49 | await trx 50 | .insertInto("email_verification_tokens") 51 | .values({ 52 | id: token, 53 | user_id: user.id, 54 | email: existingToken.email, 55 | expires_at: expiresAt, 56 | }) 57 | .execute(); 58 | }); 59 | 60 | // Send verification email 61 | await sendVerificationEmail(existingToken.email, token); 62 | 63 | return NextResponse.json({ 64 | success: true, 65 | message: "인증 이메일이 다시 발송되었습니다.", 66 | }); 67 | } catch (error) { 68 | console.error("Resend verification email error:", error); 69 | return NextResponse.json( 70 | { success: false, message: "이메일 발송 중 오류가 발생했습니다." }, 71 | { status: 500 } 72 | ); 73 | } 74 | } -------------------------------------------------------------------------------- /control-plane/src/app/(main)/account/DeleteAccountButton.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import { useState } from "react"; 5 | import { Trash2, Loader } from "lucide-react"; 6 | import { toast } from "sonner"; 7 | 8 | export default function DeleteAccountButton() { 9 | const [isLoading, setIsLoading] = useState(false); 10 | 11 | const handleDeleteAccount = async () => { 12 | setIsLoading(true); 13 | try { 14 | const response = await fetch("/api/account/request-account-deletion", { 15 | method: "POST", 16 | headers: { 17 | "Content-Type": "application/json", 18 | }, 19 | }); 20 | 21 | const result = await response.json(); 22 | 23 | if (result.success && result.requiresImmediateConfirmation) { 24 | // User has no verified email - show immediate deletion confirmation 25 | if (confirm("이메일 인증이 없어 계정이 즉시 삭제됩니다. 모든 파일과 데이터가 영구적으로 삭제됩니다. 정말로 계속하시겠습니까?")) { 26 | const deleteResponse = await fetch("/api/account/delete-account-immediately", { 27 | method: "POST", 28 | headers: { 29 | "Content-Type": "application/json", 30 | }, 31 | }); 32 | 33 | const deleteResult = await deleteResponse.json(); 34 | if (deleteResult.success) { 35 | toast.success(deleteResult.message); 36 | window.location.href = "/"; 37 | } else { 38 | toast.error(deleteResult.message); 39 | } 40 | } 41 | } else if (result.success) { 42 | // User has verified email - email confirmation sent 43 | toast.success(result.message); 44 | } else { 45 | toast.error(result.message); 46 | } 47 | } catch (error) { 48 | toast.error("계정 삭제 요청 중 오류가 발생했습니다."); 49 | } finally { 50 | setIsLoading(false); 51 | } 52 | }; 53 | 54 | return ( 55 | 77 | ); 78 | } 79 | -------------------------------------------------------------------------------- /control-plane/tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | 3 | const config = { 4 | darkMode: ["class"], 5 | content: [ 6 | "./pages/**/*.{ts,tsx}", 7 | "./components/**/*.{ts,tsx}", 8 | "./app/**/*.{ts,tsx}", 9 | "./src/**/*.{ts,tsx}", 10 | ], 11 | prefix: "", 12 | theme: { 13 | container: { 14 | center: true, 15 | padding: "2rem", 16 | screens: { 17 | "2xl": "1400px", 18 | }, 19 | }, 20 | extend: { 21 | colors: { 22 | border: "hsl(var(--border))", 23 | input: "hsl(var(--input))", 24 | ring: "hsl(var(--ring))", 25 | background: "hsl(var(--background))", 26 | foreground: "hsl(var(--foreground))", 27 | primary: { 28 | DEFAULT: "hsl(var(--primary))", 29 | foreground: "hsl(var(--primary-foreground))", 30 | }, 31 | secondary: { 32 | DEFAULT: "hsl(var(--secondary))", 33 | foreground: "hsl(var(--secondary-foreground))", 34 | }, 35 | destructive: { 36 | DEFAULT: "hsl(var(--destructive))", 37 | foreground: "hsl(var(--destructive-foreground))", 38 | }, 39 | muted: { 40 | DEFAULT: "hsl(var(--muted))", 41 | foreground: "hsl(var(--muted-foreground))", 42 | }, 43 | accent: { 44 | DEFAULT: "hsl(var(--accent))", 45 | foreground: "hsl(var(--accent-foreground))", 46 | }, 47 | popover: { 48 | DEFAULT: "hsl(var(--popover))", 49 | foreground: "hsl(var(--popover-foreground))", 50 | }, 51 | card: { 52 | DEFAULT: "hsl(var(--card))", 53 | foreground: "hsl(var(--card-foreground))", 54 | }, 55 | }, 56 | borderRadius: { 57 | lg: "var(--radius)", 58 | md: "calc(var(--radius) - 2px)", 59 | sm: "calc(var(--radius) - 4px)", 60 | }, 61 | keyframes: { 62 | "accordion-down": { 63 | from: { height: "0" }, 64 | to: { height: "var(--radix-accordion-content-height)" }, 65 | }, 66 | "accordion-up": { 67 | from: { height: "var(--radix-accordion-content-height)" }, 68 | to: { height: "0" }, 69 | }, 70 | }, 71 | animation: { 72 | "accordion-down": "accordion-down 0.2s ease-out", 73 | "accordion-up": "accordion-up 0.2s ease-out", 74 | }, 75 | }, 76 | }, 77 | plugins: [require("tailwindcss-animate")], 78 | } satisfies Config; 79 | 80 | export default config; 81 | -------------------------------------------------------------------------------- /control-plane/src/components/AdCard.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import Link from "next/link"; 3 | import { Button } from "@/components/ui/button"; 4 | import { 5 | Card, 6 | CardHeader, 7 | CardContent, 8 | CardFooter, 9 | CardTitle, 10 | } from "@/components/ui/card"; 11 | 12 | interface AdCardProps { 13 | icon: string; 14 | title: string; 15 | label: string; 16 | imageSrc: string; 17 | imageAlt: string; 18 | description: string; 19 | subtitle: string; 20 | buttonText: string; 21 | buttonHref: string; 22 | } 23 | 24 | export function AdCard({ 25 | icon, 26 | title, 27 | label, 28 | imageSrc, 29 | imageAlt, 30 | description, 31 | subtitle, 32 | buttonText, 33 | buttonHref, 34 | }: AdCardProps) { 35 | return ( 36 | 37 | 38 |
39 |
40 | 41 | {icon} {title} 42 | 43 |
44 | 45 | {label} 46 | 47 |
48 |
49 | 50 |
51 |
52 | {imageAlt} 59 |
60 |
61 |
62 |
63 |

64 | {description} 65 |

66 |

{subtitle}

67 |
68 |
69 |
70 | 71 | 79 | 80 |
81 | ); 82 | } 83 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: main 2 | on: 3 | push: 4 | branches: 5 | - main 6 | 7 | jobs: 8 | image-control-plane: 9 | defaults: 10 | run: 11 | working-directory: control-plane 12 | permissions: 13 | contents: read 14 | packages: write 15 | attestations: write 16 | runs-on: ubuntu-24.04-arm 17 | steps: 18 | - uses: actions/checkout@v4 19 | - uses: docker/setup-buildx-action@v3 20 | - uses: docker/login-action@v3 21 | with: 22 | registry: ghcr.io 23 | username: ${{ github.actor }} 24 | password: ${{ github.token }} 25 | - id: arch 26 | run: echo arch=arm64 >> "$GITHUB_OUTPUT" 27 | - uses: docker/build-push-action@v6 28 | with: 29 | context: control-plane 30 | pull: "true" 31 | push: "true" 32 | no-cache: "false" 33 | build-args: GIT_COMMIT=${{ github.sha }} 34 | tags: ghcr.io/${{ github.repository }}-control-plane:git-${{ github.sha }}-${{ steps.arch.outputs.arch }} 35 | labels: | 36 | org.opencontainers.image.revision=${{ github.sha }} 37 | cache-from: type=registry,ref=ghcr.io/${{ github.repository }}-control-plane:build-cache-${{ steps.arch.outputs.arch }} 38 | cache-to: type=registry,ref=ghcr.io/${{ github.repository }}-control-plane:build-cache-${{ steps.arch.outputs.arch }},mode=max 39 | provenance: false 40 | 41 | image-proxy: 42 | defaults: 43 | run: 44 | working-directory: proxy 45 | permissions: 46 | contents: read 47 | packages: write 48 | attestations: write 49 | runs-on: ubuntu-24.04-arm 50 | steps: 51 | - uses: actions/checkout@v4 52 | - uses: docker/setup-buildx-action@v3 53 | - uses: docker/login-action@v3 54 | with: 55 | registry: ghcr.io 56 | username: ${{ github.actor }} 57 | password: ${{ github.token }} 58 | - id: arch 59 | run: echo arch=arm64 >> "$GITHUB_OUTPUT" 60 | - uses: docker/build-push-action@v6 61 | with: 62 | context: proxy 63 | pull: "true" 64 | push: "true" 65 | no-cache: "false" 66 | build-args: GIT_COMMIT=${{ github.sha }} 67 | tags: ghcr.io/${{ github.repository }}-proxy:git-${{ github.sha }}-${{ steps.arch.outputs.arch }} 68 | labels: | 69 | org.opencontainers.image.revision=${{ github.sha }} 70 | org.opencontainers.image.licenses=AGPL-3.0-only 71 | cache-from: type=registry,ref=ghcr.io/${{ github.repository }}-proxy:build-cache-${{ steps.arch.outputs.arch }} 72 | cache-to: type=registry,ref=ghcr.io/${{ github.repository }}-proxy:build-cache-${{ steps.arch.outputs.arch }},mode=max 73 | provenance: false -------------------------------------------------------------------------------- /control-plane/src/app/(main)/account/page.tsx: -------------------------------------------------------------------------------- 1 | import { validateRequest } from "@/lib/auth"; 2 | import { redirect } from "next/navigation"; 3 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; 4 | import LogoutButton from "./LogoutButton"; 5 | import DeleteAccountButton from "./DeleteAccountButton"; 6 | import DownloadDirectoryButton from "./DownloadDirectoryButton"; 7 | import { DiscoverabilityForm } from "./DiscoverabilityForm"; 8 | import ChangePasswordForm from "./ChangePasswordForm"; 9 | import EmailManagement from "./EmailManagement"; 10 | import { Settings, User } from "lucide-react"; 11 | 12 | export default async function AccountPage() { 13 | const { user } = await validateRequest(); 14 | 15 | if (!user) { 16 | redirect("/"); 17 | } 18 | 19 | return ( 20 |
21 |
22 | 23 | 24 | 25 | 계정 관리 26 | 27 | 28 | 29 |
30 |

31 | 안녕하세요,{" "} 32 | {user.loginName}님! 33 |

34 |

35 | 나루와 {user.createdAt.getFullYear()}년{" "} 36 | {user.createdAt.getMonth() + 1} 37 | 월부터 함께해 주셨어요. 38 |

39 |
40 |
41 |
42 | 43 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 계정 작업 54 | 55 | 56 | 57 |
58 | 59 | 60 | 61 |
62 |
63 |
64 |
65 |
66 | ); 67 | } 68 | -------------------------------------------------------------------------------- /control-plane/src/app/(main)/api/account/delete-account-immediately/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from "next/server"; 2 | import { cookies } from "next/headers"; 3 | import { lucia, validateRequest } from "@/lib/auth"; 4 | import { db } from "@/lib/database"; 5 | import { 6 | ListObjectsV2Command, 7 | DeleteObjectsCommand, 8 | } from "@aws-sdk/client-s3"; 9 | import { getUserHomeDirectory, s3Client } from "@/lib/utils"; 10 | 11 | export async function POST(request: NextRequest) { 12 | try { 13 | const { user, session } = await validateRequest(); 14 | if (!user) { 15 | return NextResponse.json( 16 | { success: false, message: "로그인이 필요합니다." }, 17 | { status: 401 } 18 | ); 19 | } 20 | 21 | // Only allow immediate deletion for users without verified email 22 | if (user.email && user.emailVerifiedAt) { 23 | return NextResponse.json( 24 | { success: false, message: "이메일이 인증된 계정은 이메일 확인을 통해 삭제해야 합니다." }, 25 | { status: 400 } 26 | ); 27 | } 28 | 29 | // List all objects with user's prefix (with pagination to ensure we delete everything) 30 | const allObjects: any[] = []; 31 | let continuationToken: string | undefined; 32 | 33 | do { 34 | const listCommand = new ListObjectsV2Command({ 35 | Bucket: process.env.S3_BUCKET_NAME!, 36 | Prefix: `${getUserHomeDirectory(user.loginName)}/`, 37 | ContinuationToken: continuationToken, 38 | }); 39 | 40 | const objects = await s3Client.send(listCommand); 41 | 42 | if (objects.Contents) { 43 | allObjects.push(...objects.Contents); 44 | } 45 | 46 | continuationToken = objects.NextContinuationToken; 47 | } while (continuationToken); 48 | 49 | if (allObjects.length > 0) { 50 | // Delete all objects in batches (AWS limit is 1000 objects per delete request) 51 | const batchSize = 1000; 52 | for (let i = 0; i < allObjects.length; i += batchSize) { 53 | const batch = allObjects.slice(i, i + batchSize); 54 | await s3Client.send( 55 | new DeleteObjectsCommand({ 56 | Bucket: process.env.S3_BUCKET_NAME!, 57 | Delete: { 58 | Objects: batch.map((obj) => ({ Key: obj.Key! })), 59 | }, 60 | }) 61 | ); 62 | } 63 | } 64 | 65 | // Delete user account (this will cascade to all related tables) 66 | await db.deleteFrom("users").where("id", "=", user.id).execute(); 67 | 68 | // Invalidate session 69 | if (session) { 70 | await lucia.invalidateSession(session.id); 71 | } 72 | 73 | const sessionCookie = lucia.createBlankSessionCookie(); 74 | (await cookies()).set( 75 | sessionCookie.name, 76 | sessionCookie.value, 77 | sessionCookie.attributes 78 | ); 79 | 80 | return NextResponse.json({ 81 | success: true, 82 | message: "계정이 성공적으로 삭제되었습니다.", 83 | }); 84 | } catch (error) { 85 | console.error("Immediate account deletion error:", error); 86 | return NextResponse.json( 87 | { success: false, message: "계정 삭제 중 오류가 발생했습니다." }, 88 | { status: 500 } 89 | ); 90 | } 91 | } -------------------------------------------------------------------------------- /control-plane/src/app/(main)/api/account/signup/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from "next/server"; 2 | import { cookies } from "next/headers"; 3 | import { lucia } from "@/lib/auth"; 4 | import { db } from "@/lib/database"; 5 | import { hash } from "@node-rs/argon2"; 6 | import { DEFAULT_INDEX_HTML } from "@/lib/const"; 7 | import { 8 | PutObjectCommand, 9 | HeadObjectCommand, 10 | } from "@aws-sdk/client-s3"; 11 | import { getUserHomeDirectory, s3Client } from "@/lib/utils"; 12 | 13 | async function prepareUserHomeDirectory(userName: string) { 14 | const bucketName = process.env.S3_BUCKET_NAME!; 15 | 16 | // Check if index.html exists 17 | try { 18 | await s3Client.send( 19 | new HeadObjectCommand({ 20 | Bucket: bucketName, 21 | Key: `${getUserHomeDirectory(userName)}/index.html`.replaceAll( 22 | "//", 23 | "/" 24 | ), 25 | }) 26 | ); 27 | } catch (error: any) { 28 | // If file doesn't exist (404), create it 29 | if (error.$metadata?.httpStatusCode === 404) { 30 | await s3Client.send( 31 | new PutObjectCommand({ 32 | Bucket: bucketName, 33 | Key: `${getUserHomeDirectory(userName)}/index.html`.replaceAll( 34 | "//", 35 | "/" 36 | ), 37 | Body: DEFAULT_INDEX_HTML, 38 | ContentType: "text/html", 39 | }) 40 | ); 41 | } else { 42 | throw error; 43 | } 44 | } 45 | } 46 | 47 | export async function POST(request: NextRequest) { 48 | try { 49 | const { login_name, password } = await request.json(); 50 | 51 | if (!login_name || !password) { 52 | return NextResponse.json( 53 | { success: false, message: "아이디와 비밀번호를 입력해주세요." }, 54 | { status: 400 } 55 | ); 56 | } 57 | 58 | const password_hash = await hash(password); 59 | 60 | await prepareUserHomeDirectory(login_name); 61 | 62 | let user; 63 | try { 64 | user = await db 65 | .insertInto("users") 66 | .values({ 67 | login_name: login_name.toLowerCase(), 68 | password_hash, 69 | home_directory_size_bytes: 0, 70 | home_directory_size_bytes_updated_at: null, 71 | }) 72 | .returningAll() 73 | .execute(); 74 | } catch (e: any) { 75 | if (e.message.includes("users_login_name_key")) { 76 | return NextResponse.json( 77 | { success: false, message: "이미 사용 중인 아이디입니다." }, 78 | { status: 400 } 79 | ); 80 | } 81 | throw e; 82 | } 83 | 84 | const session = await lucia.createSession(user[0].id, {}); 85 | const sessionCookie = lucia.createSessionCookie(session.id); 86 | 87 | (await cookies()).set( 88 | sessionCookie.name, 89 | sessionCookie.value, 90 | sessionCookie.attributes 91 | ); 92 | 93 | return NextResponse.json({ 94 | success: true, 95 | message: "가입이 완료되었습니다.", 96 | }); 97 | } catch (error) { 98 | console.error("Signup error:", error); 99 | return NextResponse.json( 100 | { success: false, message: "가입 중 오류가 발생했습니다." }, 101 | { status: 500 } 102 | ); 103 | } 104 | } -------------------------------------------------------------------------------- /control-plane/jest.setup.js: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | 3 | // Mock lucia globally to avoid ES module issues 4 | jest.mock('lucia', () => ({ 5 | generateId: jest.fn((length) => 'mock-id-' + length), 6 | Lucia: jest.fn().mockImplementation(() => ({ 7 | validateSession: jest.fn() 8 | })) 9 | })); 10 | 11 | jest.mock('@lucia-auth/adapter-postgresql', () => ({ 12 | NodePostgresAdapter: jest.fn().mockImplementation(() => ({ 13 | getSession: jest.fn(), 14 | setSession: jest.fn(), 15 | deleteSession: jest.fn(), 16 | getUser: jest.fn(), 17 | setUser: jest.fn(), 18 | deleteUser: jest.fn() 19 | })) 20 | })); 21 | 22 | // Mock environment variables 23 | process.env.S3_BUCKET_NAME = 'test-bucket'; 24 | process.env.CLOUDFLARE_ZONE_ID = 'test-zone'; 25 | process.env.CLOUDFLARE_USER_API_TOKEN = 'test-token'; 26 | process.env.NEXT_PUBLIC_DOMAIN = 'test.com'; 27 | 28 | // Mock global fetch 29 | global.fetch = jest.fn(); 30 | 31 | // Mock TextEncoder and TextDecoder for Node.js compatibility 32 | const { TextEncoder, TextDecoder } = require('util'); 33 | global.TextEncoder = TextEncoder; 34 | global.TextDecoder = TextDecoder; 35 | 36 | // Mock Request for Next.js compatibility 37 | global.Request = class MockRequest { 38 | constructor(url, init = {}) { 39 | this.url = url; 40 | this.method = init.method || 'GET'; 41 | this.headers = init.headers || {}; 42 | this.body = init.body; 43 | } 44 | }; 45 | 46 | // Mock console methods to reduce test noise 47 | global.console = { 48 | ...console, 49 | log: jest.fn(), 50 | warn: jest.fn(), 51 | error: jest.fn(), 52 | }; 53 | 54 | // Mock File constructor for upload tests 55 | global.File = class MockFile { 56 | constructor(bits, name, options = {}) { 57 | this.bits = bits; 58 | this.name = name; 59 | this.type = options.type || 'application/octet-stream'; 60 | this.size = options.size || (Array.isArray(bits) ? bits.reduce((size, bit) => size + (bit.length || 0), 0) : bits.length || 0); 61 | this.lastModified = options.lastModified || Date.now(); 62 | } 63 | 64 | arrayBuffer() { 65 | return Promise.resolve(new ArrayBuffer(this.size)); 66 | } 67 | 68 | text() { 69 | return Promise.resolve(this.bits.join('')); 70 | } 71 | }; 72 | 73 | // Mock FormData 74 | global.FormData = class MockFormData { 75 | constructor() { 76 | this.data = new Map(); 77 | } 78 | 79 | append(key, value) { 80 | if (this.data.has(key)) { 81 | const existing = this.data.get(key); 82 | this.data.set(key, Array.isArray(existing) ? [...existing, value] : [existing, value]); 83 | } else { 84 | this.data.set(key, value); 85 | } 86 | } 87 | 88 | get(key) { 89 | const value = this.data.get(key); 90 | return Array.isArray(value) ? value[0] : value; 91 | } 92 | 93 | getAll(key) { 94 | const value = this.data.get(key); 95 | return Array.isArray(value) ? value : [value]; 96 | } 97 | 98 | has(key) { 99 | return this.data.has(key); 100 | } 101 | 102 | set(key, value) { 103 | this.data.set(key, value); 104 | } 105 | 106 | delete(key) { 107 | this.data.delete(key); 108 | } 109 | }; 110 | 111 | // Setup test timeout 112 | jest.setTimeout(10000); -------------------------------------------------------------------------------- /control-plane/src/components/ui/breadcrumb.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { ChevronRight, MoreHorizontal } from "lucide-react" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const Breadcrumb = React.forwardRef< 8 | HTMLElement, 9 | React.ComponentPropsWithoutRef<"nav"> & { 10 | separator?: React.ReactNode 11 | } 12 | >(({ ...props }, ref) =>