mirror of
https://github.com/System-End/spaces.git
synced 2026-04-19 15:28:27 +00:00
a lot of shit
This commit is contained in:
parent
ace70841b0
commit
750174bc6f
22 changed files with 1005 additions and 142 deletions
19
Dockerfile
19
Dockerfile
|
|
@ -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=""
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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.',
|
||||
|
|
|
|||
348
client/src/lib/AdminPanel.svelte
Normal file
348
client/src/lib/AdminPanel.svelte
Normal 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>
|
||||
|
|
@ -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"/>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
69
nginx.conf
69
nginx.conf
|
|
@ -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
43
package-lock.json
generated
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
158
src/api/admin/admin.route.js
Normal file
158
src/api/admin/admin.route.js
Normal 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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
13
src/app.js
13
src/app.js
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
40
src/middlewares/admin.middleware.js
Normal file
40
src/middlewares/admin.middleware.js
Normal 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'
|
||||
});
|
||||
}
|
||||
};
|
||||
50
src/middlewares/rate-limit.middleware.js
Normal file
50
src/middlewares/rate-limit.middleware.js
Normal 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.'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
@ -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
62
src/utils/auto-stop.js
Normal 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);
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue