246 lines
8.0 KiB
TypeScript
246 lines
8.0 KiB
TypeScript
import express, { type Request, type Response } from 'express';
|
|
import fs from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import type {
|
|
ControlPlaneState,
|
|
CreateUserInput,
|
|
PanelLoginInput,
|
|
UpdateSystemInput,
|
|
} from '../src/shared/contracts';
|
|
import { validateCreateUserInput, validateSystemInput } from '../src/shared/validation';
|
|
import {
|
|
buildRuntimePaths,
|
|
createUserRecord,
|
|
render3proxyConfig,
|
|
type RuntimePaths,
|
|
} from './lib/config';
|
|
import { getDashboardSnapshot } from './lib/snapshot';
|
|
import { AuthService } from './lib/auth';
|
|
import type { LiveSyncPublisher } from './lib/liveSync';
|
|
import type { RuntimeController } from './lib/runtime';
|
|
import { StateStore } from './lib/store';
|
|
|
|
export interface AppServices {
|
|
store: StateStore;
|
|
runtime: RuntimeController;
|
|
runtimeRootDir: string;
|
|
auth: AuthService;
|
|
liveSync?: LiveSyncPublisher;
|
|
}
|
|
|
|
export function createApp({ store, runtime, runtimeRootDir, auth, liveSync }: AppServices) {
|
|
const app = express();
|
|
const runtimePaths = buildRuntimePaths(runtimeRootDir);
|
|
const distDir = path.resolve('dist');
|
|
|
|
app.use(express.json());
|
|
app.use(express.static(distDir));
|
|
|
|
app.post('/api/auth/login', (request, response) => {
|
|
const input = request.body as Partial<PanelLoginInput>;
|
|
|
|
try {
|
|
const payload = auth.login(input.login?.trim() ?? '', input.password ?? '');
|
|
response.json(payload);
|
|
} catch (error) {
|
|
response.status(401).json({
|
|
error: error instanceof Error ? error.message : 'Wrong panel credentials.',
|
|
});
|
|
}
|
|
});
|
|
|
|
app.use('/api', (request, response, next) => {
|
|
if (request.path === '/auth/login' || request.path === '/health') {
|
|
next();
|
|
return;
|
|
}
|
|
|
|
const token = auth.extractBearerToken(request);
|
|
|
|
if (!token) {
|
|
response.status(401).json(auth.unauthorizedError());
|
|
return;
|
|
}
|
|
|
|
if (!auth.verify(token)) {
|
|
response.status(401).json(auth.invalidTokenError());
|
|
return;
|
|
}
|
|
|
|
next();
|
|
});
|
|
|
|
app.get('/api/health', async (_request, response) => {
|
|
const state = await store.read();
|
|
const previewConfig = render3proxyConfig(state, runtimePaths);
|
|
response.json({
|
|
ok: true,
|
|
runtime: runtime.getSnapshot(),
|
|
users: state.userRecords.length,
|
|
configBytes: Buffer.byteLength(previewConfig),
|
|
});
|
|
});
|
|
|
|
app.get('/api/state', async (_request, response, next) => {
|
|
try {
|
|
const payload = await getDashboardSnapshot(store, runtime, runtimePaths);
|
|
response.json(payload);
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.post('/api/runtime/:action', async (request, response, next) => {
|
|
try {
|
|
const state = await store.read();
|
|
const action = request.params.action;
|
|
const controller = action === 'restart' ? runtime.restart.bind(runtime) : runtime.start.bind(runtime);
|
|
|
|
if (!['start', 'restart'].includes(action)) {
|
|
response.status(404).json({ error: 'Unknown runtime action.' });
|
|
return;
|
|
}
|
|
|
|
const runtimeSnapshot = await controller();
|
|
state.service.lastEvent = action === 'restart' ? 'Runtime restarted from panel' : 'Runtime start requested from panel';
|
|
if (runtimeSnapshot.startedAt) {
|
|
state.service.startedAt = runtimeSnapshot.startedAt;
|
|
}
|
|
|
|
await writeConfigAndState(store, state, runtimePaths);
|
|
liveSync?.notifyPotentialChange();
|
|
response.json(await getDashboardSnapshot(store, runtime, runtimePaths));
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.post('/api/users', async (request, response, next) => {
|
|
try {
|
|
const state = await store.read();
|
|
const input = validateCreateUserInput(request.body as Partial<CreateUserInput>, state.system.services);
|
|
const record = createUserRecord(state, input);
|
|
state.userRecords.push(record);
|
|
state.service.lastEvent = `User ${record.username} created from panel`;
|
|
await persistRuntimeMutation(store, runtime, state, runtimePaths);
|
|
liveSync?.notifyPotentialChange();
|
|
response.status(201).json(await getDashboardSnapshot(store, runtime, runtimePaths));
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.put('/api/system', async (request, response, next) => {
|
|
try {
|
|
const state = await store.read();
|
|
const requestedSystem = request.body as Partial<UpdateSystemInput>;
|
|
const nextServiceIds = new Set(
|
|
(Array.isArray(requestedSystem.services) ? requestedSystem.services : []).map((service) => service.id),
|
|
);
|
|
const removedServiceIds = new Set(
|
|
state.system.services
|
|
.map((service) => service.id)
|
|
.filter((serviceId) => !nextServiceIds.has(serviceId)),
|
|
);
|
|
|
|
const removedUsers = state.userRecords.filter((user) => removedServiceIds.has(user.serviceId));
|
|
state.userRecords = state.userRecords.filter((user) => !removedServiceIds.has(user.serviceId));
|
|
state.system = validateSystemInput(requestedSystem, state.userRecords);
|
|
state.service.lastEvent =
|
|
removedUsers.length > 0
|
|
? `System configuration updated from panel and removed ${removedUsers.length} linked users`
|
|
: 'System configuration updated from panel';
|
|
await persistRuntimeMutation(store, runtime, state, runtimePaths);
|
|
liveSync?.notifyPotentialChange();
|
|
response.json(await getDashboardSnapshot(store, runtime, runtimePaths));
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.post('/api/users/:id/pause', async (request, response, next) => {
|
|
try {
|
|
const state = await store.read();
|
|
const user = state.userRecords.find((entry) => entry.id === request.params.id);
|
|
|
|
if (!user) {
|
|
response.status(404).json({ error: 'User not found.' });
|
|
return;
|
|
}
|
|
|
|
user.paused = !user.paused;
|
|
state.service.lastEvent = user.paused
|
|
? `User ${user.username} paused from panel`
|
|
: `User ${user.username} resumed from panel`;
|
|
|
|
await persistRuntimeMutation(store, runtime, state, runtimePaths);
|
|
liveSync?.notifyPotentialChange();
|
|
response.json(await getDashboardSnapshot(store, runtime, runtimePaths));
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.delete('/api/users/:id', async (request, response, next) => {
|
|
try {
|
|
const state = await store.read();
|
|
const index = state.userRecords.findIndex((entry) => entry.id === request.params.id);
|
|
|
|
if (index === -1) {
|
|
response.status(404).json({ error: 'User not found.' });
|
|
return;
|
|
}
|
|
|
|
const [removed] = state.userRecords.splice(index, 1);
|
|
state.service.lastEvent = `User ${removed.username} deleted from panel`;
|
|
await persistRuntimeMutation(store, runtime, state, runtimePaths);
|
|
liveSync?.notifyPotentialChange();
|
|
response.json(await getDashboardSnapshot(store, runtime, runtimePaths));
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
app.use(async (_request, response) => {
|
|
const distPath = path.join(distDir, 'index.html');
|
|
|
|
try {
|
|
const html = await fs.readFile(distPath, 'utf8');
|
|
response.type('html').send(html);
|
|
} catch {
|
|
response.status(404).send('Frontend build not found.');
|
|
}
|
|
});
|
|
|
|
app.use((error: unknown, _request: Request, response: Response, _next: express.NextFunction) => {
|
|
response.status(400).json({
|
|
error: error instanceof Error ? error.message : 'Unknown server error.',
|
|
});
|
|
});
|
|
|
|
return app;
|
|
}
|
|
|
|
async function persistRuntimeMutation(
|
|
store: StateStore,
|
|
runtime: RuntimeController,
|
|
state: ControlPlaneState,
|
|
runtimePaths: RuntimePaths,
|
|
) {
|
|
await writeConfigAndState(store, state, runtimePaths);
|
|
await runtime.reload();
|
|
}
|
|
|
|
async function writeConfigAndState(
|
|
store: StateStore,
|
|
state: ControlPlaneState,
|
|
runtimePaths: RuntimePaths,
|
|
) {
|
|
const config = render3proxyConfig(state, runtimePaths);
|
|
await fs.mkdir(path.dirname(runtimePaths.configPath), { recursive: true });
|
|
await fs.mkdir(path.dirname(runtimePaths.logPath), { recursive: true });
|
|
await fs.mkdir(runtimePaths.reportDir, { recursive: true });
|
|
await fs.writeFile(runtimePaths.configPath, config, 'utf8');
|
|
await store.write(state);
|
|
}
|