backend setup!

This commit is contained in:
Charmunks 2025-10-31 10:46:35 -04:00
commit f18cf813e8
32 changed files with 6121 additions and 0 deletions

3
.dockerignore Normal file
View file

@ -0,0 +1,3 @@
node_modules/
client/node_modules/
client/dist/

7
.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
.env
node_modules
dist
build
.github
.vscode
.DS_Store

105
Dockerfile Normal file
View file

@ -0,0 +1,105 @@
# Use Ubuntu as base image for better Docker-in-Docker support
FROM ubuntu:22.04
# Avoid prompts from apt
ENV DEBIAN_FRONTEND=noninteractive
# Install system dependencies including Docker
RUN apt-get update && apt-get install -y \
curl \
wget \
gnupg \
lsb-release \
ca-certificates \
apt-transport-https \
software-properties-common \
supervisor \
nginx \
&& rm -rf /var/lib/apt/lists/*
# Install Docker Engine for Docker-in-Docker
RUN mkdir -p /etc/apt/keyrings \
&& curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null \
&& apt-get update \
&& apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin \
&& rm -rf /var/lib/apt/lists/*
# Install Node.js 18.x
RUN curl -fsSL https://deb.nodesource.com/setup_18.x | bash - \
&& apt-get install -y nodejs
# Create app directory
WORKDIR /app
# Copy package files
COPY package*.json ./
COPY client/package*.json ./client/
# Install dependencies
RUN npm install
RUN cd client && npm install
# Copy application code
COPY . .
# Copy nginx configuration
COPY nginx.conf /etc/nginx/nginx.conf
# Create supervisor configuration
RUN mkdir -p /var/log/supervisor
COPY <<EOF /etc/supervisor/conf.d/supervisord.conf
[supervisord]
nodaemon=true
user=root
[program:dockerd]
command=dockerd --host=unix:///var/run/docker.sock --host=tcp://0.0.0.0:2376 --storage-driver=overlay2
autostart=true
autorestart=true
stderr_logfile=/var/log/supervisor/dockerd.err.log
stdout_logfile=/var/log/supervisor/dockerd.out.log
priority=100
[program:docker-pull]
command=/bin/bash -c "sleep 25 && docker pull linuxserver/code-server && docker pull linuxserver/blender && docker pull linuxserver/kicad && echo 'Docker images pulled successfully'"
autostart=true
autorestart=false
startsecs=0
stderr_logfile=/var/log/supervisor/docker-pull.err.log
stdout_logfile=/var/log/supervisor/docker-pull.out.log
priority=150
[program:dev-server]
command=npm run dev
directory=/app
autostart=true
autorestart=true
stderr_logfile=/var/log/supervisor/dev-server.err.log
stdout_logfile=/var/log/supervisor/dev-server.out.log
environment=NODE_ENV=development
priority=200
[program:nginx]
command=nginx -g "daemon off;"
autostart=true
autorestart=true
stderr_logfile=/var/log/supervisor/nginx.err.log
stdout_logfile=/var/log/supervisor/nginx.out.log
priority=400
EOF
# Copy and setup startup script
COPY start.sh /app/start.sh
RUN chmod +x /app/start.sh
# Expose ports
EXPOSE 80 3000 5173
# Set environment variables
ENV NODE_ENV=development
ENV DOCKER_HOST=unix:///var/run/docker.sock
ENV DOCKER_TLS_CERTDIR=""
# Use the startup script as entrypoint
ENTRYPOINT ["/app/start.sh"]

24
client/.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

47
client/README.md Normal file
View file

@ -0,0 +1,47 @@
# Svelte + Vite
This template should help get you started developing with Svelte in Vite.
## Recommended IDE Setup
[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode).
## Need an official Svelte framework?
Check out [SvelteKit](https://github.com/sveltejs/kit#readme), which is also powered by Vite. Deploy anywhere with its serverless-first approach and adapt to various platforms, with out of the box support for TypeScript, SCSS, and Less, and easily-added support for mdsvex, GraphQL, PostCSS, Tailwind CSS, and more.
## Technical considerations
**Why use this over SvelteKit?**
- It brings its own routing solution which might not be preferable for some users.
- It is first and foremost a framework that just happens to use Vite under the hood, not a Vite app.
This template contains as little as possible to get started with Vite + Svelte, while taking into account the developer experience with regards to HMR and intellisense. It demonstrates capabilities on par with the other `create-vite` templates and is a good starting point for beginners dipping their toes into a Vite + Svelte project.
Should you later need the extended capabilities and extensibility provided by SvelteKit, the template has been structured similarly to SvelteKit so that it is easy to migrate.
**Why `global.d.ts` instead of `compilerOptions.types` inside `jsconfig.json` or `tsconfig.json`?**
Setting `compilerOptions.types` shuts out all other types not explicitly listed in the configuration. Using triple-slash references keeps the default TypeScript setting of accepting type information from the entire workspace, while also adding `svelte` and `vite/client` type information.
**Why include `.vscode/extensions.json`?**
Other templates indirectly recommend extensions via the README, but this file allows VS Code to prompt the user to install the recommended extension upon opening the project.
**Why enable `checkJs` in the JS template?**
It is likely that most cases of changing variable types in runtime are likely to be accidental, rather than deliberate. This provides advanced typechecking out of the box. Should you like to take advantage of the dynamically-typed nature of JavaScript, it is trivial to change the configuration.
**Why is HMR not preserving my local component state?**
HMR state preservation comes with a number of gotchas! It has been disabled by default in both `svelte-hmr` and `@sveltejs/vite-plugin-svelte` due to its often surprising behavior. You can read the details [here](https://github.com/rixo/svelte-hmr#svelte-hmr).
If you have state that's important to retain within a component, consider creating an external store which would not be replaced by HMR.
```js
// store.js
// An extremely simple external store
import { writable } from 'svelte/store'
export default writable(0)
```

13
client/index.html Normal file
View file

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + Svelte</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

33
client/jsconfig.json Normal file
View file

@ -0,0 +1,33 @@
{
"compilerOptions": {
"moduleResolution": "Node",
"target": "ESNext",
"module": "ESNext",
/**
* svelte-preprocess cannot figure out whether you have
* a value or a type, so tell TypeScript to enforce using
* `import type` instead of `import` for Types.
*/
"importsNotUsedAsValues": "error",
"isolatedModules": true,
"resolveJsonModule": true,
/**
* To have warnings / errors of the Svelte compiler at the
* correct position, enable source maps by default.
*/
"sourceMap": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
/**
* Typecheck JS in `.svelte` and `.js` files by default.
* Disable this if you'd like to use dynamic types.
*/
"checkJs": true
},
/**
* Use global.d.ts instead of compilerOptions.types
* to avoid limiting type declarations.
*/
"include": ["src/**/*.d.ts", "src/**/*.js", "src/**/*.svelte"]
}

