Initial commit

This commit is contained in:
Jarek Krochmalski
2025-12-28 21:16:03 +01:00
commit 62e3c6439e
552 changed files with 104858 additions and 0 deletions

View File

@@ -0,0 +1,93 @@
import { json } from '@sveltejs/kit';
import {
inspectContainer,
removeContainer,
getContainerLogs
} from '$lib/server/docker';
import { deleteAutoUpdateSchedule, getAutoUpdateSetting } from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import { auditContainer } from '$lib/server/audit';
import { unregisterSchedule } from '$lib/server/scheduler';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check with environment context
if (auth.authEnabled && !await auth.can('containers', 'view', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
// Environment access check (enterprise only)
if (envIdNum && auth.isEnterprise && !await auth.canAccessEnvironment(envIdNum)) {
return json({ error: 'Access denied to this environment' }, { status: 403 });
}
try {
const details = await inspectContainer(params.id, envIdNum);
return json(details);
} catch (error) {
console.error('Error inspecting container:', error);
return json({ error: 'Failed to inspect container' }, { status: 500 });
}
};
export const DELETE: RequestHandler = async (event) => {
const { params, url, cookies } = event;
const auth = await authorize(cookies);
const force = url.searchParams.get('force') === 'true';
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check with environment context
if (auth.authEnabled && !await auth.can('containers', 'remove', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
// Environment access check (enterprise only)
if (envIdNum && auth.isEnterprise && !await auth.canAccessEnvironment(envIdNum)) {
return json({ error: 'Access denied to this environment' }, { status: 403 });
}
try {
// Get container name before deletion for audit
let containerName = params.id;
try {
const details = await inspectContainer(params.id, envIdNum);
containerName = details.Name?.replace(/^\//, '') || params.id;
} catch {
// Container might not exist or other error, use ID
}
await removeContainer(params.id, force, envIdNum);
// Audit log
await auditContainer(event, 'delete', params.id, containerName, envIdNum, { force });
// Clean up auto-update schedule if exists
try {
// Get the schedule ID before deleting
const setting = await getAutoUpdateSetting(containerName, envIdNum);
if (setting) {
// Unregister from croner
unregisterSchedule(setting.id, 'container_update');
// Delete from database
await deleteAutoUpdateSchedule(containerName, envIdNum);
}
} catch (error) {
console.error('Failed to cleanup auto-update schedule:', error);
// Don't fail the deletion if schedule cleanup fails
}
return json({ success: true });
} catch (error) {
console.error('Error removing container:', error);
return json({ error: 'Failed to remove container' }, { status: 500 });
}
};

View File

@@ -0,0 +1,59 @@
/**
* Container Exec API
*
* POST: Creates an exec instance for terminal attachment
* Returns exec ID that can be used for WebSocket connection
*/
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { createExec, getDockerConnectionInfo } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
export const POST: RequestHandler = async ({ params, request, cookies, url }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !auth.isAuthenticated) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
const containerId = params.id;
const envIdParam = url.searchParams.get('envId');
const envId = envIdParam ? parseInt(envIdParam, 10) : undefined;
// Permission check with environment context
if (!await auth.can('containers', 'exec', envId)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const body = await request.json().catch(() => ({}));
const shell = body.shell || '/bin/sh';
const user = body.user || 'root';
// Create exec instance
const exec = await createExec({
containerId,
cmd: [shell],
user,
envId
});
// Get connection info for the frontend
const connectionInfo = await getDockerConnectionInfo(envId);
return json({
execId: exec.Id,
connectionInfo: {
type: connectionInfo.type,
host: connectionInfo.host,
port: connectionInfo.port
}
});
} catch (error: any) {
console.error('Failed to create exec:', error);
return json(
{ error: error.message || 'Failed to create exec instance' },
{ status: 500 }
);
}
};

View File

@@ -0,0 +1,32 @@
import { json } from '@sveltejs/kit';
import { listContainerDirectory } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const auth = await authorize(cookies);
const path = url.searchParams.get('path') || '/';
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
const simpleLs = url.searchParams.get('simpleLs') === 'true';
// Permission check with environment context
if (auth.authEnabled && !await auth.can('containers', 'view', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const result = await listContainerDirectory(
params.id,
path,
envIdNum,
simpleLs
);
return json(result);
} catch (error: any) {
console.error('Error listing container directory:', error);
return json({ error: error.message || 'Failed to list directory' }, { status: 500 });
}
};

View File

@@ -0,0 +1,57 @@
import { json } from '@sveltejs/kit';
import { chmodContainerPath } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ params, url, cookies, request }) => {
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check with environment context
if (auth.authEnabled && !await auth.can('containers', 'exec', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const body = await request.json();
const { path, mode, recursive } = body;
if (!path || typeof path !== 'string') {
return json({ error: 'Path is required' }, { status: 400 });
}
if (!mode || typeof mode !== 'string') {
return json({ error: 'Mode is required (e.g., "755" or "u+x")' }, { status: 400 });
}
await chmodContainerPath(params.id, path, mode, recursive === true, envIdNum);
return json({ success: true, path, mode, recursive: recursive === true });
} catch (error: any) {
console.error('Error changing permissions:', error);
const msg = error.message || String(error);
if (msg.includes('Permission denied')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
if (msg.includes('No such file or directory')) {
return json({ error: 'Path not found' }, { status: 404 });
}
if (msg.includes('Invalid chmod mode')) {
return json({ error: msg }, { status: 400 });
}
if (msg.includes('Read-only file system')) {
return json({ error: 'File system is read-only' }, { status: 403 });
}
if (msg.includes('Operation not permitted')) {
return json({ error: 'Operation not permitted' }, { status: 403 });
}
if (msg.includes('container is not running')) {
return json({ error: 'Container is not running' }, { status: 400 });
}
return json({ error: `Failed to change permissions: ${msg}` }, { status: 500 });
}
};

View File

@@ -0,0 +1,116 @@
import { json } from '@sveltejs/kit';
import { readContainerFile, writeContainerFile } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import type { RequestHandler } from './$types';
// Max file size for reading (1MB)
const MAX_FILE_SIZE = 1024 * 1024;
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const auth = await authorize(cookies);
const path = url.searchParams.get('path');
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check with environment context
if (auth.authEnabled && !await auth.can('containers', 'view', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
if (!path) {
return json({ error: 'Path is required' }, { status: 400 });
}
const content = await readContainerFile(
params.id,
path,
envIdNum
);
// Check if content is too large
if (content.length > MAX_FILE_SIZE) {
return json({ error: 'File is too large to edit (max 1MB)' }, { status: 413 });
}
return json({ content, path });
} catch (error: any) {
console.error('Error reading container file:', error);
const msg = error.message || String(error);
if (msg.includes('No such file or directory')) {
return json({ error: 'File not found' }, { status: 404 });
}
if (msg.includes('Permission denied')) {
return json({ error: 'Permission denied to read this file' }, { status: 403 });
}
if (msg.includes('Is a directory')) {
return json({ error: 'Cannot read a directory' }, { status: 400 });
}
if (msg.includes('container is not running')) {
return json({ error: 'Container is not running' }, { status: 400 });
}
return json({ error: `Failed to read file: ${msg}` }, { status: 500 });
}
};
export const PUT: RequestHandler = async ({ params, url, cookies, request }) => {
const auth = await authorize(cookies);
const path = url.searchParams.get('path');
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check with environment context
if (auth.authEnabled && !await auth.can('containers', 'exec', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
if (!path) {
return json({ error: 'Path is required' }, { status: 400 });
}
const body = await request.json();
if (typeof body.content !== 'string') {
return json({ error: 'Content is required' }, { status: 400 });
}
// Check content size
if (body.content.length > MAX_FILE_SIZE) {
return json({ error: 'Content is too large (max 1MB)' }, { status: 413 });
}
await writeContainerFile(
params.id,
path,
body.content,
envIdNum
);
return json({ success: true, path });
} catch (error: any) {
console.error('Error writing container file:', error);
const msg = error.message || String(error);
if (msg.includes('Permission denied')) {
return json({ error: 'Permission denied to write this file' }, { status: 403 });
}
if (msg.includes('No such file or directory')) {
return json({ error: 'Directory not found' }, { status: 404 });
}
if (msg.includes('Read-only file system')) {
return json({ error: 'File system is read-only' }, { status: 403 });
}
if (msg.includes('No space left on device')) {
return json({ error: 'No space left on device' }, { status: 507 });
}
if (msg.includes('container is not running')) {
return json({ error: 'Container is not running' }, { status: 400 });
}
return json({ error: `Failed to write file: ${msg}` }, { status: 500 });
}
};

View File

@@ -0,0 +1,55 @@
import { json } from '@sveltejs/kit';
import { createContainerFile, createContainerDirectory } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ params, url, cookies, request }) => {
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check with environment context
if (auth.authEnabled && !await auth.can('containers', 'exec', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const body = await request.json();
const { path, type } = body;
if (!path || typeof path !== 'string') {
return json({ error: 'Path is required' }, { status: 400 });
}
if (type !== 'file' && type !== 'directory') {
return json({ error: 'Type must be "file" or "directory"' }, { status: 400 });
}
if (type === 'file') {
await createContainerFile(params.id, path, envIdNum);
} else {
await createContainerDirectory(params.id, path, envIdNum);
}
return json({ success: true, path, type });
} catch (error: any) {
console.error('Error creating path:', error);
const msg = error.message || String(error);
if (msg.includes('Permission denied')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
if (msg.includes('File exists')) {
return json({ error: 'Path already exists' }, { status: 409 });
}
if (msg.includes('No such file or directory')) {
return json({ error: 'Parent directory not found' }, { status: 404 });
}
if (msg.includes('container is not running')) {
return json({ error: 'Container is not running' }, { status: 400 });
}
return json({ error: `Failed to create: ${msg}` }, { status: 500 });
}
};

View File

@@ -0,0 +1,51 @@
import { json } from '@sveltejs/kit';
import { deleteContainerPath } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import type { RequestHandler } from './$types';
export const DELETE: RequestHandler = async ({ params, url, cookies }) => {
const auth = await authorize(cookies);
const path = url.searchParams.get('path');
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check with environment context
if (auth.authEnabled && !await auth.can('containers', 'exec', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
if (!path) {
return json({ error: 'Path is required' }, { status: 400 });
}
await deleteContainerPath(params.id, path, envIdNum);
return json({ success: true, path });
} catch (error: any) {
console.error('Error deleting path:', error);
const msg = error.message || String(error);
if (msg.includes('Permission denied')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
if (msg.includes('No such file or directory')) {
return json({ error: 'Path not found' }, { status: 404 });
}
if (msg.includes('Cannot delete critical')) {
return json({ error: msg }, { status: 400 });
}
if (msg.includes('Read-only file system')) {
return json({ error: 'File system is read-only' }, { status: 403 });
}
if (msg.includes('Directory not empty')) {
return json({ error: 'Directory is not empty' }, { status: 400 });
}
if (msg.includes('container is not running')) {
return json({ error: 'Container is not running' }, { status: 400 });
}
return json({ error: `Failed to delete: ${msg}` }, { status: 500 });
}
};

View File

@@ -0,0 +1,98 @@
import { getContainerArchive, statContainerPath } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const auth = await authorize(cookies);
const path = url.searchParams.get('path');
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check with environment context
if (auth.authEnabled && !await auth.can('containers', 'view', envIdNum)) {
return new Response(JSON.stringify({ error: 'Permission denied' }), {
status: 403,
headers: { 'Content-Type': 'application/json' }
});
}
if (!path) {
return new Response(JSON.stringify({ error: 'Path is required' }), {
status: 400,
headers: { 'Content-Type': 'application/json' }
});
}
try {
// Get format from query parameter (defaults to tar)
const format = url.searchParams.get('format') || 'tar';
// Get stat info to determine filename
let filename: string;
try {
const stat = await statContainerPath(params.id, path, envIdNum);
filename = stat.name || path.split('/').pop() || 'download';
} catch {
filename = path.split('/').pop() || 'download';
}
// Get the archive from Docker
const response = await getContainerArchive(
params.id,
path,
envIdNum
);
// Prepare response based on format
let body: ReadableStream<Uint8Array> | Uint8Array = response.body!;
let contentType = 'application/x-tar';
let extension = '.tar';
if (format === 'tar.gz') {
// Compress with gzip using Bun's native implementation
const tarData = new Uint8Array(await response.arrayBuffer());
body = Bun.gzipSync(tarData);
contentType = 'application/gzip';
extension = '.tar.gz';
}
const headers: Record<string, string> = {
'Content-Type': contentType,
'Content-Disposition': `attachment; filename="${filename}${extension}"`
};
// Set content length for compressed data
if (body instanceof Uint8Array) {
headers['Content-Length'] = body.length.toString();
} else {
// Pass through content length for streaming tar
const contentLength = response.headers.get('Content-Length');
if (contentLength) {
headers['Content-Length'] = contentLength;
}
}
return new Response(body, { headers });
} catch (error: any) {
console.error('Error downloading container file:', error);
if (error.message?.includes('No such file or directory')) {
return new Response(JSON.stringify({ error: 'File not found' }), {
status: 404,
headers: { 'Content-Type': 'application/json' }
});
}
if (error.message?.includes('Permission denied')) {
return new Response(JSON.stringify({ error: 'Permission denied to access this path' }), {
status: 403,
headers: { 'Content-Type': 'application/json' }
});
}
return new Response(JSON.stringify({ error: 'Failed to download file' }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
};

View File

@@ -0,0 +1,54 @@
import { json } from '@sveltejs/kit';
import { renameContainerPath } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ params, url, cookies, request }) => {
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check with environment context
if (auth.authEnabled && !await auth.can('containers', 'exec', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const body = await request.json();
const { oldPath, newPath } = body;
if (!oldPath || typeof oldPath !== 'string') {
return json({ error: 'Old path is required' }, { status: 400 });
}
if (!newPath || typeof newPath !== 'string') {
return json({ error: 'New path is required' }, { status: 400 });
}
await renameContainerPath(params.id, oldPath, newPath, envIdNum);
return json({ success: true, oldPath, newPath });
} catch (error: any) {
console.error('Error renaming path:', error);
const msg = error.message || String(error);
if (msg.includes('Permission denied')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
if (msg.includes('No such file or directory')) {
return json({ error: 'Source path not found' }, { status: 404 });
}
if (msg.includes('File exists') || msg.includes('Directory not empty')) {
return json({ error: 'Destination already exists' }, { status: 409 });
}
if (msg.includes('Read-only file system')) {
return json({ error: 'File system is read-only' }, { status: 403 });
}
if (msg.includes('container is not running')) {
return json({ error: 'Container is not running' }, { status: 400 });
}
return json({ error: `Failed to rename: ${msg}` }, { status: 500 });
}
};

View File

@@ -0,0 +1,154 @@
import { json } from '@sveltejs/kit';
import { putContainerArchive } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import type { RequestHandler } from './$types';
/**
* Create a simple tar archive from a single file
* TAR format: 512-byte header followed by file content padded to 512 bytes
*/
function createTarArchive(filename: string, content: Uint8Array): Uint8Array {
// TAR header is 512 bytes
const header = new Uint8Array(512);
const encoder = new TextEncoder();
// File name (100 bytes)
const nameBytes = encoder.encode(filename.slice(0, 99));
header.set(nameBytes, 0);
// File mode (8 bytes) - 0644
header.set(encoder.encode('0000644\0'), 100);
// Owner UID (8 bytes)
header.set(encoder.encode('0000000\0'), 108);
// Owner GID (8 bytes)
header.set(encoder.encode('0000000\0'), 116);
// File size in octal (12 bytes)
const sizeOctal = content.length.toString(8).padStart(11, '0');
header.set(encoder.encode(sizeOctal + '\0'), 124);
// Modification time (12 bytes) - current time in octal
const mtime = Math.floor(Date.now() / 1000).toString(8).padStart(11, '0');
header.set(encoder.encode(mtime + '\0'), 136);
// Checksum placeholder (8 spaces initially)
header.set(encoder.encode(' '), 148);
// Type flag - '0' for regular file
header[156] = 48; // '0'
// Link name (100 bytes) - empty
// Magic (6 bytes) - 'ustar\0'
header.set(encoder.encode('ustar\0'), 257);
// Version (2 bytes) - '00'
header.set(encoder.encode('00'), 263);
// Owner name (32 bytes)
header.set(encoder.encode('root'), 265);
// Group name (32 bytes)
header.set(encoder.encode('root'), 297);
// Calculate checksum
let checksum = 0;
for (let i = 0; i < 512; i++) {
checksum += header[i];
}
const checksumOctal = checksum.toString(8).padStart(6, '0') + '\0 ';
header.set(encoder.encode(checksumOctal), 148);
// Calculate padding to 512-byte boundary
const paddingSize = (512 - (content.length % 512)) % 512;
const padding = new Uint8Array(paddingSize);
// End of archive marker (two 512-byte zero blocks)
const endMarker = new Uint8Array(1024);
// Combine all parts
const totalSize = header.length + content.length + paddingSize + endMarker.length;
const tar = new Uint8Array(totalSize);
let offset = 0;
tar.set(header, offset);
offset += header.length;
tar.set(content, offset);
offset += content.length;
tar.set(padding, offset);
offset += paddingSize;
tar.set(endMarker, offset);
return tar;
}
export const POST: RequestHandler = async ({ params, url, request, cookies }) => {
const auth = await authorize(cookies);
const path = url.searchParams.get('path');
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check with environment context
if (auth.authEnabled && !await auth.can('containers', 'exec', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
if (!path) {
return json({ error: 'Target path is required' }, { status: 400 });
}
try {
const formData = await request.formData();
const files = formData.getAll('files') as File[];
if (files.length === 0) {
return json({ error: 'No files provided' }, { status: 400 });
}
// For simplicity, we'll upload files one at a time
// A more sophisticated implementation could pack multiple files into one tar
const uploaded: string[] = [];
const errors: string[] = [];
for (const file of files) {
try {
const content = new Uint8Array(await file.arrayBuffer());
const tar = createTarArchive(file.name, content);
await putContainerArchive(
params.id,
path,
tar,
envId ? parseInt(envId) : undefined
);
uploaded.push(file.name);
} catch (err: any) {
errors.push(`${file.name}: ${err.message}`);
}
}
if (errors.length > 0 && uploaded.length === 0) {
return json({ error: 'Failed to upload files', details: errors }, { status: 500 });
}
return json({
success: true,
uploaded,
errors: errors.length > 0 ? errors : undefined
});
} catch (error: any) {
console.error('Error uploading to container:', error);
if (error.message?.includes('Permission denied')) {
return json({ error: 'Permission denied to write to this path' }, { status: 403 });
}
if (error.message?.includes('No such file or directory')) {
return json({ error: 'Target directory not found' }, { status: 404 });
}
return json({ error: 'Failed to upload files' }, { status: 500 });
}
};

View File

@@ -0,0 +1,24 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { inspectContainer } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check with environment context
if (auth.authEnabled && !await auth.can('containers', 'inspect', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const containerData = await inspectContainer(params.id, envIdNum);
return json(containerData);
} catch (error) {
console.error('Failed to inspect container:', error);
return json({ error: 'Failed to inspect container' }, { status: 500 });
}
};

View File

@@ -0,0 +1,25 @@
import { json } from '@sveltejs/kit';
import { getContainerLogs } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const auth = await authorize(cookies);
const tail = parseInt(url.searchParams.get('tail') || '100');
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check with environment context
if (auth.authEnabled && !await auth.can('containers', 'logs', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const logs = await getContainerLogs(params.id, tail, envIdNum);
return json({ logs });
} catch (error: any) {
console.error('Error getting container logs:', error?.message || error, error?.stack);
return json({ error: 'Failed to get container logs', details: error?.message }, { status: 500 });
}
};

View File

@@ -0,0 +1,452 @@
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import { getEnvironment } from '$lib/server/db';
import { sendEdgeRequest, sendEdgeStreamRequest, isEdgeConnected } from '$lib/server/hawser';
import { existsSync } from 'node:fs';
import { homedir } from 'node:os';
// Detect Docker socket path
function detectDockerSocket(): string {
if (process.env.DOCKER_SOCKET && existsSync(process.env.DOCKER_SOCKET)) {
return process.env.DOCKER_SOCKET;
}
if (process.env.DOCKER_HOST?.startsWith('unix://')) {
const socketPath = process.env.DOCKER_HOST.replace('unix://', '');
if (existsSync(socketPath)) return socketPath;
}
const possibleSockets = [
'/var/run/docker.sock',
`${homedir()}/.docker/run/docker.sock`,
`${homedir()}/.orbstack/run/docker.sock`,
'/run/docker.sock'
];
for (const socket of possibleSockets) {
if (existsSync(socket)) return socket;
}
return '/var/run/docker.sock';
}
const socketPath = detectDockerSocket();
interface DockerClientConfig {
type: 'socket' | 'http' | 'https' | 'hawser-edge';
socketPath?: string;
host?: string;
port?: number;
ca?: string;
cert?: string;
key?: string;
hawserToken?: string;
environmentId?: number;
}
async function getDockerConfig(envId?: number | null): Promise<DockerClientConfig | null> {
if (!envId) {
return null;
}
const env = await getEnvironment(envId);
if (!env) {
return null;
}
if (env.connectionType === 'socket' || !env.connectionType) {
return { type: 'socket', socketPath: env.socketPath || socketPath };
}
if (env.connectionType === 'hawser-edge') {
return { type: 'hawser-edge', environmentId: envId };
}
const protocol = (env.protocol as 'http' | 'https') || 'http';
return {
type: protocol,
host: env.host || 'localhost',
port: env.port || 2375,
ca: env.tlsCa || undefined,
cert: env.tlsCert || undefined,
key: env.tlsKey || undefined,
hawserToken: env.connectionType === 'hawser-standard' ? env.hawserToken || undefined : undefined
};
}
/**
* Demultiplex Docker stream frame - returns payload and stream type
*/
function parseDockerFrame(buffer: Buffer, offset: number): { type: number; size: number; payload: string } | null {
if (buffer.length < offset + 8) return null;
const streamType = buffer.readUInt8(offset);
const frameSize = buffer.readUInt32BE(offset + 4);
if (buffer.length < offset + 8 + frameSize) return null;
const payload = buffer.slice(offset + 8, offset + 8 + frameSize).toString('utf-8');
return { type: streamType, size: 8 + frameSize, payload };
}
/**
* Handle logs streaming for Hawser Edge connections
*/
async function handleEdgeLogsStream(containerId: string, tail: string, environmentId: number): Promise<Response> {
// Check if edge agent is connected
if (!isEdgeConnected(environmentId)) {
return new Response(JSON.stringify({ error: 'Edge agent not connected' }), {
status: 503,
headers: { 'Content-Type': 'application/json' }
});
}
// First, check if container has TTY enabled and get container name
let hasTty = false;
let containerName = containerId.substring(0, 12); // Default to short ID
try {
const inspectPath = `/containers/${containerId}/json`;
const inspectResponse = await sendEdgeRequest(environmentId, 'GET', inspectPath);
if (inspectResponse.statusCode === 200) {
const info = JSON.parse(inspectResponse.body as string);
hasTty = info.Config?.Tty ?? false;
// Get container name (strip leading /)
containerName = info.Name?.replace(/^\//, '') || containerName;
}
} catch {
// Ignore - default to demux mode
}
const logsPath = `/containers/${containerId}/logs?stdout=true&stderr=true&follow=true&tail=${tail}&timestamps=true`;
let controllerClosed = false;
let cancelStream: (() => void) | null = null;
let heartbeatInterval: ReturnType<typeof setInterval> | null = null;
const stream = new ReadableStream({
start(controller) {
const encoder = new TextEncoder();
const safeEnqueue = (data: string) => {
if (!controllerClosed) {
try {
controller.enqueue(encoder.encode(data));
} catch {
controllerClosed = true;
}
}
};
// Send heartbeat to keep connection alive (every 5s for Traefik)
heartbeatInterval = setInterval(() => {
safeEnqueue(`: keepalive\n\n`);
}, 5000);
// Buffer for non-TTY stream demuxing
let buffer = Buffer.alloc(0);
// Send connected event
safeEnqueue(`event: connected\ndata: ${JSON.stringify({ containerId, containerName, hasTty })}\n\n`);
// Start streaming logs via Edge
const { cancel } = sendEdgeStreamRequest(
environmentId,
'GET',
logsPath,
{
onData: (data: string, streamType?: 'stdout' | 'stderr') => {
if (controllerClosed) return;
if (hasTty) {
// TTY mode: data is raw text, may be base64 encoded
let text = data;
try {
// Try to decode as base64
text = Buffer.from(data, 'base64').toString('utf-8');
} catch {
// Not base64, use as-is
}
if (text) {
safeEnqueue(`event: log\ndata: ${JSON.stringify({ text, containerName })}\n\n`);
}
} else {
// Non-TTY mode: data might be base64 encoded Docker multiplexed stream
let rawData: Buffer;
try {
rawData = Buffer.from(data, 'base64');
} catch {
rawData = Buffer.from(data, 'utf-8');
}
buffer = Buffer.concat([buffer, rawData]);
// Process complete frames
let offset = 0;
while (true) {
const frame = parseDockerFrame(buffer, offset);
if (!frame) break;
if (frame.payload) {
safeEnqueue(`event: log\ndata: ${JSON.stringify({
text: frame.payload,
containerName,
stream: frame.type === 2 ? 'stderr' : 'stdout'
})}\n\n`);
}
offset += frame.size;
}
// Keep remaining incomplete frame data
buffer = buffer.slice(offset);
}
},
onEnd: (reason?: string) => {
if (buffer.length > 0) {
const text = buffer.toString('utf-8');
if (text.trim()) {
safeEnqueue(`event: log\ndata: ${JSON.stringify({ text, containerName })}\n\n`);
}
}
safeEnqueue(`event: end\ndata: ${JSON.stringify({ reason: reason || 'stream ended' })}\n\n`);
if (!controllerClosed) {
try {
controller.close();
} catch {
// Already closed
}
}
},
onError: (error: string) => {
safeEnqueue(`event: error\ndata: ${JSON.stringify({ error })}\n\n`);
if (!controllerClosed) {
try {
controller.close();
} catch {
// Already closed
}
}
}
}
);
cancelStream = cancel;
},
cancel() {
controllerClosed = true;
if (heartbeatInterval) {
clearInterval(heartbeatInterval);
heartbeatInterval = null;
}
if (cancelStream) {
cancelStream();
cancelStream = null;
}
}
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no'
}
});
}
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const auth = await authorize(cookies);
const containerId = params.id;
const tail = url.searchParams.get('tail') || '100';
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check with environment context
if (auth.authEnabled && !await auth.can('containers', 'logs', envIdNum)) {
return new Response(JSON.stringify({ error: 'Permission denied' }), {
status: 403,
headers: { 'Content-Type': 'application/json' }
});
}
const config = await getDockerConfig(envIdNum);
// Handle Hawser Edge mode separately
if (config.type === 'hawser-edge') {
return handleEdgeLogsStream(containerId, tail, config.environmentId!);
}
// First, check if container has TTY enabled and get container name
let hasTty = false;
let containerName = containerId.substring(0, 12); // Default to short ID
try {
const inspectPath = `/containers/${containerId}/json`;
let inspectResponse: Response;
if (config.type === 'socket') {
inspectResponse = await fetch(`http://localhost${inspectPath}`, {
// @ts-ignore - Bun supports unix socket
unix: config.socketPath
});
} else {
const inspectUrl = `${config.type}://${config.host}:${config.port}${inspectPath}`;
const inspectHeaders: Record<string, string> = {};
if (config.hawserToken) inspectHeaders['X-Hawser-Token'] = config.hawserToken;
inspectResponse = await fetch(inspectUrl, {
headers: inspectHeaders,
// @ts-ignore
tls: config.type === 'https' ? { ca: config.ca, cert: config.cert, key: config.key } : undefined
});
}
if (inspectResponse.ok) {
const info = await inspectResponse.json();
hasTty = info.Config?.Tty ?? false;
// Get container name (strip leading /)
containerName = info.Name?.replace(/^\//, '') || containerName;
}
} catch {
// Ignore - default to demux mode
}
// Build the logs URL with follow=true for streaming
const logsPath = `/containers/${containerId}/logs?stdout=true&stderr=true&follow=true&tail=${tail}&timestamps=true`;
let controllerClosed = false;
let abortController: AbortController | null = new AbortController();
let heartbeatInterval: ReturnType<typeof setInterval> | null = null;
const stream = new ReadableStream({
async start(controller) {
const encoder = new TextEncoder();
const safeEnqueue = (data: string) => {
if (!controllerClosed) {
try {
controller.enqueue(encoder.encode(data));
} catch {
controllerClosed = true;
}
}
};
// Send heartbeat to keep connection alive (every 5s for Traefik)
heartbeatInterval = setInterval(() => {
safeEnqueue(`: keepalive\n\n`);
}, 5000);
try {
let response: Response;
if (config.type === 'socket') {
response = await fetch(`http://localhost${logsPath}`, {
// @ts-ignore - Bun supports unix socket
unix: config.socketPath,
signal: abortController?.signal
});
} else {
const logsUrl = `${config.type}://${config.host}:${config.port}${logsPath}`;
const logsHeaders: Record<string, string> = {};
if (config.hawserToken) logsHeaders['X-Hawser-Token'] = config.hawserToken;
response = await fetch(logsUrl, {
headers: logsHeaders,
signal: abortController?.signal,
// @ts-ignore
tls: config.type === 'https' ? { ca: config.ca, cert: config.cert, key: config.key } : undefined
});
}
if (!response.ok) {
safeEnqueue(`event: error\ndata: ${JSON.stringify({ error: `Docker API error: ${response.status}` })}\n\n`);
if (!controllerClosed) controller.close();
return;
}
// Send connected event
safeEnqueue(`event: connected\ndata: ${JSON.stringify({ containerId, containerName, hasTty })}\n\n`);
const reader = response.body?.getReader();
if (!reader) {
safeEnqueue(`event: error\ndata: ${JSON.stringify({ error: 'No response body' })}\n\n`);
if (!controllerClosed) controller.close();
return;
}
let buffer = Buffer.alloc(0);
while (!controllerClosed) {
const { done, value } = await reader.read();
if (done) {
// Send any remaining buffer content
if (buffer.length > 0) {
const text = buffer.toString('utf-8');
if (text.trim()) {
safeEnqueue(`event: log\ndata: ${JSON.stringify({ text, containerName })}\n\n`);
}
}
safeEnqueue(`event: end\ndata: ${JSON.stringify({ reason: 'stream ended' })}\n\n`);
break;
}
if (value) {
if (hasTty) {
// TTY mode: raw text, no demux needed
const text = new TextDecoder().decode(value);
if (text) {
safeEnqueue(`event: log\ndata: ${JSON.stringify({ text, containerName })}\n\n`);
}
} else {
// Non-TTY mode: demux Docker stream frames
buffer = Buffer.concat([buffer, Buffer.from(value)]);
// Process complete frames
let offset = 0;
while (true) {
const frame = parseDockerFrame(buffer, offset);
if (!frame) break;
// Stream type 1 = stdout, 2 = stderr
if (frame.payload) {
safeEnqueue(`event: log\ndata: ${JSON.stringify({ text: frame.payload, containerName, stream: frame.type === 2 ? 'stderr' : 'stdout' })}\n\n`);
}
offset += frame.size;
}
// Keep remaining incomplete frame data
buffer = buffer.slice(offset);
}
}
}
reader.releaseLock();
} catch (error) {
if (!controllerClosed) {
const errorMsg = error instanceof Error ? error.message : String(error);
if (!errorMsg.includes('abort')) {
safeEnqueue(`event: error\ndata: ${JSON.stringify({ error: errorMsg })}\n\n`);
}
}
}
if (!controllerClosed) {
try {
controller.close();
} catch {
// Already closed
}
}
},
cancel() {
controllerClosed = true;
if (heartbeatInterval) {
clearInterval(heartbeatInterval);
heartbeatInterval = null;
}
abortController?.abort();
abortController = null;
}
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no'
}
});
};

View File

@@ -0,0 +1,32 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { pauseContainer, inspectContainer } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { auditContainer } from '$lib/server/audit';
export const POST: RequestHandler = async (event) => {
const { params, url, cookies } = event;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check with environment context (pause/unpause uses 'stop' permission)
if (auth.authEnabled && !await auth.can('containers', 'stop', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const details = await inspectContainer(params.id, envIdNum);
const containerName = details.Name.replace(/^\//, '');
await pauseContainer(params.id, envIdNum);
// Audit log
await auditContainer(event, 'pause', params.id, containerName, envIdNum);
return json({ success: true });
} catch (error) {
console.error('Failed to pause container:', error);
return json({ error: 'Failed to pause container' }, { status: 500 });
}
};

View File

@@ -0,0 +1,53 @@
import { json } from '@sveltejs/kit';
import { renameContainer, inspectContainer } from '$lib/server/docker';
import { renameAutoUpdateSchedule } from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import { auditContainer } from '$lib/server/audit';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async (event) => {
const { params, request, url, cookies } = event;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check with environment context (renaming requires create permission)
if (auth.authEnabled && !await auth.can('containers', 'create', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const { name } = await request.json();
if (!name || typeof name !== 'string') {
return json({ error: 'New name is required' }, { status: 400 });
}
// Get old container name before renaming
let oldName = params.id;
try {
const details = await inspectContainer(params.id, envIdNum);
oldName = details.Name?.replace(/^\//, '') || params.id;
} catch {
// Container might not exist or other error, use ID
}
await renameContainer(params.id, name, envIdNum);
// Audit log
await auditContainer(event, 'rename', params.id, name, envIdNum, { previousId: params.id, newName: name });
// Update schedule if exists
try {
await renameAutoUpdateSchedule(oldName, name, envIdNum);
} catch (error) {
console.error('Failed to update schedule name:', error);
// Don't fail the rename if schedule update fails
}
return json({ success: true });
} catch (error) {
console.error('Error renaming container:', error);
return json({ error: 'Failed to rename container' }, { status: 500 });
}
};

View File

@@ -0,0 +1,45 @@
import { json } from '@sveltejs/kit';
import { restartContainer, inspectContainer } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { auditContainer } from '$lib/server/audit';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async (event) => {
const { params, url, cookies } = event;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check with environment context
if (auth.authEnabled && !await auth.can('containers', 'restart', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
// Environment access check (enterprise only)
if (envIdNum && auth.isEnterprise && !await auth.canAccessEnvironment(envIdNum)) {
return json({ error: 'Access denied to this environment' }, { status: 403 });
}
try {
// Get container name for audit
let containerName = params.id;
try {
const details = await inspectContainer(params.id, envIdNum);
containerName = details.Name?.replace(/^\//, '') || params.id;
} catch {
// Use ID if can't get name
}
await restartContainer(params.id, envIdNum);
// Audit log
await auditContainer(event, 'restart', params.id, containerName, envIdNum);
return json({ success: true });
} catch (error) {
console.error('Error restarting container:', error);
return json({ error: 'Failed to restart container' }, { status: 500 });
}
};

View File

@@ -0,0 +1,38 @@
import { json } from '@sveltejs/kit';
import { startContainer, inspectContainer } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { auditContainer } from '$lib/server/audit';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async (event) => {
const { params, url, cookies } = event;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check with environment context
if (auth.authEnabled && !await auth.can('containers', 'start', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
// Environment access check (enterprise only)
if (envIdNum && auth.isEnterprise && !await auth.canAccessEnvironment(envIdNum)) {
return json({ error: 'Access denied to this environment' }, { status: 403 });
}
try {
await startContainer(params.id, envIdNum);
const details = await inspectContainer(params.id, envIdNum);
const containerName = details.Name.replace(/^\//, '');
// Audit log
await auditContainer(event, 'start', params.id, containerName, envIdNum);
return json({ success: true });
} catch (error) {
console.error('Error starting container:', error);
return json({ error: 'Failed to start container' }, { status: 500 });
}
};

View File

@@ -0,0 +1,91 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getContainerStats } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { hasEnvironments } from '$lib/server/db';
function calculateCpuPercent(stats: any): number {
const cpuDelta = stats.cpu_stats.cpu_usage.total_usage - stats.precpu_stats.cpu_usage.total_usage;
const systemDelta = stats.cpu_stats.system_cpu_usage - stats.precpu_stats.system_cpu_usage;
const cpuCount = stats.cpu_stats.online_cpus || stats.cpu_stats.cpu_usage.percpu_usage?.length || 1;
if (systemDelta > 0 && cpuDelta > 0) {
return (cpuDelta / systemDelta) * cpuCount * 100;
}
return 0;
}
function calculateNetworkIO(stats: any): { rx: number; tx: number } {
let rx = 0;
let tx = 0;
if (stats.networks) {
for (const iface of Object.values(stats.networks) as any[]) {
rx += iface.rx_bytes || 0;
tx += iface.tx_bytes || 0;
}
}
return { rx, tx };
}
function calculateBlockIO(stats: any): { read: number; write: number } {
let read = 0;
let write = 0;
const ioStats = stats.blkio_stats?.io_service_bytes_recursive;
if (Array.isArray(ioStats)) {
for (const entry of ioStats) {
if (entry.op === 'read' || entry.op === 'Read') {
read += entry.value || 0;
} else if (entry.op === 'write' || entry.op === 'Write') {
write += entry.value || 0;
}
}
}
return { read, write };
}
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check with environment context (stats uses view permission)
if (auth.authEnabled && !await auth.can('containers', 'view', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
// Early return if no environments configured (fresh install)
if (!await hasEnvironments()) {
return json({ error: 'No environment configured' }, { status: 404 });
}
try {
const stats = await getContainerStats(params.id, envIdNum) as any;
const cpuPercent = calculateCpuPercent(stats);
const memoryUsage = stats.memory_stats?.usage || 0;
const memoryLimit = stats.memory_stats?.limit || 1;
const memoryPercent = (memoryUsage / memoryLimit) * 100;
const networkIO = calculateNetworkIO(stats);
const blockIO = calculateBlockIO(stats);
return json({
cpuPercent: Math.round(cpuPercent * 100) / 100,
memoryUsage,
memoryLimit,
memoryPercent: Math.round(memoryPercent * 100) / 100,
networkRx: networkIO.rx,
networkTx: networkIO.tx,
blockRead: blockIO.read,
blockWrite: blockIO.write,
timestamp: Date.now()
});
} catch (error: any) {
console.error('Failed to get container stats:', error);
return json({ error: error.message || 'Failed to get stats' }, { status: 500 });
}
};

View File

@@ -0,0 +1,38 @@
import { json } from '@sveltejs/kit';
import { stopContainer, inspectContainer } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { auditContainer } from '$lib/server/audit';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async (event) => {
const { params, url, cookies } = event;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check with environment context
if (auth.authEnabled && !await auth.can('containers', 'stop', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
// Environment access check (enterprise only)
if (envIdNum && auth.isEnterprise && !await auth.canAccessEnvironment(envIdNum)) {
return json({ error: 'Access denied to this environment' }, { status: 403 });
}
try {
const details = await inspectContainer(params.id, envIdNum);
const containerName = details.Name.replace(/^\//, '');
await stopContainer(params.id, envIdNum);
// Audit log
await auditContainer(event, 'stop', params.id, containerName, envIdNum);
return json({ success: true });
} catch (error) {
console.error('Error stopping container:', error);
return json({ error: 'Failed to stop container' }, { status: 500 });
}
};

View File

@@ -0,0 +1,73 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { execInContainer, getContainerTop } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
function parsePsOutput(output: string): { Titles: string[]; Processes: string[][] } | null {
const lines = output.trim().split('\n').filter(line => line.trim());
if (lines.length === 0) return null;
const headerLine = lines[0];
const headers = headerLine.trim().split(/\s+/);
// Find the index of COMMAND (last column, can have spaces)
const commandIndex = headers.findIndex(h => h === 'COMMAND' || h === 'CMD');
const processes = lines.slice(1).map(line => {
const parts = line.trim().split(/\s+/);
// COMMAND can have spaces, so join everything from commandIndex onwards
if (commandIndex !== -1 && parts.length > commandIndex) {
const beforeCommand = parts.slice(0, commandIndex);
const command = parts.slice(commandIndex).join(' ');
return [...beforeCommand, command];
}
return parts;
});
return { Titles: headers, Processes: processes };
}
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check with environment context (process list uses inspect permission)
if (auth.authEnabled && !await auth.can('containers', 'inspect', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
// Try different ps commands in order of preference
const psCommands = [
['ps', 'aux', '--sort=-pcpu'], // GNU ps with CPU sort
['ps', 'aux'], // GNU ps without sort
['ps', '-ef'], // POSIX ps
];
for (const cmd of psCommands) {
try {
const output = await execInContainer(params.id, cmd, envIdNum);
// Check if output looks like an error message (BusyBox error, etc.)
if (output.includes('unrecognized option') || output.includes('Usage:') || output.includes('BusyBox')) {
continue;
}
const result = parsePsOutput(output);
if (result && result.Processes.length > 0) {
return json({ ...result, source: 'ps' });
}
} catch {
// Try next command
}
}
// Fallback to docker top API
const top = await getContainerTop(params.id, envIdNum);
return json({ ...top, source: 'top' });
} catch (error: any) {
console.error('Failed to get container processes:', error);
return json({ error: error.message || 'Failed to get processes' }, { status: 500 });
}
};

View File

@@ -0,0 +1,32 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { unpauseContainer, inspectContainer } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { auditContainer } from '$lib/server/audit';
export const POST: RequestHandler = async (event) => {
const { params, url, cookies } = event;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check with environment context (unpause uses 'start' permission)
if (auth.authEnabled && !await auth.can('containers', 'start', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const details = await inspectContainer(params.id, envIdNum);
const containerName = details.Name.replace(/^\//, '');
await unpauseContainer(params.id, envIdNum);
// Audit log
await auditContainer(event, 'unpause', params.id, containerName, envIdNum);
return json({ success: true });
} catch (error) {
console.error('Failed to unpause container:', error);
return json({ error: 'Failed to unpause container' }, { status: 500 });
}
};

View File

@@ -0,0 +1,43 @@
import { json } from '@sveltejs/kit';
import { updateContainer, type CreateContainerOptions } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { auditContainer } from '$lib/server/audit';
import { removePendingContainerUpdate } from '$lib/server/db';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async (event) => {
const { params, request, url, cookies } = event;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check with environment context (update requires create permission)
if (auth.authEnabled && !await auth.can('containers', 'create', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const body = await request.json();
const { startAfterUpdate, ...options } = body;
console.log(`Updating container ${params.id} with name: ${options.name}`);
const container = await updateContainer(params.id, options, startAfterUpdate, envIdNum);
// Clear pending update indicator (if any) since container was just updated
if (envIdNum) {
await removePendingContainerUpdate(envIdNum, params.id).catch(() => {
// Ignore errors - record may not exist
});
}
// Audit log - include full options to see what was modified
await auditContainer(event, 'update', container.id, options.name, envIdNum, { ...options, startAfterUpdate });
return json({ success: true, id: container.id });
} catch (error) {
console.error('Error updating container:', error);
return json({ error: 'Failed to update container', details: String(error) }, { status: 500 });
}
};