diff --git a/app/api/projects/route.ts b/app/api/projects/route.ts new file mode 100644 index 0000000..5e92dec --- /dev/null +++ b/app/api/projects/route.ts @@ -0,0 +1,314 @@ +import type { Database, Environment, Prisma, Project, Sandbox } from '@prisma/client' +import { NextResponse } from 'next/server' + +import { withAuth } from '@/lib/api-auth' +import { EnvironmentCategory } from '@/lib/const' +import { prisma } from '@/lib/db' +import { getK8sServiceForUser } from '@/lib/k8s/k8s-service-helper' +import { KubernetesUtils } from '@/lib/k8s/kubernetes-utils' +import { VERSIONS } from '@/lib/k8s/versions' +import { logger as baseLogger } from '@/lib/logger' +import { generateRandomString } from '@/lib/util/common' + +const logger = baseLogger.child({ module: 'api/projects' }) + +/** + * Validate project name format + * Rules: + * - Only letters, numbers, spaces, and hyphens allowed + * - Must start with a letter + * - Must end with a letter + */ +function validateProjectName(name: string): { valid: boolean; error?: string } { + // Check if name is empty or only whitespace + if (!name || name.trim().length === 0) { + return { valid: false, error: 'Project name cannot be empty' } + } + + // Check if name contains only allowed characters (letters, numbers, spaces, hyphens) + const allowedPattern = /^[a-zA-Z0-9\s-]+$/ + if (!allowedPattern.test(name)) { + return { + valid: false, + error: 'Project name can only contain letters, numbers, spaces, and hyphens', + } + } + + // Check if name starts with a letter + const trimmedName = name.trim() + if (!/^[a-zA-Z]/.test(trimmedName)) { + return { valid: false, error: 'Project name must start with a letter' } + } + + // Check if name ends with a letter + if (!/[a-zA-Z]$/.test(trimmedName)) { + return { valid: false, error: 'Project name must end with a letter' } + } + + return { valid: true } +} + +type ProjectWithRelations = Project & { + databases: Database[] + sandboxes: Sandbox[] + environments: Environment[] +} + +type GetProjectsResponse = ProjectWithRelations[] + +export const GET = withAuth(async (req, _context, session) => { + // Get query parameters for filtering + const { searchParams } = new URL(req.url) + const allParam = searchParams.get('all') + const keywordParam = searchParams.get('keyword') + const createdFromParam = searchParams.get('createdFrom') + const createdToParam = searchParams.get('createdTo') + + // Build where clause + const whereClause: Prisma.ProjectWhereInput = { + userId: session.user.id, + } + + // Add keyword filter if provided (searches in both name and description) + if (keywordParam) { + whereClause.OR = [ + { + name: { + contains: keywordParam, + mode: 'insensitive', + }, + }, + { + description: { + contains: keywordParam, + mode: 'insensitive', + }, + }, + ] + } + + // Add createdAt date filters if provided + const createdAtFilter: { gte?: Date; lte?: Date } = {} + if (createdFromParam) { + const createdFrom = new Date(createdFromParam) + if (!isNaN(createdFrom.getTime())) { + createdAtFilter.gte = createdFrom + } + } + if (createdToParam) { + const createdTo = new Date(createdToParam) + if (!isNaN(createdTo.getTime())) { + createdAtFilter.lte = createdTo + } + } + if (Object.keys(createdAtFilter).length > 0) { + whereClause.createdAt = createdAtFilter + } + + // Add namespace filter from user's kubeconfig (unless 'all' parameter is provided) + if (allParam !== 'true') { + try { + const k8sService = await getK8sServiceForUser(session.user.id) + const namespace = k8sService.getDefaultNamespace() + whereClause.sandboxes = { + some: { + k8sNamespace: namespace, + }, + } + } catch { + // If user doesn't have kubeconfig configured, log warning but don't fail + // Skip namespace filtering and return all projects for the user + logger.warn( + `User ${session.user.id} does not have KUBECONFIG configured, returning all projects` + ) + } + } + + const projects = await prisma.project.findMany({ + where: whereClause, + include: { + databases: true, + sandboxes: true, + environments: true, + }, + orderBy: { + updatedAt: 'desc', + }, + }) + + logger.info( + `Fetched ${projects.length} projects for user ${session.user.id}${allParam === 'true' ? ' (all namespaces)' : ''}` + ) + + return NextResponse.json(projects) +}) + +type PostProjectResponse = { error: string; errorCode?: string; message?: string } | Project + +export const POST = withAuth(async (req, _context, session) => { + const body = await req.json() + const { name, description, githubRepo, githubBranch } = body + + if (!name || typeof name !== 'string') { + return NextResponse.json({ error: 'Project name is required' }, { status: 400 }) + } + + // Validate project name format + const nameValidation = validateProjectName(name) + if (!nameValidation.valid) { + return NextResponse.json( + { + error: nameValidation.error || 'Invalid project name format', + errorCode: 'INVALID_PROJECT_NAME', + }, + { status: 400 } + ) + } + + logger.info(`Creating project: ${name} for user: ${session.user.id}`) + + // Get K8s service for user - will throw if KUBECONFIG is missing + let k8sService + let namespace + try { + k8sService = await getK8sServiceForUser(session.user.id) + namespace = k8sService.getDefaultNamespace() + } catch (error) { + // Check if error is due to missing kubeconfig + if (error instanceof Error && error.message.includes('does not have KUBECONFIG configured')) { + logger.warn(`Project creation failed - missing kubeconfig for user: ${session.user.id}`) + return NextResponse.json( + { + error: 'Kubeconfig not configured', + errorCode: 'KUBECONFIG_MISSING', + message: 'Please configure your kubeconfig before creating a project', + }, + { status: 400 } + ) + } + // Re-throw other errors + throw error + } + + // Generate K8s compatible names + const k8sProjectName = KubernetesUtils.toK8sProjectName(name) + const randomSuffix = KubernetesUtils.generateRandomString() + const ttydAuthToken = generateRandomString(24) // 24 chars = ~143 bits entropy for terminal auth + const editorPassword = generateRandomString(20) // code-server password + const fileBrowserUsername = `fb-${randomSuffix}` // filebrowser username + const fileBrowserPassword = generateRandomString(16) // 16 char random password + const databaseName = `${k8sProjectName}-${randomSuffix}` + const sandboxName = `${k8sProjectName}-${randomSuffix}` + + // Create project with database and sandbox in a transaction + const result = await prisma.$transaction(async (tx) => { + // 1. Create Project with status CREATING + const project = await tx.project.create({ + data: { + name, + description, + userId: session.user.id, + status: 'CREATING', + githubRepo: githubRepo || undefined, + githubBranch: githubBranch || undefined, + }, + }) + + // 2. Create Database record - lockedUntil is null so reconcile job can process immediately + const database = await tx.database.create({ + data: { + projectId: project.id, + name: databaseName, + k8sNamespace: namespace, + databaseName: databaseName, + status: 'CREATING', + lockedUntil: null, // Unlocked - ready for reconcile job to process + // Resource configuration from versions + storageSize: VERSIONS.STORAGE.DATABASE_SIZE, + cpuRequest: VERSIONS.RESOURCES.DATABASE.requests.cpu, + cpuLimit: VERSIONS.RESOURCES.DATABASE.limits.cpu, + memoryRequest: VERSIONS.RESOURCES.DATABASE.requests.memory, + memoryLimit: VERSIONS.RESOURCES.DATABASE.limits.memory, + }, + }) + + // 3. Create Sandbox record - lockedUntil is null so reconcile job can process immediately + const sandbox = await tx.sandbox.create({ + data: { + projectId: project.id, + name: sandboxName, + k8sNamespace: namespace, + sandboxName: sandboxName, + status: 'CREATING', + lockedUntil: null, // Unlocked - ready for reconcile job to process + // Resource configuration from versions + runtimeImage: VERSIONS.RUNTIME_IMAGE, + cpuRequest: VERSIONS.RESOURCES.SANDBOX.requests.cpu, + cpuLimit: VERSIONS.RESOURCES.SANDBOX.limits.cpu, + memoryRequest: VERSIONS.RESOURCES.SANDBOX.requests.memory, + memoryLimit: VERSIONS.RESOURCES.SANDBOX.limits.memory, + }, + }) + + // 4. Create Environment record for ttyd access token + const ttydEnv = await tx.environment.create({ + data: { + projectId: project.id, + key: 'TTYD_ACCESS_TOKEN', + value: ttydAuthToken, + category: EnvironmentCategory.TTYD, + isSecret: true, // Mark as secret since it's an access token + }, + }) + + // 4b. Create Environment record for editor password + const editorPasswordEnv = await tx.environment.create({ + data: { + projectId: project.id, + key: 'EDITOR_PASSWORD', + value: editorPassword, + category: EnvironmentCategory.AUTH, + isSecret: true, + }, + }) + + // 5. Create Environment records for filebrowser credentials + const fileBrowserUsernameEnv = await tx.environment.create({ + data: { + projectId: project.id, + key: 'FILE_BROWSER_USERNAME', + value: fileBrowserUsername, + category: EnvironmentCategory.FILE_BROWSER, + isSecret: false, + }, + }) + + const fileBrowserPasswordEnv = await tx.environment.create({ + data: { + projectId: project.id, + key: 'FILE_BROWSER_PASSWORD', + value: fileBrowserPassword, + category: EnvironmentCategory.FILE_BROWSER, + isSecret: true, // Mark as secret since it's a password + }, + }) + + return { + project, + database, + sandbox, + ttydEnv, + editorPasswordEnv, + fileBrowserUsernameEnv, + fileBrowserPasswordEnv, + } + }, { + timeout: 20000, + }) + + logger.info( + `Project created: ${result.project.id} with database: ${result.database.id}, sandbox: ${result.sandbox.id}, ttyd env: ${result.ttydEnv.id}, editor password env: ${result.editorPasswordEnv.id}, filebrowser username env: ${result.fileBrowserUsernameEnv.id}, filebrowser password env: ${result.fileBrowserPasswordEnv.id}` + ) + + return NextResponse.json(result.project) +}) diff --git a/app/api/sandbox/[id]/ports/route.ts b/app/api/sandbox/[id]/ports/route.ts new file mode 100644 index 0000000..9a997f8 --- /dev/null +++ b/app/api/sandbox/[id]/ports/route.ts @@ -0,0 +1,162 @@ +/** + * GET/POST/DELETE /api/sandbox/[id]/ports + * + * Manage custom exposed ports for a sandbox. + * + * GET — Returns current exposed ports + * POST — Expose a new port (creates K8s Ingress + Service port) + * DELETE — Unexpose a port (removes K8s Ingress + Service port) + */ + +import type { Prisma } from '@prisma/client' +import { NextResponse } from 'next/server' + +import { verifySandboxAccess, withAuth } from '@/lib/api-auth' +import { prisma } from '@/lib/db' +import { getK8sServiceForUser } from '@/lib/k8s/k8s-service-helper' +import { KubernetesUtils } from '@/lib/k8s/kubernetes-utils' +import { logger as baseLogger } from '@/lib/logger' + +const logger = baseLogger.child({ module: 'api/sandbox/[id]/ports' }) + +interface ExposedPort { + port: number + url: string +} + +// Built-in ports that cannot be exposed/unexposed by users +const BUILT_IN_PORTS = [3000, 3773, 7681, 8080] + +function getExposedPorts(json: Prisma.JsonValue): ExposedPort[] { + if (Array.isArray(json)) return json as unknown as ExposedPort[] + return [] +} + +export const GET = withAuth(async (_req, context, session) => { + const resolvedParams = await context.params + const sandboxId = Array.isArray(resolvedParams.id) ? resolvedParams.id[0] : resolvedParams.id + + const sandbox = await verifySandboxAccess(sandboxId, session.user.id) + const exposedPorts = getExposedPorts(sandbox.exposedPorts) + + return NextResponse.json({ ports: exposedPorts }) +}) + +export const POST = withAuth<{ port?: number; url?: string; error?: string }>(async (req, context, session) => { + const resolvedParams = await context.params + const sandboxId = Array.isArray(resolvedParams.id) ? resolvedParams.id[0] : resolvedParams.id + + try { + const body = await req.json() + const port = Number(body.port) + + if (!port || port < 1 || port > 65535 || !Number.isInteger(port)) { + return NextResponse.json({ error: 'Invalid port number (1-65535)' }, { status: 400 }) + } + + if (BUILT_IN_PORTS.includes(port)) { + return NextResponse.json( + { error: `Port ${port} is a built-in port and cannot be exposed manually` }, + { status: 400 } + ) + } + + const sandbox = await verifySandboxAccess(sandboxId, session.user.id) + const existingPorts = getExposedPorts(sandbox.exposedPorts) + + // Check if already exposed + if (existingPorts.some((p) => p.port === port)) { + const existing = existingPorts.find((p) => p.port === port)! + return NextResponse.json({ port: existing.port, url: existing.url }) + } + + // Get project name for k8s labels + const project = await prisma.project.findUnique({ + where: { id: sandbox.projectId }, + select: { name: true }, + }) + + if (!project) { + return NextResponse.json({ error: 'Project not found' }, { status: 404 }) + } + + const k8sProjectName = KubernetesUtils.toK8sProjectName(project.name) + const k8sService = await getK8sServiceForUser(session.user.id) + + // Expose port in K8s + const url = await k8sService.exposePort( + sandbox.k8sNamespace, + sandbox.sandboxName, + k8sProjectName, + port + ) + + // Store in database + const updatedPorts: Prisma.JsonArray = [...existingPorts, { port, url }] as unknown as Prisma.JsonArray + await prisma.sandbox.update({ + where: { id: sandboxId }, + data: { exposedPorts: updatedPorts }, + }) + + logger.info(`Port ${port} exposed for sandbox ${sandboxId}: ${url}`) + return NextResponse.json({ port, url }) + } catch (error) { + logger.error(`Failed to expose port for sandbox ${sandboxId}: ${error}`) + const errorMessage = error instanceof Error ? error.message : 'Unknown error' + return NextResponse.json( + { error: `Failed to expose port: ${errorMessage}` }, + { status: 500 } + ) + } +}) + +export const DELETE = withAuth<{ success?: boolean; error?: string }>(async (req, context, session) => { + const resolvedParams = await context.params + const sandboxId = Array.isArray(resolvedParams.id) ? resolvedParams.id[0] : resolvedParams.id + + try { + const body = await req.json() + const port = Number(body.port) + + if (!port || port < 1 || port > 65535 || !Number.isInteger(port)) { + return NextResponse.json({ error: 'Invalid port number (1-65535)' }, { status: 400 }) + } + + if (BUILT_IN_PORTS.includes(port)) { + return NextResponse.json( + { error: `Port ${port} is a built-in port and cannot be removed` }, + { status: 400 } + ) + } + + const sandbox = await verifySandboxAccess(sandboxId, session.user.id) + const existingPorts = getExposedPorts(sandbox.exposedPorts) + + // Check if port is actually exposed + if (!existingPorts.some((p) => p.port === port)) { + return NextResponse.json({ error: `Port ${port} is not exposed` }, { status: 404 }) + } + + const k8sService = await getK8sServiceForUser(session.user.id) + + // Unexpose port in K8s + await k8sService.unexposePort(sandbox.k8sNamespace, sandbox.sandboxName, port) + + // Remove from database + const updatedPorts: Prisma.JsonArray = existingPorts.filter((p) => p.port !== port) as unknown as Prisma.JsonArray + await prisma.sandbox.update({ + where: { id: sandboxId }, + data: { exposedPorts: updatedPorts }, + }) + + logger.info(`Port ${port} unexposed for sandbox ${sandboxId}`) + return NextResponse.json({ success: true }) + } catch (error) { + logger.error(`Failed to unexpose port for sandbox ${sandboxId}: ${error}`) + const errorMessage = error instanceof Error ? error.message : 'Unknown error' + return NextResponse.json( + { error: `Failed to unexpose port: ${errorMessage}` }, + { status: 500 } + ) + } +}) diff --git a/components/editor/editor-panel-host.tsx b/components/editor/editor-panel-host.tsx new file mode 100644 index 0000000..e13c560 --- /dev/null +++ b/components/editor/editor-panel-host.tsx @@ -0,0 +1,27 @@ +'use client'; + +import type { Sandbox } from '@prisma/client'; + +interface EditorPanelHostProps { + sandbox: Sandbox | undefined; +} + +export function EditorPanelHost({ sandbox }: EditorPanelHostProps) { + if (!sandbox?.editorUrl) { + return ( +
+ Editor is not available yet. +
+ ); + } + + return ( +
+