a lot of shit

This commit is contained in:
Charmunks 2025-11-06 21:09:14 -05:00
parent ace70841b0
commit 750174bc6f
22 changed files with 1005 additions and 142 deletions

View file

@ -44,6 +44,9 @@ RUN cd client && npm install
# Copy application code
COPY . .
# Build frontend
RUN cd client && npm run build
# Copy nginx configuration
COPY nginx.conf /etc/nginx/nginx.conf
@ -55,7 +58,7 @@ nodaemon=true
user=root
[program:dockerd]
command=dockerd --host=unix:///var/run/docker.sock --host=tcp://0.0.0.0:2376 --storage-driver=overlay2 --iptables=true --ip-forward=true --ip-masq=true
command=dockerd --host=unix:///var/run/docker.sock --storage-driver=overlay2 --exec-opt native.cgroupdriver=cgroupfs --iptables=true --ip-forward=true --ip-masq=true
autostart=true
autorestart=true
stderr_logfile=/var/log/supervisor/dockerd.err.log
@ -71,14 +74,14 @@ 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
[program:backend]
command=npm run serve:server
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
stderr_logfile=/var/log/supervisor/backend.err.log
stdout_logfile=/var/log/supervisor/backend.out.log
environment=NODE_ENV=production
priority=200
[program:nginx]
@ -95,10 +98,10 @@ COPY start.sh /app/start.sh
RUN sed -i 's/\r$//' /app/start.sh && chmod +x /app/start.sh
# Expose ports
EXPOSE 80 3000 5173
EXPOSE 80 3000
# Set environment variables
ENV NODE_ENV=development
ENV NODE_ENV=production
ENV DOCKER_HOST=unix:///var/run/docker.sock
ENV DOCKER_TLS_CERTDIR=""

View file