1069
client/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

16
client/package.json Normal file
View file

@ -0,0 +1,16 @@
{
"name": "client",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^1.1.0",
"svelte": "^3.52.0",
"vite": "^3.2.3"
}
}

1
client/public/vite.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

63
client/src/App.svelte Normal file
View file

@ -0,0 +1,63 @@
<script>
import { onMount } from 'svelte'
import svelteLogo from './assets/svelte.svg'
import Emojis from './lib/Emojis.svelte'
let emojisList;
onMount(async () => {
const response = await fetch('http://localhost:5678/api/v1/emojis');
const { emojis } = await response.json();
emojisList = emojis;
console.log(emojisList)
});
</script>
<main>
<div>
<a href="https://vitejs.dev" target="_blank" rel="noreferrer">
<img src="/vite.svg" class="logo" alt="Vite Logo" />
</a>
<a href="https://svelte.dev" target="_blank" rel="noreferrer">
<img src={svelteLogo} class="logo svelte" alt="Svelte Logo" />
</a>
</div>
<h1>Vite + Svelte</h1>
{#if emojisList}
<div class="card">
<Emojis emojis={emojisList} />
</div>
{/if}
<p>
Check out <a href="https://github.com/sveltejs/kit#readme" target="_blank" rel="noreferrer">SvelteKit</a>, the official Svelte app framework powered by Vite!
</p>
<p class="read-the-docs">
Click on the Vite and Svelte logos to learn more
</p>
</main>
<style>
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.svelte:hover {
filter: drop-shadow(0 0 2em #ff3e00aa);
}
.read-the-docs {
color: #888;
}
</style>

81
client/src/app.css Normal file
View file

@ -0,0 +1,81 @@
:root {
font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
font-size: 16px;
line-height: 24px;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
.card {
padding: 2em;
}
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="26.6" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 308"><path fill="#FF3E00" d="M239.682 40.707C211.113-.182 154.69-12.301 113.895 13.69L42.247 59.356a82.198 82.198 0 0 0-37.135 55.056a86.566 86.566 0 0 0 8.536 55.576a82.425 82.425 0 0 0-12.296 30.719a87.596 87.596 0 0 0 14.964 66.244c28.574 40.893 84.997 53.007 125.787 27.016l71.648-45.664a82.182 82.182 0 0 0 37.135-55.057a86.601 86.601 0 0 0-8.53-55.577a82.409 82.409 0 0 0 12.29-30.718a87.573 87.573 0 0 0-14.963-66.244"></path><path fill="#FFF" d="M106.889 270.841c-23.102 6.007-47.497-3.036-61.103-22.648a52.685 52.685 0 0 1-9.003-39.85a49.978 49.978 0 0 1 1.713-6.693l1.35-4.115l3.671 2.697a92.447 92.447 0 0 0 28.036 14.007l2.663.808l-.245 2.659a16.067 16.067 0 0 0 2.89 10.656a17.143 17.143 0 0 0 18.397 6.828a15.786 15.786 0 0 0 4.403-1.935l71.67-45.672a14.922 14.922 0 0 0 6.734-9.977a15.923 15.923 0 0 0-2.713-12.011a17.156 17.156 0 0 0-18.404-6.832a15.78 15.78 0 0 0-4.396 1.933l-27.35 17.434a52.298 52.298 0 0 1-14.553 6.391c-23.101 6.007-47.497-3.036-61.101-22.649a52.681 52.681 0 0 1-9.004-39.849a49.428 49.428 0 0 1 22.34-33.114l71.664-45.677a52.218 52.218 0 0 1 14.563-6.398c23.101-6.007 47.497 3.036 61.101 22.648a52.685 52.685 0 0 1 9.004 39.85a50.559 50.559 0 0 1-1.713 6.692l-1.35 4.116l-3.67-2.693a92.373 92.373 0 0 0-28.037-14.013l-2.664-.809l.246-2.658a16.099 16.099 0 0 0-2.89-10.656a17.143 17.143 0 0 0-18.398-6.828a15.786 15.786 0 0 0-4.402 1.935l-71.67 45.674a14.898 14.898 0 0 0-6.73 9.975a15.9 15.9 0 0 0 2.709 12.012a17.156 17.156 0 0 0 18.404 6.832a15.841 15.841 0 0 0 4.402-1.935l27.345-17.427a52.147 52.147 0 0 1 14.552-6.397c23.101-6.006 47.497 3.037 61.102 22.65a52.681 52.681 0 0 1 9.003 39.848a49.453 49.453 0 0 1-22.34 33.12l-71.664 45.673a52.218 52.218 0 0 1-14.563 6.398"></path></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View file

@ -0,0 +1,25 @@
<script>
export let emojis;
</script>
<section>
<ul>
{#each emojis as emoji}
<li>{emoji}</li>
{/each}
</ul>
</section>
<style>
ul {
list-style-type: none;
}
li {
display: inline-flex;
align-items: center;
justify-content: center;
flex-direction: column;
padding: 5px;
}
</style>

8
client/src/main.js Normal file
View file

@ -0,0 +1,8 @@
import './app.css'
import App from './App.svelte'
const app = new App({
target: document.getElementById('app')
})
export default app

2
client/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1,2 @@
/// <reference types="svelte" />
/// <reference types="vite/client" />

11
client/vite.config.js Normal file
View file

@ -0,0 +1,11 @@
import { defineConfig } from 'vite'
import { svelte } from '@sveltejs/vite-plugin-svelte'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [svelte()],
server: {
host: '0.0.0.0', // Binds to all network interfaces, accessible from other devices
port: 5173, // Default port, can be changed
}
})

23
docker-compose.yml Normal file
View file

@ -0,0 +1,23 @@
version: '3.8'
services:
spaces-app:
build: .
privileged: true # Required for Docker-in-Docker
ports:
- "80:80" # Nginx proxy
- "3000:3000" # Backend API
- "5173:5173" # Frontend
- "2376:2376" # Docker daemon API
volumes:
- docker-data:/var/lib/docker # Docker storage volume
environment:
- NODE_ENV=development
- DOCKER_HOST=unix:///var/run/docker.sock
- DOCKER_TLS_CERTDIR=""
restart: unless-stopped
extra_hosts:
- "host.docker.internal:host-gateway" # Enable access to Docker host ports
volumes:
docker-data:

70
nginx.conf Normal file
View file

@ -0,0 +1,70 @@
events {
worker_connections 1024;
}
http {
upstream frontend {
server localhost:5173;
}
upstream api {
server localhost:3000;
}
server {
listen 80;
server_name localhost;
resolver 127.0.0.11 valid=30s;
location /api {
proxy_pass http://api;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Port forwarding - matches /port/8080, /port/3001, etc.
# Routes to localhost ports (containers running in Docker-in-Docker)
location ~ ^/port/(\d+)(/.*)?$ {
set $port $1;
set $path $2;
set $target localhost:$port;
proxy_pass http://$target$path;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Pass through authentication headers for HTTP Basic Auth
proxy_pass_header Authorization;
proxy_set_header Authorization $http_authorization;
# WebSocket support for development servers
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# Additional headers for better compatibility
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Server $host;
proxy_buffering off;
# Handle authentication responses properly
proxy_intercept_errors off;
}
location / {
proxy_pass http://frontend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
}

3581
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

32
package.json Normal file
View file

@ -0,0 +1,32 @@
{
"name": "spaces",
"version": "1.0.0",
"description": "Free web ide for teens",
"main": "src/app.js",
"type": "module",
"scripts": {
"client-install": "cd client && npm install",
"start": "node src/index.js",
"serve:server": "nodemon src/index.js",
"serve:client": "cd client && npm run dev",
"dev": "concurrently \"npm run serve:server\" \"npm run serve:client\"",
"heroku-postbuild": "NPM_CONFIG_PRODUCTION=false cd client && npm install && npm run build"
},
"author": "Charmunk",
"license": "MIT",
"dependencies": {
"airtable": "^0.12.2",
"body-parser": "^1.20.1",
"concurrently": "^7.5.0",
"cors": "^2.8.5",
"dockerode": "^4.0.9",
"dotenv": "^17.2.3",
"express": "^4.18.2",
"get-port": "^7.1.0",
"knex": "^3.1.0",
"pg": "^8.16.3"
},
"devDependencies": {
"nodemon": "^2.0.20"
}
}

16
src/api/index.js Normal file
View file

@ -0,0 +1,16 @@
import express from "express";
import spaces from './spaces/space.route.js';
import auth from './users/auth.route.js';
const router = express.Router();
router.get('/', (req, res) => {
res.status(200).json({
message: 'API :3',
});
});
router.use('/spaces/', spaces);
router.use('/users/', auth);
export default router;

View file

@ -0,0 +1,87 @@
import express from "express";
import {
createContainer,
startContainer,
stopContainer,
getContainerStatus,
getUserSpaces
} from "../../utils/spaces.js";
const router = express.Router();
router.post("/create", async (req, res) => {
const { password, type } = req.body;
const authorization = req.headers.authorization;
try {
const result = await createContainer(password, type, authorization);
res.json(result);
} catch (err) {
if (err.validTypes) {
return res.status(400).json({
error: err.message,
validTypes: err.validTypes
});
}
const statusCode = err.message.includes("Missing") || err.message.includes("Invalid authorization") ? 400 : 500;
res.status(statusCode).json({ error: err.message });
}
});
router.post("/start/:spaceId", async (req, res) => {
const { spaceId } = req.params;
const authorization = req.headers.authorization;
try {
const result = await startContainer(spaceId, authorization);
res.json(result);
} catch (err) {
const statusCode = err.statusCode || (err.message.includes("required") || err.message.includes("Missing") || err.message.includes("Invalid authorization") ? 400 : 500);
res.status(statusCode).json({ error: err.message });
}
});
router.post("/stop/:spaceId", async (req, res) => {
const { spaceId } = req.params;
const authorization = req.headers.authorization;
try {
const result = await stopContainer(spaceId, authorization);
res.json(result);
} catch (err) {
const statusCode = err.statusCode || (err.message.includes("required") || err.message.includes("Missing") || err.message.includes("Invalid authorization") ? 400 : 500);
res.status(statusCode).json({ error: err.message });
}
});
router.get("/status/:spaceId", async (req, res) => {
const { spaceId } = req.params;
const authorization = req.headers.authorization;
try {
const result = await getContainerStatus(spaceId, authorization);
res.json(result);
} catch (err) {
const statusCode = err.statusCode || (err.message.includes("required") || err.message.includes("Missing") || err.message.includes("Invalid authorization") ? 400 : 500);
res.status(statusCode).json({ error: err.message });
}
});
// GET /api/v1/spaces/list - List all spaces for authenticated user
router.get("/list", async (req, res) => {
const authorization = req.headers.authorization;
try {
const spaces = await getUserSpaces(authorization);
res.json({
message: "Spaces retrieved successfully",
spaces
});
} catch (err) {
const statusCode = err.message.includes("Missing") || err.message.includes("Invalid authorization") ? 400 : 500;
res.status(statusCode).json({ error: err.message });
}
});
export default router;

258
src/api/users/auth.route.js Normal file
View file

@ -0,0 +1,258 @@
import express from 'express';
import { sendEmail, checkEmail } from '../../utils/airtable.js';
import pg from '../../utils/db.js';
import crypto from 'crypto';
const router = express.Router();
const randomToken = () => {
return crypto.randomBytes(32).toString('hex');
};
router.get('/send', (req, res) => {
res.status(200).json({
message: 'Use Post to /send to send verification code',
});
});
// POST /api/v1/users/send
router.post('/send', async (req, res) => {
try {
const { email } = req.body;
if (!email) {
return res.status(400).json({
success: false,
message: 'Email is required'
});
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return res.status(400).json({
success: false,
message: 'Invalid email format'
});
}
const result = await sendEmail(email);
res.status(200).json({
success: true,
message: 'Verification code sent successfully',
data: {
email: result.email,
}
});
} catch (error) {
console.error('Error in /send route:', error);
res.status(500).json({
success: false,
message: 'Failed to send verification code',
error: process.env.NODE_ENV === 'development' ? error.message : undefined
});
}
});
// POST /api/v1/users/signup
router.post('/signup', async (req, res) => {
try {
const { email, username, verificationCode } = req.body;
if (!email || !username || !verificationCode) {
return res.status(400).json({
success: false,
message: 'Email, username, and verification code are required'
});
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return res.status(400).json({
success: false,
message: 'Invalid email format'
});
}
if (username.length > 100) {
return res.status(400).json({
success: false,
message: 'Username must be 100 characters or less'
});
}
const codeValid = await checkEmail(email, verificationCode);
if (!codeValid) {
return res.status(400).json({
success: false,
message: 'Invalid or expired verification code'
});
}
const existingUser = await pg('users')
.where('email', email)
.orWhere('username', username)
.first();
if (existingUser) {
return res.status(409).json({
success: false,
message: existingUser.email === email ? 'Email already registered' : 'Username already taken'
});
}
const authToken = randomToken();
const [newUser] = await pg('users')
.insert({
email,
username,
authorization: authToken
})
.returning(['id', 'email', 'username', 'authorization']);
res.status(201).json({
success: true,
message: 'User created successfully',
data: {
id: newUser.id,
email: newUser.email,
username: newUser.username,
authorization: newUser.authorization,
}
});
} catch (error) {
console.error('Error in /signup route:', error);
if (error.code === '23505') {
return res.status(409).json({
success: false,
message: 'Email or username already exists'
});
}
res.status(500).json({
success: false,
message: 'Failed to create user account',
error: process.env.NODE_ENV === 'development' ? error.message : undefined
});
}
});
// POST /api/v1/users/login
router.post('/login', async (req, res) => {
try {
const { email, code } = req.body;
if (!email || !code) {
return res.status(400).json({
success: false,
message: 'Email and verification code are required'
});
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return res.status(400).json({
success: false,
message: 'Invalid email format'
});
}
const codeValid = await checkEmail(email, code);
if (!codeValid) {
return res.status(400).json({
success: false,
message: 'Invalid or expired verification code'
});
}
const user = await pg('users')
.where('email', email)
.first();
if (!user) {
return res.status(404).json({
success: false,
message: 'User not found. Please sign up first.'
});
}
const newAuthToken = randomToken();
const [updatedUser] = await pg('users')
.where('email', email)
.update({ authorization: newAuthToken })
.returning(['email', 'username', 'authorization']);
res.status(200).json({
success: true,
message: 'Login successful',
data: {
email: updatedUser.email,
username: updatedUser.username,
authorization: updatedUser.authorization,
}
});
} catch (error) {
console.error('Error in /login route:', error);
res.status(500).json({
success: false,
message: 'Failed to login',
error: process.env.NODE_ENV === 'development' ? error.message : undefined
});
}
});
// POST /api/v1/users/signout
router.post('/signout', async (req, res) => {
try {
const { authorization } = req.body;
if (!authorization) {
return res.status(400).json({
success: false,
message: 'Authorization token is required'
});
}
const user = await pg('users')
.where('authorization', authorization)
.first();
if (!user) {
return res.status(404).json({
success: false,
message: 'Invalid authorization token'
});
}
const newAuthToken = randomToken();
const [updatedUser] = await pg('users')
.where('authorization', authorization)
.update({ authorization: newAuthToken })
.returning(['email']);
res.status(200).json({
success: true,
message: 'Sign out successful',
data: {
email: updatedUser.email,
}
});
} catch (error) {
console.error('Error in /signout route:', error);
res.status(500).json({
success: false,
message: 'Failed to sign out',
error: process.env.NODE_ENV === 'development' ? error.message : undefined
});
}
});
export default router;

34
src/app.js Normal file
View file

@ -0,0 +1,34 @@
import express from 'express';
import cors from 'cors';
import path from 'path';
import { fileURLToPath } from 'url';
const app = express();
const __dirname = path.dirname(fileURLToPath(import.meta.url));
import api from './api/index.js';
import { notFound, errorHandler } from './middlewares/errors.middleware.js';
if (process.env.NODE_ENV === 'production') {
app.use(express.static('/client/public'));
app.get('*', (req, res) => {
res.sendFile(path.resolve(__dirname, 'client', 'public', 'index.html'));
});
}
app.use(express.urlencoded({ extended: true }));
app.use(express.json());
app.use(cors());
app.get('/', (req, res) => {
res.status(200).json({
message: 'API is located at /api/v1',
});
});
app.use('/api/v1', api);
app.use(notFound);
app.use(errorHandler);
export default app;

8
src/index.js Normal file
View file

@ -0,0 +1,8 @@
import app from './app.js';
const port = process.env.PORT || 5678;
app.listen(port, () => {
console.log(`Server is up at port http://localhost:${port}`);
});

View file

@ -0,0 +1,21 @@
function notFound(req, res, next) {
res.status(404);
const error = new Error(`🔍 - Not Found - ${req.originalUrl}`);
next(error);
}
/* eslint-disable no-unused-vars */
function errorHandler(err, req, res, next) {
/* eslint-enable no-unused-vars */
const statusCode = res.statusCode !== 200 ? res.statusCode : 500;
res.status(statusCode);
res.json({
message: err.message,
stack: process.env.NODE_ENV === 'production' ? '🥞' : err.stack,
});
}
export {
notFound,
errorHandler,
};

144
src/utils/airtable.js Normal file
View file

@ -0,0 +1,144 @@
import Airtable from 'airtable';
import dotenv from 'dotenv';
dotenv.config();
const airtable = new Airtable({
apiKey: process.env.AIRTABLE_API_KEY,
});
const base = airtable.base(process.env.AIRTABLE_BASE_ID);
export const getAllRecords = async (tableName) => {
try {
const records = await base(tableName).select().all();
return records.map(record => ({
id: record.id,
fields: record.fields,
createdTime: record._rawJson.createdTime
}));
} catch (error) {
console.error(`Error fetching records from ${tableName}:`, error);
throw error;
}
};
export const createRecord = async (tableName, fields) => {
try {
const record = await base(tableName).create(fields);
return {
id: record.id,
fields: record.fields,
createdTime: record._rawJson.createdTime
};
} catch (error) {
console.error(`Error creating record in ${tableName}:`, error);
throw error;
}
};
export const updateRecord = async (tableName, recordId, fields) => {
try {
const record = await base(tableName).update(recordId, fields);
return {
id: record.id,
fields: record.fields,
createdTime: record._rawJson.createdTime
};
} catch (error) {
console.error(`Error updating record in ${tableName}:`, error);
throw error;
}
};
export const deleteRecord = async (tableName, recordId) => {
try {
const deletedRecord = await base(tableName).destroy(recordId);
return { id: deletedRecord.id, deleted: true };
} catch (error) {
console.error(`Error deleting record from ${tableName}:`, error);
throw error;
}
};
export const findRecords = async (tableName, filterFormula = '', sort = []) => {
try {
const records = await base(tableName).select({
filterByFormula: filterFormula,
sort: sort
}).all();
return records.map(record => ({
id: record.id,
fields: record.fields,
createdTime: record._rawJson.createdTime
}));
} catch (error) {
console.error(`Error finding records in ${tableName}:`, error);
throw error;
}
};
const genCode = () => {
return Math.floor(100000 + Math.random() * 900000);
};
export const sendEmail = async (email) => {
try {
const code = genCode();
const record = await createRecord('Spaces Emails', {
email: email,
code: code
});
console.log(`Email verification code ${code} created for ${email}`);
return {
success: true,
recordId: record.id,
code: code,
email: email
};
} catch (error) {
console.error(`Error sending email verification for ${email}:`, error);
throw error;
}
};
export const checkEmail = async (email, codeToCheck) => {
try {
const filterFormula = `{email} = "${email}"`;
const records = await findRecords('Spaces Emails', filterFormula, [
{ field: 'Time Created', direction: 'desc' }
]);
if (records.length === 0) {
console.log(`No verification codes found for ${email}.`);
return false;
}
const latestRecord = records[0];
if (latestRecord.fields.code !== codeToCheck) {
console.log(`Verification code ${latestRecord.fields.code} for ${email} does not match most recent one ${codeToCheck}.`);
return false;
}
const recordCreatedTime = new Date(latestRecord.createdTime);
const currentTime = new Date();
const timeDifferenceMinutes = (currentTime - recordCreatedTime) / (1000 * 60);
if (timeDifferenceMinutes > 5) {
console.log(`Verification code for ${email} has expired.`);
return false;
}
return true;
} catch (error) {
console.error(`Error checking email verification for ${email}:`, error);
throw error;
}
};
export { base };
export default airtable;

16
src/utils/db.js Normal file
View file

@ -0,0 +1,16 @@
import knex from 'knex';
import dotenv from 'dotenv';
dotenv.config();
const pg = knex({
client: 'pg',
connection: process.env.PG_CONNECTION_STRING,
searchPath: ['knex', 'public'],
});
console.log("Connected to PostgreSQL database");
console.log(`Using connection string: ${process.env.PG_CONNECTION_STRING}`);
export default pg;

295
src/utils/spaces.js Normal file
View file

@ -0,0 +1,295 @@
import Docker from "dockerode";
import getPort from "get-port";
import pg from "./db.js";
import { getUser } from "./user.js";
const docker = new Docker();
const containerConfigs = {
"code-server": {
image: "linuxserver/code-server",
port: "8443/tcp",
env: (password) => [`PASSWORD=${password}`],
description: "VS Code Server"
},
"blender": {
image: "linuxserver/blender",
port: "3000/tcp",
env: (password) => [`PASSWORD=${password}`],
description: "Blender 3D"
},
"kicad": {
image: "linuxserver/kicad",
port: "3001/tcp",
env: (password) => [`PASSWORD=${password}`],
description: "KiCad PCB Design"
}
};
export const createContainer = async (password, type, authorization) => {
if (!password) {
throw new Error("Missing container password");
}
if (!type) {
throw new Error("Missing container type");
}
if (!authorization) {
throw new Error("Missing authorization token");
}
const user = await getUser(authorization);
if (!user) {
throw new Error("Invalid authorization token");
}
const config = containerConfigs[type.toLowerCase()];
if (!config) {
const error = new Error("Invalid container type");
error.validTypes = Object.keys(containerConfigs);
throw error;
}
try {
const port = await getPort();
const container = await docker.createContainer({
Image: config.image,
Env: config.env(password),
ExposedPorts: { [config.port]: {} },
HostConfig: {
PortBindings: { [config.port]: [{ HostPort: `${port}` }] },
},
});
await container.start();
const [newSpace] = await pg('spaces')
.insert({
user_id: user.id,
container_id: container.id,
type: type.toLowerCase(),
description: config.description,
image: config.image,
port,
access_url: `${process.env.SERVER_URL}:${port}`
})
.returning(['id', 'container_id', 'type', 'description', 'image', 'port', 'access_url']);
return {
message: "Container created successfully",
spaceId: newSpace.id,
containerId: newSpace.container_id,
type: newSpace.type,
description: newSpace.description,
image: newSpace.image,
port: newSpace.port,
accessUrl: newSpace.access_url
};
} catch (err) {
console.error("Docker error:", err);
throw new Error("Failed to create container");
}
};
export const startContainer = async (spaceId, authorization) => {
if (!spaceId) {
throw new Error("Space ID is required");
}
if (!authorization) {
throw new Error("Missing authorization token");
}
const user = await getUser(authorization);
if (!user) {
throw new Error("Invalid authorization token");
}
try {
const space = await pg('spaces')
.where('id', spaceId)
.where('user_id', user.id)
.first();
if (!space) {
const error = new Error("Space not found or not owned by user");
error.statusCode = 404;
throw error;
}
const container = docker.getContainer(space.container_id);
await container.inspect();
await container.start();
return {
message: "Container started successfully",
spaceId: space.id,
containerId: space.container_id,
};
} catch (err) {
console.error("Docker error:", err);
if (err.statusCode === 404) {
const error = new Error(err.message || "Container not found");
error.statusCode = 404;
throw error;
}
if (err.statusCode === 304) {
const error = new Error("Container is already running");
error.statusCode = 400;
throw error;
}
throw new Error("Failed to start container");
}
};
export const stopContainer = async (spaceId, authorization) => {
if (!spaceId) {
throw new Error("Space ID is required");
}
if (!authorization) {
throw new Error("Missing authorization token");
}
const user = await getUser(authorization);
if (!user) {
throw new Error("Invalid authorization token");
}
try {
const space = await pg('spaces')
.where('id', spaceId)
.where('user_id', user.id)
.first();
if (!space) {
const error = new Error("Space not found or not owned by user");
error.statusCode = 404;
throw error;
}
const container = docker.getContainer(space.container_id);
await container.inspect();
await container.stop();
return {
message: "Container stopped successfully",
spaceId: space.id,
containerId: space.container_id,
};
} catch (err) {
console.error("Docker error:", err);
if (err.statusCode === 404) {
const error = new Error(err.message || "Container not found");
error.statusCode = 404;
throw error;
}
if (err.statusCode === 304) {
const error = new Error("Container is already stopped");
error.statusCode = 400;
throw error;
}
throw new Error("Failed to stop container");
}
};
export const getContainerStatus = async (spaceId, authorization) => {
if (!spaceId) {
throw new Error("Space ID is required");
}
if (!authorization) {
throw new Error("Missing authorization token");
}
const user = await getUser(authorization);
if (!user) {
throw new Error("Invalid authorization token");
}
try {
const space = await pg('spaces')
.where('id', spaceId)
.where('user_id', user.id)
.first();
if (!space) {
const error = new Error("Space not found or not owned by user");
error.statusCode = 404;
throw error;
}
const container = docker.getContainer(space.container_id);
const info = await container.inspect();
return {
spaceId: space.id,
containerId: space.container_id,
type: space.type,
description: space.description,
accessUrl: space.access_url,
status: info.State.Status,
running: info.State.Running,
startedAt: info.State.StartedAt,
finishedAt: info.State.FinishedAt,
};
} catch (err) {
console.error("Docker error:", err);
if (err.statusCode === 404) {
const error = new Error(err.message || "Container not found");
error.statusCode = 404;
throw error;
}
throw new Error("Failed to get container status");
}
};
export const getUserSpaces = async (authorization) => {
if (!authorization) {
throw new Error("Missing authorization token");
}
const user = await getUser(authorization);
if (!user) {
throw new Error("Invalid authorization token");
}
try {
const spaces = await pg('spaces')
.where('user_id', user.id)
.select(['id', 'type', 'description', 'image', 'port', 'access_url', 'created_at']);
return spaces;
} catch (err) {
console.error("Database error:", err);
throw new Error("Failed to get user spaces");
}
};
// Get spaces by user ID directly
export const getSpacesByUserId = async (userId) => {
if (!userId) {
throw new Error("User ID is required");
}
try {
const spaces = await pg('spaces')
.where('user_id', userId)
.select(['id', 'container_id', 'type', 'description', 'image', 'port', 'access_url', 'created_at', 'updated_at']);
return spaces;
} catch (err) {
console.error("Database error:", err);
throw new Error("Failed to get spaces for user");
}
};

16
src/utils/user.js Normal file
View file

@ -0,0 +1,16 @@
import airtable from './airtable.js';
import pg from './db.js';
export const getUser = async (authorization) => {
try {
const user = await pg('users')
.where('authorization', authorization)
.first();
return user;
} catch (error) {
console.error('Error fetching user:', error);
throw error;
}
};

11
start.sh Normal file
View file

@ -0,0 +1,11 @@
#!/bin/bash
# Check if Docker is available (from host system)
if docker info >/dev/null 2>&1; then
echo "Docker is available from host system!"
else
echo "Warning: Docker not available. Make sure Docker socket is mounted."
fi
# Start all services with supervisor
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf