diff --git a/backend/server/src/DokkuClient.ts b/backend/server/src/DokkuClient.ts index e2e9911..38c559d 100644 --- a/backend/server/src/DokkuClient.ts +++ b/backend/server/src/DokkuClient.ts @@ -1,15 +1,19 @@ import { SSHConfig, SSHSocketClient } from "./SSHSocketClient" +// Interface for the response structure from Dokku commands export interface DokkuResponse { ok: boolean output: string } +// DokkuClient class extends SSHSocketClient to interact with Dokku via SSH export class DokkuClient extends SSHSocketClient { constructor(config: SSHConfig) { + // Initialize with Dokku daemon socket path super(config, "/var/run/dokku-daemon/dokku-daemon.sock") } + // Send a command to Dokku and parse the response async sendCommand(command: string): Promise { try { const response = await this.sendData(command) @@ -18,15 +22,18 @@ export class DokkuClient extends SSHSocketClient { throw new Error("Received data is not a string") } + // Parse the JSON response from Dokku return JSON.parse(response) } catch (error: any) { throw new Error(`Failed to send command: ${error.message}`) } } + // List all deployed Dokku apps async listApps(): Promise { const response = await this.sendCommand("apps:list") - return response.output.split("\n").slice(1) // Split by newline and ignore the first line (header) + // Split the output by newline and remove the header + return response.output.split("\n").slice(1) } } diff --git a/backend/server/src/FileManager.ts b/backend/server/src/FileManager.ts index 1ef5077..2ac1a80 100644 --- a/backend/server/src/FileManager.ts +++ b/backend/server/src/FileManager.ts @@ -12,11 +12,13 @@ import { import { MAX_BODY_SIZE } from "./ratelimit" import { TFile, TFileData, TFolder } from "./types" +// Define the structure for sandbox files export type SandboxFiles = { files: (TFolder | TFile)[] fileData: TFileData[] } +// FileManager class to handle file operations in a sandbox export class FileManager { private sandboxId: string private sandbox: Sandbox @@ -25,6 +27,7 @@ export class FileManager { private dirName = "/home/user" private refreshFileList: (files: SandboxFiles) => void + // Constructor to initialize the FileManager constructor( sandboxId: string, sandbox: Sandbox, @@ -36,6 +39,7 @@ export class FileManager { this.refreshFileList = refreshFileList } + // Initialize the FileManager async initialize() { this.sandboxFiles = await getSandboxFiles(this.sandboxId) const projectDirectory = path.posix.join( @@ -94,6 +98,7 @@ export class FileManager { } } + // Watch a directory for changes async watchDirectory(directory: string): Promise { try { const handle = await this.sandbox.files.watch( @@ -130,7 +135,7 @@ export class FileManager { ) } - // A new file or directory was created. + // Handle file/directory creation event if (event.type === "create") { const folder = findFolderById( this.sandboxFiles.files, @@ -140,16 +145,16 @@ export class FileManager { const newItem = isDir ? ({ - id: sandboxFilePath, - name: event.name, - type: "folder", - children: [], - } as TFolder) + id: sandboxFilePath, + name: event.name, + type: "folder", + children: [], + } as TFolder) : ({ - id: sandboxFilePath, - name: event.name, - type: "file", - } as TFile) + id: sandboxFilePath, + name: event.name, + type: "file", + } as TFile) if (folder) { // If the folder exists, add the new item (file/folder) as a child @@ -174,7 +179,7 @@ export class FileManager { console.log(`Create ${sandboxFilePath}`) } - // A file or directory was removed or renamed. + // Handle file/directory removal or rename event else if (event.type === "remove" || event.type == "rename") { const folder = findFolderById( this.sandboxFiles.files, @@ -206,7 +211,7 @@ export class FileManager { console.log(`Removed: ${sandboxFilePath}`) } - // The contents of a file were changed. + // Handle file write event else if (event.type === "write") { const folder = findFolderById( this.sandboxFiles.files, @@ -259,6 +264,7 @@ export class FileManager { } } + // Watch subdirectories recursively async watchSubdirectories(directory: string) { const dirContent = await this.sandbox.files.list(directory) await Promise.all( @@ -271,15 +277,18 @@ export class FileManager { ) } + // Get file content async getFile(fileId: string): Promise { const file = this.sandboxFiles.fileData.find((f) => f.id === fileId) return file?.data } + // Get folder content async getFolder(folderId: string): Promise { return getFolder(folderId) } + // Save file content async saveFile(fileId: string, body: string): Promise { if (!fileId) return // handles saving when no file is open @@ -295,6 +304,7 @@ export class FileManager { this.fixPermissions() } + // Move a file to a different folder async moveFile( fileId: string, folderId: string @@ -318,6 +328,7 @@ export class FileManager { return newFiles.files } + // Move a file within the container private async moveFileInContainer(oldPath: string, newPath: string) { try { const fileContents = await this.sandbox.files.read( @@ -333,6 +344,7 @@ export class FileManager { } } + // Create a new file async createFile(name: string): Promise { const size: number = await getProjectSize(this.sandboxId) if (size > 200 * 1024 * 1024) { @@ -360,11 +372,13 @@ export class FileManager { return true } + // Create a new folder async createFolder(name: string): Promise { const id = `projects/${this.sandboxId}/${name}` await this.sandbox.files.makeDir(path.posix.join(this.dirName, id)) } + // Rename a file async renameFile(fileId: string, newName: string): Promise { const fileData = this.sandboxFiles.fileData.find((f) => f.id === fileId) const file = this.sandboxFiles.files.find((f) => f.id === fileId) @@ -381,6 +395,7 @@ export class FileManager { file.id = newFileId } + // Delete a file async deleteFile(fileId: string): Promise<(TFolder | TFile)[]> { const file = this.sandboxFiles.fileData.find((f) => f.id === fileId) if (!file) return this.sandboxFiles.files @@ -396,6 +411,7 @@ export class FileManager { return newFiles.files } + // Delete a folder async deleteFolder(folderId: string): Promise<(TFolder | TFile)[]> { const files = await getFolder(folderId) @@ -413,6 +429,7 @@ export class FileManager { return newFiles.files } + // Close all file watchers async closeWatchers() { await Promise.all( this.fileWatchers.map(async (handle: WatchHandle) => { @@ -420,4 +437,4 @@ export class FileManager { }) ) } -} +} \ No newline at end of file diff --git a/backend/server/src/SSHSocketClient.ts b/backend/server/src/SSHSocketClient.ts index c653b23..0fe4152 100644 --- a/backend/server/src/SSHSocketClient.ts +++ b/backend/server/src/SSHSocketClient.ts @@ -1,5 +1,6 @@ import { Client } from "ssh2" +// Interface defining the configuration for SSH connection export interface SSHConfig { host: string port?: number @@ -7,25 +8,29 @@ export interface SSHConfig { privateKey: Buffer } +// Class to handle SSH connections and communicate with a Unix socket export class SSHSocketClient { private conn: Client private config: SSHConfig private socketPath: string private isConnected: boolean = false + // Constructor initializes the SSH client and sets up configuration constructor(config: SSHConfig, socketPath: string) { this.conn = new Client() - this.config = { ...config, port: 22 } + this.config = { ...config, port: 22 } // Default port to 22 if not provided this.socketPath = socketPath this.setupTerminationHandlers() } + // Set up handlers for graceful termination private setupTerminationHandlers() { process.on("SIGINT", this.closeConnection.bind(this)) process.on("SIGTERM", this.closeConnection.bind(this)) } + // Method to close the SSH connection private closeConnection() { console.log("Closing SSH connection...") this.conn.end() @@ -33,6 +38,7 @@ export class SSHSocketClient { process.exit(0) } + // Method to establish the SSH connection connect(): Promise { return new Promise((resolve, reject) => { this.conn @@ -54,6 +60,7 @@ export class SSHSocketClient { }) } + // Method to send data through the SSH connection to the Unix socket sendData(data: string): Promise { return new Promise((resolve, reject) => { if (!this.isConnected) { @@ -61,6 +68,7 @@ export class SSHSocketClient { return } + // Use netcat to send data to the Unix socket this.conn.exec( `echo "${data}" | nc -U ${this.socketPath}`, (err, stream) => { diff --git a/backend/server/src/index.ts b/backend/server/src/index.ts index ffaf067..9d82c4e 100644 --- a/backend/server/src/index.ts +++ b/backend/server/src/index.ts @@ -20,12 +20,14 @@ import { TerminalManager } from "./TerminalManager" import { User } from "./types" import { LockManager } from "./utils" +// Handle uncaught exceptions process.on("uncaughtException", (error) => { console.error("Uncaught Exception:", error) // Do not exit the process // You can add additional logging or recovery logic here }) +// Handle unhandled promise rejections process.on("unhandledRejection", (reason, promise) => { console.error("Unhandled Rejection at:", promise, "reason:", reason) // Do not exit the process @@ -35,8 +37,10 @@ process.on("unhandledRejection", (reason, promise) => { // The amount of time in ms that a container will stay alive without a hearbeat. const CONTAINER_TIMEOUT = 120_000 +// Load environment variables dotenv.config() +// Initialize Express app and create HTTP server const app: Express = express() const port = process.env.PORT || 4000 app.use(cors()) @@ -47,10 +51,12 @@ const io = new Server(httpServer, { }, }) +// Check if the sandbox owner is connected function isOwnerConnected(sandboxId: string): boolean { return (connections[sandboxId] ?? 0) > 0 } +// Extract port number from a string function extractPortNumber(inputString: string): number | null { const cleanedString = inputString.replace(/\x1B\[[0-9;]*m/g, "") const regex = /http:\/\/localhost:(\d+)/ @@ -58,12 +64,15 @@ function extractPortNumber(inputString: string): number | null { return match ? parseInt(match[1]) : null } +// Initialize containers and managers const containers: Record = {} const connections: Record = {} const fileManagers: Record = {} const terminalManagers: Record = {} +// Middleware for socket authentication io.use(async (socket, next) => { + // Define the schema for handshake query validation const handshakeSchema = z.object({ userId: z.string(), sandboxId: z.string(), @@ -74,12 +83,14 @@ io.use(async (socket, next) => { const q = socket.handshake.query const parseQuery = handshakeSchema.safeParse(q) + // Check if the query is valid according to the schema if (!parseQuery.success) { next(new Error("Invalid request.")) return } const { sandboxId, userId } = parseQuery.data + // Fetch user data from the database const dbUser = await fetch( `${process.env.DATABASE_WORKER_URL}/api/user?id=${userId}`, { @@ -90,32 +101,39 @@ io.use(async (socket, next) => { ) const dbUserJSON = (await dbUser.json()) as User + // Check if user data was retrieved successfully if (!dbUserJSON) { next(new Error("DB error.")) return } + // Check if the user owns the sandbox or has shared access const sandbox = dbUserJSON.sandbox.find((s) => s.id === sandboxId) const sharedSandboxes = dbUserJSON.usersToSandboxes.find( (uts) => uts.sandboxId === sandboxId ) + // If user doesn't own or have shared access to the sandbox, deny access if (!sandbox && !sharedSandboxes) { next(new Error("Invalid credentials.")) return } + // Set socket data with user information socket.data = { userId, sandboxId: sandboxId, isOwner: sandbox !== undefined, } + // Allow the connection next() }) +// Initialize lock manager const lockManager = new LockManager() +// Check for required environment variables if (!process.env.DOKKU_HOST) console.error("Environment variable DOKKU_HOST is not defined") if (!process.env.DOKKU_USERNAME) @@ -123,6 +141,7 @@ if (!process.env.DOKKU_USERNAME) if (!process.env.DOKKU_KEY) console.error("Environment variable DOKKU_KEY is not defined") +// Initialize Dokku client const client = process.env.DOKKU_HOST && process.env.DOKKU_KEY && process.env.DOKKU_USERNAME ? new DokkuClient({ @@ -133,6 +152,7 @@ const client = : null client?.connect() +// Initialize Git client used to deploy Dokku apps const git = process.env.DOKKU_HOST && process.env.DOKKU_KEY ? new SecureGitClient( @@ -141,6 +161,7 @@ const git = ) : null +// Handle socket connections io.on("connection", async (socket) => { try { const data = socket.data as { @@ -149,6 +170,7 @@ io.on("connection", async (socket) => { isOwner: boolean } + // Handle connection based on user type (owner or not) if (data.isOwner) { connections[data.sandboxId] = (connections[data.sandboxId] ?? 0) + 1 } else { @@ -158,6 +180,7 @@ io.on("connection", async (socket) => { } } + // Create or retrieve container const createdContainer = await lockManager.acquireLock( data.sandboxId, async () => { @@ -180,10 +203,12 @@ io.on("connection", async (socket) => { } ) + // Function to send loaded event const sendLoadedEvent = (files: SandboxFiles) => { socket.emit("loaded", files.files) } + // Initialize file and terminal managers if container was created if (createdContainer) { fileManagers[data.sandboxId] = new FileManager( data.sandboxId, @@ -203,6 +228,7 @@ io.on("connection", async (socket) => { // Load file list from the file manager into the editor sendLoadedEvent(fileManager.sandboxFiles) + // Handle various socket events (heartbeat, file operations, terminal operations, etc.) socket.on("heartbeat", async () => { try { // This keeps the container alive for another CONTAINER_TIMEOUT seconds. @@ -214,6 +240,7 @@ io.on("connection", async (socket) => { } }) + // Handle request to get file content socket.on("getFile", async (fileId: string, callback) => { try { const fileContent = await fileManager.getFile(fileId) @@ -224,6 +251,7 @@ io.on("connection", async (socket) => { } }) + // Handle request to get folder contents socket.on("getFolder", async (folderId: string, callback) => { try { const files = await fileManager.getFolder(folderId) @@ -234,6 +262,7 @@ io.on("connection", async (socket) => { } }) + // Handle request to save file socket.on("saveFile", async (fileId: string, body: string) => { try { await saveFileRL.consume(data.userId, 1) @@ -244,6 +273,7 @@ io.on("connection", async (socket) => { } }) + // Handle request to move file socket.on( "moveFile", async (fileId: string, folderId: string, callback) => { @@ -263,6 +293,7 @@ io.on("connection", async (socket) => { message?: string } + // Handle request to list apps socket.on( "list", async (callback: (response: CallbackResponse) => void) => { @@ -283,6 +314,7 @@ io.on("connection", async (socket) => { } ) + // Handle request to deploy project socket.on( "deploy", async (callback: (response: CallbackResponse) => void) => { @@ -313,6 +345,7 @@ io.on("connection", async (socket) => { } ) + // Handle request to create a new file socket.on("createFile", async (name: string, callback) => { try { await createFileRL.consume(data.userId, 1) @@ -324,6 +357,7 @@ io.on("connection", async (socket) => { } }) + // Handle request to create a new folder socket.on("createFolder", async (name: string, callback) => { try { await createFolderRL.consume(data.userId, 1) @@ -335,6 +369,7 @@ io.on("connection", async (socket) => { } }) + // Handle request to rename a file socket.on("renameFile", async (fileId: string, newName: string) => { try { await renameFileRL.consume(data.userId, 1) @@ -345,6 +380,7 @@ io.on("connection", async (socket) => { } }) + // Handle request to delete a file socket.on("deleteFile", async (fileId: string, callback) => { try { await deleteFileRL.consume(data.userId, 1) @@ -356,6 +392,7 @@ io.on("connection", async (socket) => { } }) + // Handle request to delete a folder socket.on("deleteFolder", async (folderId: string, callback) => { try { const newFiles = await fileManager.deleteFolder(folderId) @@ -366,6 +403,7 @@ io.on("connection", async (socket) => { } }) + // Handle request to create a new terminal socket.on("createTerminal", async (id: string, callback) => { try { await lockManager.acquireLock(data.sandboxId, async () => { @@ -387,6 +425,7 @@ io.on("connection", async (socket) => { } }) + // Handle request to resize terminal socket.on( "resizeTerminal", (dimensions: { cols: number; rows: number }) => { @@ -399,6 +438,7 @@ io.on("connection", async (socket) => { } ) + // Handle terminal input data socket.on("terminalData", async (id: string, data: string) => { try { await terminalManager.sendTerminalData(id, data) @@ -408,6 +448,7 @@ io.on("connection", async (socket) => { } }) + // Handle request to close terminal socket.on("closeTerminal", async (id: string, callback) => { try { await terminalManager.closeTerminal(id) @@ -418,6 +459,7 @@ io.on("connection", async (socket) => { } }) + // Handle request to generate code socket.on( "generateCode", async ( @@ -442,7 +484,7 @@ io.on("connection", async (socket) => { } ) - // Generate code from cloudflare workers AI + // Generate code from Cloudflare Workers AI const generateCodePromise = fetch( `${process.env.AI_WORKER_URL}/api?fileName=${encodeURIComponent( fileName @@ -472,6 +514,7 @@ io.on("connection", async (socket) => { } ) + // Handle socket disconnection socket.on("disconnect", async () => { try { if (data.isOwner) { @@ -498,6 +541,7 @@ io.on("connection", async (socket) => { } }) +// Start the server httpServer.listen(port, () => { console.log(`Server running on port ${port}`) })