From 023f1f60e0785798785fa9ef8a488cf5c38d84a3 Mon Sep 17 00:00:00 2001 From: marcelo-schreiber Date: Wed, 1 May 2024 14:54:17 -0300 Subject: [PATCH] Add support for ruby and javascript --- pull-images.js | 14 +++++ src/index.ts | 85 ++------------------------ src/routes/run-code.ts | 96 ++++++++++++++++++++++++++++++ src/utils/createContainerConfig.ts | 17 +++++- test/run-code.test.ts | 77 ++++++++++++++++++++++-- 5 files changed, 203 insertions(+), 86 deletions(-) create mode 100644 pull-images.js create mode 100644 src/routes/run-code.ts diff --git a/pull-images.js b/pull-images.js new file mode 100644 index 0000000..36261bb --- /dev/null +++ b/pull-images.js @@ -0,0 +1,14 @@ +import { $ } from "bun"; + +import { codeConfig } from "./src/utils/createContainerConfig"; + +for (const key in codeConfig) { + const image = codeConfig[key].image; + try { + await $`docker pull ${codeConfig[key].image}`; + } catch (error) { + console.error(`Error pulling image: ${image}`, error); + } +} + +// Path: pull-images.js diff --git a/src/index.ts b/src/index.ts index a9b0d5e..196e84f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,94 +1,21 @@ -import { cfg as SETTINGS } from "./utils/createContainerConfig"; -import Docker from "dockerode"; import express from "express"; import { rateLimit } from "express-rate-limit"; +import { runCode } from "./routes/run-code"; -const TIMEOUT = 3000; // 3 seconds (in milliseconds) export const PORT = process.env.PORT || 3000; const limiter = rateLimit({ - windowMs: 30 * 1000, // 30 seconds - max: 3, // limit each IP to 3 requests per windowMs - standardHeaders: 'draft-7', + windowMs: 60 * 1000, // 60 seconds or 1 minute + max: 15, // limit each IP to 7 requests per windowMs + standardHeaders: "draft-7", legacyHeaders: false, -}) +}); -const docker: Docker = new Docker(); const app = express(); app.use(limiter); app.use(express.json()); - -app.post("/", async (request, response) => { - const { code, input } = request.body; - - if (!code) { - return response.status(400).json({ message: "Code is required" }); - } - - const container = await docker.createContainer({ - ...SETTINGS, // default container settings and quotas - Cmd: ["python", "-c", `${code}`], - }); - - try { - const timeoutPromise = new Promise((_, reject) => { - setTimeout(() => { - reject(new Error("Timeout")); - }, TIMEOUT); - }); - - await container.start(); - - if (input && input !== "") { - const inputs = input.split("\n"); - - const streamInput = await container.attach({ - stream: true, - stdout: false, - stderr: true, - stdin: true, - }); - - for (const i of inputs) { - streamInput.write(`${i}\n`); - } - } - - const streamOutput = await container.attach({ - stream: true, - stdout: true, - stderr: true, - stdin: false, - }); - - let output = ""; - - streamOutput.on("data", (chunk: Buffer) => { - output += chunk.toString("utf-8"); - }); - - await Promise.race([container.wait(), timeoutPromise]); - - return response.status(200).json({ message: output }); - } catch (error: Error | unknown) { - if (error instanceof Error && error.message === "Timeout") { - return response - .status(408) - .json({ message: `Timeout of ${TIMEOUT} exceeded` }); - } - - console.error(error); - - return response.status(500).json({ message: "Internal Server Error" }); - } finally { - try { - // prevent memory leaks - await container.stop(); - await container.remove(); - } catch (e) {} - } -}); +app.post("/run/:lang", runCode); app.use("*", (_, response) => { response.status(404).json({ message: "Not Found" }); diff --git a/src/routes/run-code.ts b/src/routes/run-code.ts new file mode 100644 index 0000000..1ec7fe4 --- /dev/null +++ b/src/routes/run-code.ts @@ -0,0 +1,96 @@ +import type { Request, Response } from "express"; +import Docker from "dockerode"; +import { cfg as SETTINGS, codeConfig } from "../utils/createContainerConfig"; + +const docker: Docker = new Docker(); + +const TIMEOUT = 3000; // 3 seconds (in milliseconds) + +export async function runCode(request: Request, response: Response) { + const { lang } = request.params; + const { code, input } = request.body; + + if (!code) { + return response.status(400).json({ message: "Code is required" }); + } + + if (Object.keys(codeConfig).includes(lang) === false) { + return response + .status(400) + .json({ + message: `Language not supported. The languages supported are: ${Object.keys( + codeConfig + ).join(", ")}`, + }); + } + + if (code.length > 1000) { + return response + .status(400) + .json({ message: "Code length must be less than 1000 characters" }); + } + + const container = await docker.createContainer({ + ...SETTINGS, // default container settings and quotas + Image: codeConfig[lang as keyof typeof codeConfig].image, // default image + Cmd: [...codeConfig[lang as keyof typeof codeConfig].cmd, code], // default command + }); + + try { + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => { + reject(new Error("Timeout")); + }, TIMEOUT); + }); + + await container.start(); + + if (input && input !== "") { + const inputs = input.split("\n"); + + const streamInput = await container.attach({ + stream: true, + stdout: false, + stderr: true, + stdin: true, + }); + + for (const i of inputs) { + streamInput.write(`${i}\n`); + } + } + + const streamOutput = await container.attach({ + stream: true, + stdout: true, + stderr: true, + stdin: false, + }); + + let output = ""; + + streamOutput.on("data", (chunk: Buffer) => { + output += chunk.toString("utf-8"); + }); + + await Promise.race([container.wait(), timeoutPromise]); + + return response.status(200).json({ message: output }); + } catch (error: Error | unknown) { + if (error instanceof Error && error.message === "Timeout") { + return response + .status(408) + .json({ message: `Timeout of ${TIMEOUT} exceeded` }); + } + + console.error(error); + + return response.status(500).json({ message: "Internal Server Error" }); + } finally { + try { + // prevent memory leaks + await container.stop(); + await container.remove(); + } catch (e) {} + } +} diff --git a/src/utils/createContainerConfig.ts b/src/utils/createContainerConfig.ts index d85e4c8..7691be7 100644 --- a/src/utils/createContainerConfig.ts +++ b/src/utils/createContainerConfig.ts @@ -1,7 +1,21 @@ import type Docker from "dockerode"; +export const codeConfig = { + python: { + image: "python:3.9-slim", + cmd: ["python", "-c"], + }, + javascript: { + image: "node:14-slim", + cmd: ["node", "-e"], + }, + ruby: { + image: "ruby:3.0-slim", + cmd: ["ruby", "-e"], + }, +}; + export const cfg: Docker.ContainerCreateOptions = { - Image: "python:3.9-slim", NetworkDisabled: true, Tty: true, AttachStdin: true, @@ -21,4 +35,5 @@ export const cfg: Docker.ContainerCreateOptions = { ReadonlyRootfs: true, Privileged: false, }, + ArgsEscaped: true, }; diff --git a/test/run-code.test.ts b/test/run-code.test.ts index 30629dd..1f261d8 100644 --- a/test/run-code.test.ts +++ b/test/run-code.test.ts @@ -1,5 +1,4 @@ import { expect, test } from "vitest"; - import { PORT } from "../src/index"; import "../src/index"; // runs server @@ -10,8 +9,8 @@ test("404 check", async () => { expect(response.status).toBe(404); }); -test("run code without input", async () => { - const response = await fetch(`http://localhost:${PORT}/`, { +test("run python code without input", async () => { + const response = await fetch(`http://localhost:${PORT}/run/python`, { method: "POST", headers: { "Content-Type": "application/json", @@ -26,8 +25,8 @@ test("run code without input", async () => { expect(body).toMatchObject({ message: "Hello, World!\r\n" }); }); -test("run code with input", async () => { - const response = await fetch(`http://localhost:${PORT}/`, { +test("run python code with input", async () => { + const response = await fetch(`http://localhost:${PORT}/run/python`, { method: "POST", headers: { "Content-Type": "application/json", @@ -41,4 +40,70 @@ test("run code with input", async () => { const body = await response.json(); expect(body).toMatchObject({ message: "With input!\r\n" }); -}) \ No newline at end of file +}); + +test("run javascript code without input", async () => { + const response = await fetch(`http://localhost:${PORT}/run/javascript`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + code: "console.log('Hello, World!')", + }), + }); + + const body = await response.json(); + + expect(body).toMatchObject({ message: "Hello, World!\r\n" }); +}); + +test("run ruby code without input", async () => { + const response = await fetch(`http://localhost:${PORT}/run/ruby`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + code: "puts 'Hello, World!'", + }), + }); + + const body = await response.json(); + + expect(body).toMatchObject({ message: "Hello, World!\r\n" }); +}); + +test("run unsupported language", async () => { + const response = await fetch(`http://localhost:${PORT}/run/unsupported`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + code: "print('Hello, World!')", + }), + }); + + const body = await response.json(); + + expect(body.message).toContain( + "Language not supported. The languages supported are" + ); +}); + +test("code timeout", async () => { + const response = await fetch(`http://localhost:${PORT}/run/python`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + code: "while True: pass", + }), + }); + + const body = await response.json(); + + expect(body.message).toContain("Timeout"); +});