@ -2,11 +2,13 @@
import { onMount } from 'svelte';
import Auth from './lib/Auth.svelte';
import Dashboard from './lib/Dashboard.svelte';
import AdminPanel from './lib/AdminPanel.svelte';
import { API_BASE } from './config.js';
let isAuthenticated = false;
let user = null;
let spaces = [];
let showAdminPanel = false;
onMount(() => {
const storedAuth = localStorage.getItem('auth_token');
@ -25,12 +27,13 @@
});
function handleAuthenticated(event) {
const { authorization, username, email } = event.detail;
const { authorization, username, email, is_admin } = event.detail;
user = {
authorization,
username,
email
email,
is_admin
};
localStorage.setItem('auth_token', authorization);
@ -86,12 +89,24 @@
<main>
{#if isAuthenticated && user}
<Dashboard
bind:spaces={spaces}
authorization={user.authorization}
username={user.username}
on:signout={handleSignOut}
/>
{#if showAdminPanel && user.is_admin}
<div class="admin-header">
<button on:click={() => showAdminPanel = false}>Back to Dashboard</button>
</div>
<AdminPanel authorization={user.authorization} />
{:else}
{#if user.is_admin}
<div class="admin-link">
<button on:click={() => showAdminPanel = true}>Admin Panel</button>
</div>
{/if}
<Dashboard
bind:spaces={spaces}
authorization={user.authorization}
username={user.username}
on:signout={handleSignOut}
/>
{/if}
{:else}
<Auth on:authenticated={handleAuthenticated} />
{/if}
@ -102,4 +117,23 @@
width: 100%;
min-height: 100vh;
}
.admin-header, .admin-link {
padding: 10px 20px;
background-color: #f8f9fa;
border-bottom: 1px solid #ddd;
}
.admin-header button, .admin-link button {
padding: 8px 16px;
background-color: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.admin-header button:hover, .admin-link button:hover {
background-color: #0056b3;
}
</style>

View file

@ -1,4 +1,4 @@
export const API_BASE = 'https://t0080w08wcockgs44ws8w880.b.selfhosted.hackclub.com/api/v1';
export const API_BASE = 'http://localhost:2593/api/v1';
export const ERROR_MESSAGES = {
NETWORK_ERROR: 'Network error. Please try again.',

View file

@ -0,0 +1,348 @@
<script>
import { onMount } from 'svelte';
import { API_BASE } from '../config.js';
export let authorization;
let analytics = { totalUsers: 0, totalSpaces: 0, activeSpaces: 0 };
let users = [];
let spaces = [];
let loading = false;
let activeTab = 'analytics';
onMount(() => {
loadData();
});
async function loadData() {
loading = true;
await Promise.all([
loadAnalytics(),
loadUsers(),
loadSpaces()
]);
loading = false;
}
async function loadAnalytics() {
try {
const response = await fetch(`${API_BASE}/admin/analytics`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ authorization })
});
const data = await response.json();
if (response.ok) {
analytics = data.data;
}
} catch (err) {
console.error('Failed to load analytics:', err);
}
}
async function loadUsers() {
try {
const response = await fetch(`${API_BASE}/admin/users`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ authorization })
});
const data = await response.json();
if (response.ok) {
users = data.data;
}
} catch (err) {
console.error('Failed to load users:', err);
}
}
async function loadSpaces() {
try {
const response = await fetch(`${API_BASE}/admin/spaces`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ authorization })
});
const data = await response.json();
if (response.ok) {
spaces = data.data;
}
} catch (err) {
console.error('Failed to load spaces:', err);
}
}
async function updateUser(userId, updates) {
try {
const response = await fetch(`${API_BASE}/admin/users/${userId}/update`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ authorization, ...updates })
});
if (response.ok) {
await loadUsers();
await loadAnalytics();
}
} catch (err) {
console.error('Failed to update user:', err);
}
}
async function deleteUser(userId) {
if (!confirm('Are you sure you want to delete this user and all their spaces?')) return;
try {
const response = await fetch(`${API_BASE}/admin/users/${userId}/delete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ authorization })
});
if (response.ok) {
await loadData();
}
} catch (err) {
console.error('Failed to delete user:', err);
}
}
async function deleteSpace(spaceId) {
if (!confirm('Are you sure you want to delete this space?')) return;
try {
const response = await fetch(`${API_BASE}/admin/spaces/${spaceId}/delete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ authorization })
});
if (response.ok) {
await loadData();
}
} catch (err) {
console.error('Failed to delete space:', err);
}
}
function handleMaxSpacesChange(userId, newValue) {
const value = parseInt(newValue);
if (!isNaN(value) && value >= 0) {
updateUser(userId, { max_spaces: value });
}
}
function toggleAdmin(userId, currentValue) {
updateUser(userId, { is_admin: !currentValue });
}
</script>
<div class="admin-panel">
<h1>Admin Panel</h1>
{#if loading}
<p>Loading...</p>
{:else}
<div class="tabs">
<button class:active={activeTab === 'analytics'} on:click={() => activeTab = 'analytics'}>Analytics</button>
<button class:active={activeTab === 'users'} on:click={() => activeTab = 'users'}>Users</button>
<button class:active={activeTab === 'spaces'} on:click={() => activeTab = 'spaces'}>Spaces</button>
</div>
{#if activeTab === 'analytics'}
<div class="analytics">
<div class="stat-card">
<h3>Total Users</h3>
<p class="stat">{analytics.totalUsers}</p>
</div>
<div class="stat-card">
<h3>Total Spaces</h3>
<p class="stat">{analytics.totalSpaces}</p>
</div>
<div class="stat-card">
<h3>Active Spaces</h3>
<p class="stat">{analytics.activeSpaces}</p>
</div>
</div>
{/if}
{#if activeTab === 'users'}
<div class="table-container">
<table>
<thead>
<tr>
<th>ID</th>
<th>Email</th>
<th>Username</th>
<th>Spaces</th>
<th>Max Spaces</th>
<th>Admin</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{#each users as user}
<tr>
<td>{user.id}</td>
<td>{user.email}</td>
<td>{user.username}</td>
<td>{user.spaceCount}</td>
<td>
<input
type="number"
value={user.max_spaces}
on:change={(e) => handleMaxSpacesChange(user.id, e.target.value)}
min="0"
/>
</td>
<td>
<input
type="checkbox"
checked={user.is_admin}
on:change={() => toggleAdmin(user.id, user.is_admin)}
/>
</td>
<td>
<button on:click={() => deleteUser(user.id)} class="delete-btn">Delete</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
{#if activeTab === 'spaces'}
<div class="table-container">
<table>
<thead>
<tr>
<th>ID</th>
<th>Type</th>
<th>Owner</th>
<th>Email</th>
<th>Status</th>
<th>Port</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{#each spaces as space}
<tr>
<td>{space.id}</td>
<td>{space.type}</td>
<td>{space.username}</td>
<td>{space.email}</td>
<td>{space.running ? 'Running' : 'Stopped'}</td>
<td>{space.port}</td>
<td>
<button on:click={() => deleteSpace(space.id)} class="delete-btn">Delete</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
{/if}
</div>
<style>
.admin-panel {
padding: 20px;
max-width: 1200px;
margin: 0 auto;
}
h1 {
margin-bottom: 30px;
}
.tabs {
display: flex;
gap: 10px;
margin-bottom: 20px;
border-bottom: 2px solid #ddd;
}
.tabs button {
padding: 10px 20px;
background: none;
border: none;
cursor: pointer;
font-size: 16px;
border-bottom: 3px solid transparent;
}
.tabs button.active {
border-bottom-color: #007bff;
font-weight: bold;
}
.analytics {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
}
.stat-card {
padding: 20px;
border: 1px solid #ddd;
border-radius: 8px;
text-align: center;
}
.stat-card h3 {
margin: 0 0 10px 0;
font-size: 14px;
color: #666;
}
.stat {
font-size: 32px;
font-weight: bold;
margin: 0;
}
.table-container {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 10px;
}
th, td {
padding: 10px;
text-align: left;
border-bottom: 1px solid #ddd;
}
th {
background-color: #f5f5f5;
font-weight: bold;
}
input[type="number"] {
width: 60px;
padding: 5px;
border: 1px solid #ddd;
border-radius: 4px;
}
input[type="checkbox"] {
cursor: pointer;
}
.delete-btn {
padding: 5px 10px;
background-color: #dc3545;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.delete-btn:hover {
background-color: #c82333;
}
</style>

View file

@ -1,6 +1,7 @@
<script>
import { createEventDispatcher } from 'svelte';
import { API_BASE, ERROR_MESSAGES } from '../config.js';
import '../styles/auth.css';
const dispatch = createEventDispatcher();
@ -62,7 +63,8 @@
dispatch('authenticated', {
authorization: data.data.authorization,
username: data.data.username,
email: data.data.email
email: data.data.email,
is_admin: data.data.is_admin
});
} else {
error = data.message || 'Login failed';
@ -93,7 +95,8 @@
dispatch('authenticated', {
authorization: data.data.authorization,
username: data.data.username,
email: data.data.email
email: data.data.email,
is_admin: data.data.is_admin
});
} else {
error = data.message || 'Signup failed';
@ -119,9 +122,7 @@
}
</script>
<svelte:head>
<link rel="stylesheet" href="/src/styles/auth.css" />
</svelte:head>
<a href="https://hackclub.com/">
<img class="flag-banner" src="https://assets.hackclub.com/flag-orpheus-top.svg" alt="Hack Club"/>

View file

@ -1,6 +1,7 @@
<script>
import { createEventDispatcher } from 'svelte';
import { API_BASE, ERROR_MESSAGES } from '../config.js';
import '../styles/dashboard.css';
export let spaces = [];
export let authorization = '';
@ -29,16 +30,18 @@
loading = true;
try {
const body = { type: newSpaceType };
if (newSpaceType !== 'kicad' && newSpaceType !== 'blender') {
body.password = newSpacePassword;
}
const response = await fetch(`${API_BASE}/spaces/create`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': authorization,
},
body: JSON.stringify({
password: newSpacePassword,
type: newSpaceType
}),
body: JSON.stringify(body),
});
const data = await response.json();
@ -85,8 +88,6 @@
actionLoading = actionLoading;
actionError = actionError;
const space = spaces.find(s => s.id === spaceId);
try {
const response = await fetch(`${API_BASE}/spaces/start/${spaceId}`, {
method: 'POST',
@ -100,7 +101,11 @@
if (response.ok) {
await loadSpaces();
} else {
actionError[spaceId] = data.error || ERROR_MESSAGES.START_FAILED;
if (data.error?.includes('only have one space running')) {
actionError[spaceId] = data.error;
} else {
actionError[spaceId] = data.error || ERROR_MESSAGES.START_FAILED;
}
actionError = actionError;
}
} catch (err) {
@ -109,10 +114,6 @@
} finally {
delete actionLoading[spaceId];
actionLoading = actionLoading;
if (space && space.access_url) {
window.location.href = space.access_url;
}
}
}
@ -241,10 +242,6 @@
}
</script>
<svelte:head>
<link rel="stylesheet" href="/src/styles/dashboard.css" />
</svelte:head>
<div class="dashboard">
<header class="dashboard-header">
<div class="header-content">
@ -302,46 +299,52 @@
</div>
</div>
<div class="form-group">
<label class="form-label" for="password">Password</label>
<div class="password-input-wrapper">
{#if showPassword}
<input
class="form-input password-input"
id="password"
type="text"
bind:value={newSpacePassword}
required
placeholder="Set a password for this space"
/>
{:else}
<input
class="form-input password-input"
id="password"
type="password"
bind:value={newSpacePassword}
required
placeholder="Set a password for this space"
/>
{/if}
<button
type="button"
class="password-toggle"
on:click={togglePasswordVisibility}
aria-label={showPassword ? 'Hide password' : 'Show password'}
>
{#if newSpaceType !== 'kicad' && newSpaceType !== 'blender'}
<div class="form-group">
<label class="form-label" for="password">Password</label>
<p class="password-info">This password will be needed to access the space. Please pick a secure password, you cannot change it later.</p>
<div class="password-input-wrapper">
{#if showPassword}
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 256 256">
<path d="M53.92,34.62A8,8,0,1,0,42.08,45.38L61.32,66.55C25,88.84,9.38,123.2,8.69,124.76a8,8,0,0,0,0,6.5c.35.79,8.82,19.57,27.65,38.4C61.43,194.74,93.12,208,128,208a127.11,127.11,0,0,0,52.07-10.83l22,24.21a8,8,0,1,0,11.84-10.76Zm47.33,75.84,41.67,45.85a32,32,0,0,1-41.67-45.85ZM128,192c-30.78,0-57.67-11.19-79.93-33.25A133.16,133.16,0,0,1,25,128c4.69-8.79,19.66-33.39,47.35-49.38l18,19.75a48,48,0,0,0,63.66,70l14.73,16.2A112,112,0,0,1,128,192Zm6-95.43a8,8,0,0,1,3-15.72,48.16,48.16,0,0,1,38.77,42.64,8,8,0,0,1-7.22,8.71,6.39,6.39,0,0,1-.75,0,8,8,0,0,1-8-7.26A32.09,32.09,0,0,0,134,96.57Zm113.28,34.69c-.42.94-10.55,23.37-33.36,43.8a8,8,0,1,1-10.67-11.92A132.77,132.77,0,0,0,231.05,128a133.15,133.15,0,0,0-23.12-30.77C185.67,75.19,158.78,64,128,64a118.37,118.37,0,0,0-19.36,1.57A8,8,0,1,1,106,49.79,134,134,0,0,1,128,48c34.88,0,66.57,13.26,91.66,38.35,18.83,18.83,27.3,37.62,27.65,38.41A8,8,0,0,1,247.31,131.26Z"></path>
</svg>
<input
class="form-input password-input"
id="password"
type="text"
bind:value={newSpacePassword}
required
placeholder="Set a password for this space"
/>
{:else}
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 256 256">
<path d="M247.31,124.76c-.35-.79-8.82-19.58-27.65-38.41C194.57,61.26,162.88,48,128,48S61.43,61.26,36.34,86.35C17.51,105.18,9,124,8.69,124.76a8,8,0,0,0,0,6.5c.35.79,8.82,19.57,27.65,38.4C61.43,194.74,93.12,208,128,208s66.57-13.26,91.66-38.34c18.83-18.83,27.3-37.61,27.65-38.4A8,8,0,0,0,247.31,124.76ZM128,192c-30.78,0-57.67-11.19-79.93-33.25A133.47,133.47,0,0,1,25,128,133.33,133.33,0,0,1,48.07,97.25C70.33,75.19,97.22,64,128,64s57.67,11.19,79.93,33.25A133.46,133.46,0,0,1,231.05,128C223.84,141.46,192.43,192,128,192Zm0-112a48,48,0,1,0,48,48A48.05,48.05,0,0,0,128,80Zm0,80a32,32,0,1,1,32-32A32,32,0,0,1,128,160Z"></path>
</svg>
<input
class="form-input password-input"
id="password"
type="password"
bind:value={newSpacePassword}
required
placeholder="Set a password for this space"
/>
{/if}
</button>
<button
type="button"
class="password-toggle"
on:click={togglePasswordVisibility}
aria-label={showPassword ? 'Hide password' : 'Show password'}
>
{#if showPassword}
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 256 256">
<path d="M53.92,34.62A8,8,0,1,0,42.08,45.38L61.32,66.55C25,88.84,9.38,123.2,8.69,124.76a8,8,0,0,0,0,6.5c.35.79,8.82,19.57,27.65,38.4C61.43,194.74,93.12,208,128,208a127.11,127.11,0,0,0,52.07-10.83l22,24.21a8,8,0,1,0,11.84-10.76Zm47.33,75.84,41.67,45.85a32,32,0,0,1-41.67-45.85ZM128,192c-30.78,0-57.67-11.19-79.93-33.25A133.16,133.16,0,0,1,25,128c4.69-8.79,19.66-33.39,47.35-49.38l18,19.75a48,48,0,0,0,63.66,70l14.73,16.2A112,112,0,0,1,128,192Zm6-95.43a8,8,0,0,1,3-15.72,48.16,48.16,0,0,1,38.77,42.64,8,8,0,0,1-7.22,8.71,6.39,6.39,0,0,1-.75,0,8,8,0,0,1-8-7.26A32.09,32.09,0,0,0,134,96.57Zm113.28,34.69c-.42.94-10.55,23.37-33.36,43.8a8,8,0,1,1-10.67-11.92A132.77,132.77,0,0,0,231.05,128a133.15,133.15,0,0,0-23.12-30.77C185.67,75.19,158.78,64,128,64a118.37,118.37,0,0,0-19.36,1.57A8,8,0,1,1,106,49.79A134,134,0,0,1,128,48c34.88,0,66.57,13.26,91.66,38.35,18.83,18.83,27.3,37.62,27.65,38.41A8,8,0,0,1,247.31,131.26Z"></path>
</svg>
{:else}
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 256 256">
<path d="M247.31,124.76c-.35-.79-8.82-19.58-27.65-38.41C194.57,61.26,162.88,48,128,48S61.43,61.26,36.34,86.35C17.51,105.18,9,124,8.69,124.76a8,8,0,0,0,0,6.5c.35.79,8.82,19.57,27.65,38.4C61.43,194.74,93.12,208,128,208s66.57-13.26,91.66-38.34c18.83-18.83,27.3-37.61,27.65-38.4A8,8,0,0,0,247.31,124.76ZM128,192c-30.78,0-57.67-11.19-79.93-33.25A133.47,133.47,0,0,1,25,128,133.33,133.33,0,0,1,48.07,97.25C70.33,75.19,97.22,64,128,64s57.67,11.19,79.93,33.25A133.46,133.46,0,0,1,231.05,128C223.84,141.46,192.43,192,128,192Zm0-112a48,48,0,1,0,48,48A48.05,48.05,0,0,0,128,80Zm0,80a32,32,0,1,1,32-32A32,32,0,0,1,128,160Z"></path>
</svg>
{/if}
</button>
</div>
</div>
</div>
{:else}
<div class="form-group">
</div>
{/if}
{#if error}
<div class="error-message">{error}</div>
@ -350,7 +353,7 @@
<button
class="btn-primary"
on:click={createSpace}
disabled={loading || !newSpacePassword}
disabled={loading || (newSpaceType !== 'kicad' && newSpaceType !== 'blender' && !newSpacePassword)}
>
{loading ? 'Creating...' : 'Create Space'}
</button>
@ -377,13 +380,6 @@
<div class="space-info">
<p><strong>Space ID:</strong> {space.id}</p>
{#if space.url}
<p><strong>URL:</strong>
<a href={space.url} target="_blank" rel="noopener noreferrer">
{space.url}
</a>
</p>
{/if}
<p><strong>Created:</strong> {new Date(space.created_at).toLocaleString()}</p>
</div>
@ -397,16 +393,10 @@
{actionLoading[space.id]}...
</button>
{:else}
{#if space.status?.toLowerCase() === 'running'}
<button
class="action-btn stop"
on:click={() => stopSpace(space.id)}
>
Stop
</button>
{#if space.url}
{#if space.running || space.status?.toLowerCase() === 'running'}
{#if space.access_url}
<a
href={space.url}
href={space.access_url}
target="_blank"
rel="noopener noreferrer"
class="action-btn open"
@ -414,6 +404,12 @@
Open
</a>
{/if}
<button
class="action-btn stop"
on:click={() => stopSpace(space.id)}
>
Stop
</button>
{:else}
<button
class="action-btn start"

View file

@ -8,10 +8,12 @@ services:
- "2593:80" # Nginx proxy
volumes:
- docker-data:/var/lib/docker # Docker storage volume
- /sys/fs/cgroup:/sys/fs/cgroup:rw # Fix cgroupv2 support
environment:
- NODE_ENV=development
- DOCKER_HOST=unix:///var/run/docker.sock
- DOCKER_TLS_CERTDIR=""
restart: unless-stopped
cgroup: host # Use host cgroup namespace
volumes:
docker-data:

View file

@ -3,9 +3,8 @@ events {
}
http {
upstream frontend {
server localhost:5173;
}
include /etc/nginx/mime.types;
default_type application/octet-stream;
upstream api {
server localhost:3000;
@ -31,6 +30,12 @@ http {
set $port $1;
set $fullpath $uri;
set $target 127.0.0.1:$port;
# Block access to internal ports
if ($port ~ "^(2376|3000)$") {
return 403;
}
proxy_pass http://$target$fullpath$is_args$args;
proxy_set_header Host localhost:$port;
proxy_set_header X-Real-IP $remote_addr;
@ -56,12 +61,57 @@ http {
proxy_intercept_errors off;
}
# Port forwarding for KiCad - matches /kicad/space/8080, /kicad/space/3001, etc.
# Routes to localhost ports using HTTPS (for KiCad containers)
location ~ ^/kicad/space/(\d+)(/.*)?$ {
set $port $1;
set $path $2;
set $target 127.0.0.1:$port;
# Block access to internal ports
if ($port ~ "^(2376|3000)$") {
return 403;
}
proxy_pass https://$target$path$is_args$args;
proxy_set_header Host $http_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 $http_connection;
# Additional headers for better compatibility
proxy_buffering off;
proxy_read_timeout 86400;
# Handle authentication responses properly
proxy_intercept_errors off;
# SSL verification settings for self-signed certificates
proxy_ssl_verify off;
proxy_ssl_server_name off;
}
# Port forwarding - matches /space/8080, /space/3001, etc.
# Routes to localhost ports (containers running in Docker-in-Docker)
location ~ ^/space/(\d+)(/.*)?$ {
set $port $1;
set $path $2;
set $target 127.0.0.1:$port;
# Block access to internal ports
if ($port ~ "^(2376|3000)$") {
return 403;
}
proxy_pass http://$target$path$is_args$args;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
@ -85,17 +135,10 @@ http {
proxy_intercept_errors off;
}
# Frontend fallback - must be last
# Serve static frontend files - must be last
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";
root /app/client/dist;
try_files $uri $uri/ /index.html;
}
}

43
package-lock.json generated
View file

@ -16,6 +16,7 @@
"dockerode": "^4.0.9",
"dotenv": "^17.2.3",
"express": "^4.18.2",
"express-rate-limit": "^8.2.1",
"get-port": "^7.1.0",
"knex": "^3.1.0",
"pg": "^8.16.3"
@ -775,6 +776,7 @@
"version": "4.18.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz",
"integrity": "sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==",
"peer": true,
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
@ -812,6 +814,24 @@
"node": ">= 0.10.0"
}
},
"node_modules/express-rate-limit": {
"version": "8.2.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz",
"integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==",
"license": "MIT",
"dependencies": {
"ip-address": "10.0.1"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/express-rate-limit"
},
"peerDependencies": {
"express": ">= 4.11"
}
},
"node_modules/fill-range": {
"version": "7.0.1",
"dev": true,
@ -1029,6 +1049,15 @@
"node": ">= 0.10"
}
},
"node_modules/ip-address": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz",
"integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"license": "MIT",
@ -2690,6 +2719,7 @@
"version": "4.18.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz",
"integrity": "sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==",
"peer": true,
"requires": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
@ -2724,6 +2754,14 @@
"vary": "~1.1.2"
}
},
"express-rate-limit": {
"version": "8.2.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz",
"integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==",
"requires": {
"ip-address": "10.0.1"
}
},
"fill-range": {
"version": "7.0.1",
"dev": true,
@ -2851,6 +2889,11 @@
"resolved": "https://registry.npmjs.org/interpret/-/interpret-2.2.0.tgz",
"integrity": "sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw=="
},
"ip-address": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz",
"integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA=="
},
"ipaddr.js": {
"version": "1.9.1"
},

View file

@ -22,6 +22,7 @@
"dockerode": "^4.0.9",
"dotenv": "^17.2.3",
"express": "^4.18.2",
"express-rate-limit": "^8.2.1",
"get-port": "^7.1.0",
"knex": "^3.1.0",
"pg": "^8.16.3"

View file

@ -0,0 +1,158 @@
import express from 'express';
import pg from '../../utils/db.js';
import { requireAdmin } from '../../middlewares/admin.middleware.js';
import { deleteSpace } from '../../utils/spaces.js';
const router = express.Router();
router.post('/analytics', requireAdmin, async (req, res) => {
try {
const [userCount] = await pg('users').count('id as count');
const [spaceCount] = await pg('spaces').count('id as count');
const [activeSpaces] = await pg('spaces')
.where('running', true)
.count('id as count');
res.status(200).json({
success: true,
data: {
totalUsers: parseInt(userCount.count),
totalSpaces: parseInt(spaceCount.count),
activeSpaces: parseInt(activeSpaces.count)
}
});
} catch (error) {
console.error('Error fetching analytics:', error);
res.status(500).json({
success: false,
message: 'Failed to fetch analytics'
});
}
});
router.post('/users', requireAdmin, async (req, res) => {
try {
const users = await pg('users')
.select('id', 'email', 'username', 'max_spaces', 'is_admin')
.orderBy('id', 'desc');
const usersWithSpaces = await Promise.all(
users.map(async (user) => {
const [spaceCount] = await pg('spaces')
.where('user_id', user.id)
.count('id as count');
return {
...user,
spaceCount: parseInt(spaceCount.count)
};
})
);
res.status(200).json({
success: true,
data: usersWithSpaces
});
} catch (error) {
console.error('Error fetching users:', error);
res.status(500).json({
success: false,
message: 'Failed to fetch users'
});
}
});
router.post('/spaces', requireAdmin, async (req, res) => {
try {
const spaces = await pg('spaces')
.select('spaces.*', 'users.username', 'users.email')
.join('users', 'spaces.user_id', 'users.id')
.orderBy('spaces.id', 'desc');
res.status(200).json({
success: true,
data: spaces
});
} catch (error) {
console.error('Error fetching spaces:', error);
res.status(500).json({
success: false,
message: 'Failed to fetch spaces'
});
}
});
router.post('/users/:userId/update', requireAdmin, async (req, res) => {
try {
const { userId } = req.params;
const { max_spaces, is_admin } = req.body;
const updates = {};
if (max_spaces !== undefined) updates.max_spaces = max_spaces;
if (is_admin !== undefined) updates.is_admin = is_admin;
const [updatedUser] = await pg('users')
.where('id', userId)
.update(updates)
.returning(['id', 'email', 'username', 'max_spaces', 'is_admin']);
res.status(200).json({
success: true,
data: updatedUser
});
} catch (error) {
console.error('Error updating user:', error);
res.status(500).json({
success: false,
message: 'Failed to update user'
});
}
});
router.post('/users/:userId/delete', requireAdmin, async (req, res) => {
try {
const { userId } = req.params;
const userSpaces = await pg('spaces').where('user_id', userId);
for (const space of userSpaces) {
try {
await deleteSpace(space.id);
} catch (error) {
console.error(`Error deleting space ${space.id}:`, error);
}
}
await pg('users').where('id', userId).delete();
res.status(200).json({
success: true,
message: 'User and associated spaces deleted'
});
} catch (error) {
console.error('Error deleting user:', error);
res.status(500).json({
success: false,
message: 'Failed to delete user'
});
}
});
router.post('/spaces/:spaceId/delete', requireAdmin, async (req, res) => {
try {
const { spaceId } = req.params;
await deleteSpace(spaceId);
res.status(200).json({
success: true,
message: 'Space deleted successfully'
});
} catch (error) {
console.error('Error deleting space:', error);
res.status(500).json({
success: false,
message: 'Failed to delete space'
});
}
});
export default router;

View file

@ -1,6 +1,7 @@
import express from "express";
import spaces from './spaces/space.route.js';
import auth from './users/auth.route.js';
import admin from './admin/admin.route.js';
const router = express.Router();
@ -13,4 +14,5 @@ router.get('/', (req, res) => {
router.use('/spaces/', spaces);
router.use('/users/', auth);
router.use('/admin/', admin);
export default router;

View file

@ -7,10 +7,11 @@ import {
getUserSpaces,
deleteSpace
} from "../../utils/spaces.js";
import { containerOpsLimiter } from "../../middlewares/rate-limit.middleware.js";
const router = express.Router();
router.post("/create", async (req, res) => {
router.post("/create", containerOpsLimiter, async (req, res) => {
const { password, type } = req.body;
const authorization = req.headers.authorization;
@ -30,7 +31,7 @@ router.post("/create", async (req, res) => {
}
});
router.post("/start/:spaceId", async (req, res) => {
router.post("/start/:spaceId", containerOpsLimiter, async (req, res) => {
const { spaceId } = req.params;
const authorization = req.headers.authorization;
@ -43,7 +44,7 @@ router.post("/start/:spaceId", async (req, res) => {
}
});
router.post("/stop/:spaceId", async (req, res) => {
router.post("/stop/:spaceId", containerOpsLimiter, async (req, res) => {
const { spaceId } = req.params;
const authorization = req.headers.authorization;

View file

@ -2,6 +2,7 @@ import express from 'express';
import { sendEmail, checkEmail } from '../../utils/airtable.js';
import pg from '../../utils/db.js';
import crypto from 'crypto';
import { strictLimiter, authLimiter } from '../../middlewares/rate-limit.middleware.js';
const router = express.Router();
@ -16,7 +17,7 @@ router.get('/send', (req, res) => {
});
// POST /api/v1/users/send
router.post('/send', async (req, res) => {
router.post('/send', strictLimiter, async (req, res) => {
try {
const { email } = req.body;
@ -56,7 +57,7 @@ router.post('/send', async (req, res) => {
});
// POST /api/v1/users/signup
router.post('/signup', async (req, res) => {
router.post('/signup', authLimiter, async (req, res) => {
try {
const { email, username, verificationCode } = req.body;
@ -109,9 +110,10 @@ router.post('/signup', async (req, res) => {
email,
username,
authorization: authToken,
max_spaces: 3
max_spaces: 3,
is_admin: false
})
.returning(['id', 'email', 'username', 'authorization']);
.returning(['id', 'email', 'username', 'authorization', 'is_admin']);
res.status(201).json({
success: true,
@ -121,6 +123,7 @@ router.post('/signup', async (req, res) => {
email: newUser.email,
username: newUser.username,
authorization: newUser.authorization,
is_admin: newUser.is_admin
}
});
@ -143,7 +146,7 @@ router.post('/signup', async (req, res) => {
});
// POST /api/v1/users/login
router.post('/login', async (req, res) => {
router.post('/login', authLimiter, async (req, res) => {
try {
const { email, verificationCode } = req.body;
@ -186,7 +189,7 @@ router.post('/login', async (req, res) => {
const [updatedUser] = await pg('users')
.where('email', email)
.update({ authorization: newAuthToken })
.returning(['email', 'username', 'authorization']);
.returning(['email', 'username', 'authorization', 'is_admin']);
res.status(200).json({
success: true,
@ -195,6 +198,7 @@ router.post('/login', async (req, res) => {
email: updatedUser.email,
username: updatedUser.username,
authorization: updatedUser.authorization,
is_admin: updatedUser.is_admin
}
});

View file

@ -10,16 +10,13 @@ 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.use(cors({
origin: process.env.FRONTEND_URL || 'http://localhost:5173',
credentials: true,
optionsSuccessStatus: 200
}));
app.get('/', (req, res) => {
res.status(200).json({

View file

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

View file

@ -0,0 +1,40 @@
import pg from '../utils/db.js';
import { getUser } from '../utils/user.js';
export const requireAdmin = async (req, res, next) => {
try {
const { authorization } = req.body;
if (!authorization) {
return res.status(401).json({
success: false,
message: 'Authorization token required'
});
}
const user = await getUser(authorization);
if (!user) {
return res.status(401).json({
success: false,
message: 'Invalid authorization token'
});
}
if (!user.is_admin) {
return res.status(403).json({
success: false,
message: 'Admin access required'
});
}
req.user = user;
next();
} catch (error) {
console.error('Error in admin middleware:', error);
res.status(500).json({
success: false,
message: 'Authentication error'
});
}
};

View file

@ -0,0 +1,50 @@
import rateLimit from 'express-rate-limit';
export const strictLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 3,
standardHeaders: true,
legacyHeaders: false,
message: {
success: false,
message: 'Too many verification code requests. Please try again in 15 minutes.'
},
handler: (req, res) => {
res.status(429).json({
success: false,
message: 'Too many verification code requests. Please try again in 15 minutes.'
});
}
});
export const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5,
standardHeaders: true,
legacyHeaders: false,
message: {
success: false,
message: 'Too many authentication attempts. Please try again in 15 minutes.'
},
handler: (req, res) => {
res.status(429).json({
success: false,
message: 'Too many authentication attempts. Please try again in 15 minutes.'
});
}
});
export const containerOpsLimiter = rateLimit({
windowMs: 60 * 1000,
max: 20,
standardHeaders: true,
legacyHeaders: false,
message: {
error: 'Too many container operations. Please try again in 1 minute.'
},
handler: (req, res) => {
res.status(429).json({
error: 'Too many container operations. Please try again in 1 minute.'
});
}
});

View file

@ -92,7 +92,7 @@ export const sendEmail = async (email) => {
code: code
});
console.log(`Email verification code ${code} created for ${email}`);
console.log(`Email verification code created for ${email}`);
return {
success: true,
recordId: record.id,
@ -120,7 +120,7 @@ export const checkEmail = async (email, codeToCheck) => {
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}.`);
console.log(`Verification code for ${email} does not match.`);
return false;
}

62
src/utils/auto-stop.js Normal file
View file

@ -0,0 +1,62 @@
import Docker from "dockerode";
import pg from "./db.js";
const docker = new Docker();
const THREE_HOURS_MS = 3 * 60 * 60 * 1000;
export const stopExpiredSpaces = async () => {
try {
const threeHoursAgo = new Date(Date.now() - THREE_HOURS_MS);
const expiredSpaces = await pg('spaces')
.where('running', true)
.where('started_at', '<', threeHoursAgo)
.select(['id', 'container_id', 'user_id', 'type', 'started_at']);
if (expiredSpaces.length === 0) {
console.log('[Auto-stop] No expired spaces found');
return;
}
console.log(`[Auto-stop] Found ${expiredSpaces.length} expired space(s) to stop`);
for (const space of expiredSpaces) {
try {
const container = docker.getContainer(space.container_id);
await container.inspect();
await container.stop();
await pg('spaces')
.where('id', space.id)
.update({ running: false });
console.log(`[Auto-stop] Stopped space ${space.id} (container ${space.container_id}) - runtime exceeded 3 hours`);
} catch (err) {
if (err.statusCode === 304) {
await pg('spaces')
.where('id', space.id)
.update({ running: false });
console.log(`[Auto-stop] Space ${space.id} was already stopped, updated database status`);
} else {
console.error(`[Auto-stop] Failed to stop space ${space.id}:`, err.message);
}
}
}
} catch (err) {
console.error('[Auto-stop] Error in stopExpiredSpaces:', err);
}
};
export const startAutoStopJob = () => {
const INTERVAL_MS = 5 * 60 * 1000;
console.log('[Auto-stop] Starting auto-stop job - checking every 5 minutes for spaces running > 3 hours');
stopExpiredSpaces();
setInterval(() => {
stopExpiredSpaces();
}, INTERVAL_MS);
};

View file

@ -10,7 +10,6 @@ const pg = knex({
});
console.log("Connected to PostgreSQL database");
console.log(`Using connection string: ${process.env.PG_CONNECTION_STRING}`);
export default pg;

View file

@ -5,6 +5,7 @@ import { getUser, checkUserSpaceLimit } from "./user.js";
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import crypto from "crypto";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@ -36,10 +37,6 @@ const containerConfigs = {
};
export const createContainer = async (password, type, authorization) => {
if (!password) {
throw new Error("Missing container password");
}
if (!type) {
throw new Error("Missing container type");
}
@ -62,6 +59,13 @@ export const createContainer = async (password, type, authorization) => {
throw error;
}
const typeLower = type.toLowerCase();
if (typeLower === "kicad" || typeLower === "blender") {
password = crypto.randomBytes(16).toString('hex');
} else if (!password) {
throw new Error("Missing container password");
}
try {
const port = await getPort();
@ -74,7 +78,16 @@ export const createContainer = async (password, type, authorization) => {
NetworkMode: "bridge",
Dns: ["8.8.8.8", "8.8.4.4", "1.1.1.1"],
PublishAllPorts: false,
RestartPolicy: { Name: "unless-stopped" }
RestartPolicy: { Name: "unless-stopped" },
Memory: 2 * 1024 * 1024 * 1024,
MemorySwap: 2 * 1024 * 1024 * 1024,
NanoCpus: 2000000000,
CpuShares: 1024,
PidsLimit: 512,
SecurityOpt: ["no-new-privileges:true"],
ReadonlyRootfs: false,
CapDrop: ["ALL"],
CapAdd: ["CHOWN", "DAC_OVERRIDE", "FOWNER", "SETGID", "SETUID", "NET_BIND_SERVICE"]
},
});
@ -106,23 +119,42 @@ export const createContainer = async (password, type, authorization) => {
var access_url = `${process.env.SERVER_URL}:${port}`;
} else {
console.log("Docker environment detected, using standard access URL");
var access_url = `${process.env.SERVER_URL}/space/${port}/`;
if (type.toLowerCase() === 'kicad') {
var access_url = `${process.env.SERVER_URL}/kicad/space/${port}/`;
} else {
var access_url = `${process.env.SERVER_URL}/space/${port}/`;
}
}
if (typeLower === "kicad" || typeLower === "blender") {
const urlObj = new URL(access_url);
urlObj.username = "abc";
urlObj.password = password;
access_url = urlObj.toString();
}
const insertData = {
user_id: user.id,
container_id: container.id,
type: type.toLowerCase(),
description: config.description,
image: config.image,
port,
access_url: access_url,
running: true,
started_at: new Date()
};
if (typeLower === "kicad" || typeLower === "blender") {
insertData.password = password;
}
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: access_url
})
.returning(['id', 'container_id', 'type', 'description', 'image', 'port', 'access_url']);
.insert(insertData)
.returning(['id', 'container_id', 'type', 'description', 'image', 'port', 'access_url', 'password', 'running']);
return {
const result = {
message: "Container created successfully",
spaceId: newSpace.id,
containerId: newSpace.container_id,
@ -132,6 +164,12 @@ export const createContainer = async (password, type, authorization) => {
port: newSpace.port,
accessUrl: newSpace.access_url
};
if (newSpace.password) {
result.password = newSpace.password;
}
return result;
} catch (err) {
console.error("Docker error:", err);
throw new Error("Failed to create container");
@ -164,11 +202,32 @@ export const startContainer = async (spaceId, authorization) => {
throw error;
}
// Check if user already has a running space
const runningSpace = await pg('spaces')
.where('user_id', user.id)
.where('running', true)
.whereNot('id', spaceId)
.first();
if (runningSpace) {
const error = new Error("You can only have one space running at a time. Please stop your other space first.");
error.statusCode = 400;
throw error;
}
const container = docker.getContainer(space.container_id);
await container.inspect();
await container.start();
// Update running status and started_at timestamp in database
await pg('spaces')
.where('id', spaceId)
.update({
running: true,
started_at: new Date()
});
return {
message: "Container started successfully",
spaceId: space.id,
@ -187,6 +246,9 @@ export const startContainer = async (spaceId, authorization) => {
error.statusCode = 400;
throw error;
}
if (err.statusCode === 400) {
throw err;
}
throw new Error("Failed to start container");
}
@ -223,6 +285,14 @@ export const stopContainer = async (spaceId, authorization) => {
await container.inspect();
await container.stop();
// Update running status and clear started_at timestamp in database
await pg('spaces')
.where('id', spaceId)
.update({
running: false,
started_at: null
});
return {
message: "Container stopped successfully",
spaceId: space.id,
@ -312,9 +382,16 @@ export const getUserSpaces = async (authorization) => {
try {
const spaces = await pg('spaces')
.where('user_id', user.id)
.select(['id', 'type', 'description', 'image', 'port', 'access_url', 'created_at']);
.select(['id', 'container_id', 'type', 'description', 'image', 'port', 'access_url', 'password', 'created_at', 'running']);
return spaces;
const spacesWithStatus = spaces.map((space) => {
return {
...space,
status: space.running ? 'running' : 'stopped'
};
});
return spacesWithStatus;
} catch (err) {
console.error("Database error:", err);
throw new Error("Failed to get user spaces");