Skip to content

Commit d8fe4fa

Browse files
author
sertdev
committed
feat(editor): route sandbox editor to code-server
1 parent ad9ab25 commit d8fe4fa

19 files changed

Lines changed: 1663 additions & 264 deletions

app/api/projects/route.ts

Lines changed: 314 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,314 @@
1+
import type { Database, Environment, Prisma, Project, Sandbox } from '@prisma/client'
2+
import { NextResponse } from 'next/server'
3+
4+
import { withAuth } from '@/lib/api-auth'
5+
import { EnvironmentCategory } from '@/lib/const'
6+
import { prisma } from '@/lib/db'
7+
import { getK8sServiceForUser } from '@/lib/k8s/k8s-service-helper'
8+
import { KubernetesUtils } from '@/lib/k8s/kubernetes-utils'
9+
import { VERSIONS } from '@/lib/k8s/versions'
10+
import { logger as baseLogger } from '@/lib/logger'
11+
import { generateRandomString } from '@/lib/util/common'
12+
13+
const logger = baseLogger.child({ module: 'api/projects' })
14+
15+
/**
16+
* Validate project name format
17+
* Rules:
18+
* - Only letters, numbers, spaces, and hyphens allowed
19+
* - Must start with a letter
20+
* - Must end with a letter
21+
*/
22+
function validateProjectName(name: string): { valid: boolean; error?: string } {
23+
// Check if name is empty or only whitespace
24+
if (!name || name.trim().length === 0) {
25+
return { valid: false, error: 'Project name cannot be empty' }
26+
}
27+
28+
// Check if name contains only allowed characters (letters, numbers, spaces, hyphens)
29+
const allowedPattern = /^[a-zA-Z0-9\s-]+$/
30+
if (!allowedPattern.test(name)) {
31+
return {
32+
valid: false,
33+
error: 'Project name can only contain letters, numbers, spaces, and hyphens',
34+
}
35+
}
36+
37+
// Check if name starts with a letter
38+
const trimmedName = name.trim()
39+
if (!/^[a-zA-Z]/.test(trimmedName)) {
40+
return { valid: false, error: 'Project name must start with a letter' }
41+
}
42+
43+
// Check if name ends with a letter
44+
if (!/[a-zA-Z]$/.test(trimmedName)) {
45+
return { valid: false, error: 'Project name must end with a letter' }
46+
}
47+
48+
return { valid: true }
49+
}
50+
51+
type ProjectWithRelations = Project & {
52+
databases: Database[]
53+
sandboxes: Sandbox[]
54+
environments: Environment[]
55+
}
56+
57+
type GetProjectsResponse = ProjectWithRelations[]
58+
59+
export const GET = withAuth<GetProjectsResponse>(async (req, _context, session) => {
60+
// Get query parameters for filtering
61+
const { searchParams } = new URL(req.url)
62+
const allParam = searchParams.get('all')
63+
const keywordParam = searchParams.get('keyword')
64+
const createdFromParam = searchParams.get('createdFrom')
65+
const createdToParam = searchParams.get('createdTo')
66+
67+
// Build where clause
68+
const whereClause: Prisma.ProjectWhereInput = {
69+
userId: session.user.id,
70+
}
71+
72+
// Add keyword filter if provided (searches in both name and description)
73+
if (keywordParam) {
74+
whereClause.OR = [
75+
{
76+
name: {
77+
contains: keywordParam,
78+
mode: 'insensitive',
79+
},
80+
},
81+
{
82+
description: {
83+
contains: keywordParam,
84+
mode: 'insensitive',
85+
},
86+
},
87+
]
88+
}
89+
90+
// Add createdAt date filters if provided
91+
const createdAtFilter: { gte?: Date; lte?: Date } = {}
92+
if (createdFromParam) {
93+
const createdFrom = new Date(createdFromParam)
94+
if (!isNaN(createdFrom.getTime())) {
95+
createdAtFilter.gte = createdFrom
96+
}
97+
}
98+
if (createdToParam) {
99+
const createdTo = new Date(createdToParam)
100+
if (!isNaN(createdTo.getTime())) {
101+
createdAtFilter.lte = createdTo
102+
}
103+
}
104+
if (Object.keys(createdAtFilter).length > 0) {
105+
whereClause.createdAt = createdAtFilter
106+
}
107+
108+
// Add namespace filter from user's kubeconfig (unless 'all' parameter is provided)
109+
if (allParam !== 'true') {
110+
try {
111+
const k8sService = await getK8sServiceForUser(session.user.id)
112+
const namespace = k8sService.getDefaultNamespace()
113+
whereClause.sandboxes = {
114+
some: {
115+
k8sNamespace: namespace,
116+
},
117+
}
118+
} catch {
119+
// If user doesn't have kubeconfig configured, log warning but don't fail
120+
// Skip namespace filtering and return all projects for the user
121+
logger.warn(
122+
`User ${session.user.id} does not have KUBECONFIG configured, returning all projects`
123+
)
124+
}
125+
}
126+
127+
const projects = await prisma.project.findMany({
128+
where: whereClause,
129+
include: {
130+
databases: true,
131+
sandboxes: true,
132+
environments: true,
133+
},
134+
orderBy: {
135+
updatedAt: 'desc',
136+
},
137+
})
138+
139+
logger.info(
140+
`Fetched ${projects.length} projects for user ${session.user.id}${allParam === 'true' ? ' (all namespaces)' : ''}`
141+
)
142+
143+
return NextResponse.json(projects)
144+
})
145+
146+
type PostProjectResponse = { error: string; errorCode?: string; message?: string } | Project
147+
148+
export const POST = withAuth<PostProjectResponse>(async (req, _context, session) => {
149+
const body = await req.json()
150+
const { name, description, githubRepo, githubBranch } = body
151+
152+
if (!name || typeof name !== 'string') {
153+
return NextResponse.json({ error: 'Project name is required' }, { status: 400 })
154+
}
155+
156+
// Validate project name format
157+
const nameValidation = validateProjectName(name)
158+
if (!nameValidation.valid) {
159+
return NextResponse.json(
160+
{
161+
error: nameValidation.error || 'Invalid project name format',
162+
errorCode: 'INVALID_PROJECT_NAME',
163+
},
164+
{ status: 400 }
165+
)
166+
}
167+
168+
logger.info(`Creating project: ${name} for user: ${session.user.id}`)
169+
170+
// Get K8s service for user - will throw if KUBECONFIG is missing
171+
let k8sService
172+
let namespace
173+
try {
174+
k8sService = await getK8sServiceForUser(session.user.id)
175+
namespace = k8sService.getDefaultNamespace()
176+
} catch (error) {
177+
// Check if error is due to missing kubeconfig
178+
if (error instanceof Error && error.message.includes('does not have KUBECONFIG configured')) {
179+
logger.warn(`Project creation failed - missing kubeconfig for user: ${session.user.id}`)
180+
return NextResponse.json(
181+
{
182+
error: 'Kubeconfig not configured',
183+
errorCode: 'KUBECONFIG_MISSING',
184+
message: 'Please configure your kubeconfig before creating a project',
185+
},
186+
{ status: 400 }
187+
)
188+
}
189+
// Re-throw other errors
190+
throw error
191+
}
192+
193+
// Generate K8s compatible names
194+
const k8sProjectName = KubernetesUtils.toK8sProjectName(name)
195+
const randomSuffix = KubernetesUtils.generateRandomString()
196+
const ttydAuthToken = generateRandomString(24) // 24 chars = ~143 bits entropy for terminal auth
197+
const editorPassword = generateRandomString(20) // code-server password
198+
const fileBrowserUsername = `fb-${randomSuffix}` // filebrowser username
199+
const fileBrowserPassword = generateRandomString(16) // 16 char random password
200+
const databaseName = `${k8sProjectName}-${randomSuffix}`
201+
const sandboxName = `${k8sProjectName}-${randomSuffix}`
202+
203+
// Create project with database and sandbox in a transaction
204+
const result = await prisma.$transaction(async (tx) => {
205+
// 1. Create Project with status CREATING
206+
const project = await tx.project.create({
207+
data: {
208+
name,
209+
description,
210+
userId: session.user.id,
211+
status: 'CREATING',
212+
githubRepo: githubRepo || undefined,
213+
githubBranch: githubBranch || undefined,
214+
},
215+
})
216+
217+
// 2. Create Database record - lockedUntil is null so reconcile job can process immediately
218+
const database = await tx.database.create({
219+
data: {
220+
projectId: project.id,
221+
name: databaseName,
222+
k8sNamespace: namespace,
223+
databaseName: databaseName,
224+
status: 'CREATING',
225+
lockedUntil: null, // Unlocked - ready for reconcile job to process
226+
// Resource configuration from versions
227+
storageSize: VERSIONS.STORAGE.DATABASE_SIZE,
228+
cpuRequest: VERSIONS.RESOURCES.DATABASE.requests.cpu,
229+
cpuLimit: VERSIONS.RESOURCES.DATABASE.limits.cpu,
230+
memoryRequest: VERSIONS.RESOURCES.DATABASE.requests.memory,
231+
memoryLimit: VERSIONS.RESOURCES.DATABASE.limits.memory,
232+
},
233+
})
234+
235+
// 3. Create Sandbox record - lockedUntil is null so reconcile job can process immediately
236+
const sandbox = await tx.sandbox.create({
237+
data: {
238+
projectId: project.id,
239+
name: sandboxName,
240+
k8sNamespace: namespace,
241+
sandboxName: sandboxName,
242+
status: 'CREATING',
243+
lockedUntil: null, // Unlocked - ready for reconcile job to process
244+
// Resource configuration from versions
245+
runtimeImage: VERSIONS.RUNTIME_IMAGE,
246+
cpuRequest: VERSIONS.RESOURCES.SANDBOX.requests.cpu,
247+
cpuLimit: VERSIONS.RESOURCES.SANDBOX.limits.cpu,
248+
memoryRequest: VERSIONS.RESOURCES.SANDBOX.requests.memory,
249+
memoryLimit: VERSIONS.RESOURCES.SANDBOX.limits.memory,
250+
},
251+
})
252+
253+
// 4. Create Environment record for ttyd access token
254+
const ttydEnv = await tx.environment.create({
255+
data: {
256+
projectId: project.id,
257+
key: 'TTYD_ACCESS_TOKEN',
258+
value: ttydAuthToken,
259+
category: EnvironmentCategory.TTYD,
260+
isSecret: true, // Mark as secret since it's an access token
261+
},
262+
})
263+
264+
// 4b. Create Environment record for editor password
265+
const editorPasswordEnv = await tx.environment.create({
266+
data: {
267+
projectId: project.id,
268+
key: 'EDITOR_PASSWORD',
269+
value: editorPassword,
270+
category: EnvironmentCategory.AUTH,
271+
isSecret: true,
272+
},
273+
})
274+
275+
// 5. Create Environment records for filebrowser credentials
276+
const fileBrowserUsernameEnv = await tx.environment.create({
277+
data: {
278+
projectId: project.id,
279+
key: 'FILE_BROWSER_USERNAME',
280+
value: fileBrowserUsername,
281+
category: EnvironmentCategory.FILE_BROWSER,
282+
isSecret: false,
283+
},
284+
})
285+
286+
const fileBrowserPasswordEnv = await tx.environment.create({
287+
data: {
288+
projectId: project.id,
289+
key: 'FILE_BROWSER_PASSWORD',
290+
value: fileBrowserPassword,
291+
category: EnvironmentCategory.FILE_BROWSER,
292+
isSecret: true, // Mark as secret since it's a password
293+
},
294+
})
295+
296+
return {
297+
project,
298+
database,
299+
sandbox,
300+
ttydEnv,
301+
editorPasswordEnv,
302+
fileBrowserUsernameEnv,
303+
fileBrowserPasswordEnv,
304+
}
305+
}, {
306+
timeout: 20000,
307+
})
308+
309+
logger.info(
310+
`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}`
311+
)
312+
313+
return NextResponse.json(result.project)
314+
})

0 commit comments

Comments
 (0)