This commit is contained in:
jarek committed 2026-01-01 16:00:34 +01:00
1 parent 5475112806
commit 242f8df49d
35 files changed
+1892 -1987

No files matched your search

+1
View File
@@ -86,6 +86,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
tlsCa: data.tlsCa,
tlsCert: data.tlsCert,
tlsKey: data.tlsKey,
tlsSkipVerify: data.tlsSkipVerify || false,
icon: data.icon || 'globe',
socketPath: data.socketPath || '/var/run/docker.sock',
collectActivity: data.collectActivity !== false,
@@ -65,6 +65,7 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => {
tlsCa: data.tlsCa,
tlsCert: data.tlsCert,
tlsKey: data.tlsKey,
tlsSkipVerify: data.tlsSkipVerify,
icon: data.icon,
socketPath: data.socketPath,
collectActivity: data.collectActivity,
+15 -2
View File
@@ -1,5 +1,5 @@
import { json } from '@sveltejs/kit';
import { listComposeStacks, deployStack, saveStackComposeFile } from '$lib/server/stacks';
import { listComposeStacks, deployStack, saveStackComposeFile, saveStackEnvVars } from '$lib/server/stacks';
import { EnvironmentNotFoundError } from '$lib/server/docker';
import { upsertStackSource, getStackSources } from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
@@ -78,7 +78,7 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => {
try {
const body = await request.json();
const { name, compose, start } = body;
const { name, compose, start, envVars } = body;
if (!name || typeof name !== 'string') {
return json({ error: 'Stack name is required' }, { status: 400 });
@@ -95,6 +95,11 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => {
return json({ error: result.error }, { status: 400 });
}
// Save environment variables if provided (to both DB and .env file)
if (envVars && Array.isArray(envVars) && envVars.length > 0) {
await saveStackEnvVars(name, envVars, envIdNum);
}
// Record the stack as internally created
await upsertStackSource({
stackName: name,
@@ -105,6 +110,14 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => {
return json({ success: true, started: false });
}
// Save environment variables BEFORE deploying so they're available during start
if (envVars && Array.isArray(envVars) && envVars.length > 0) {
// First ensure the stack directory exists by saving compose file
await saveStackComposeFile(name, compose, true);
// Save to both DB and .env file
await saveStackEnvVars(name, envVars, envIdNum);
}
// Deploy and start the stack
const result = await deployStack({
name,
+156 -8
View File
@@ -1,11 +1,96 @@
import { json } from '@sveltejs/kit';
import { getStackEnvVars, setStackEnvVars } from '$lib/server/db';
import { getStacksDir } from '$lib/server/stacks';
import { authorize } from '$lib/server/authorize';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import type { RequestHandler } from './$types';
/**
* Parse a .env file content into key-value pairs
*/
function parseEnvFile(content: string): Record<string, string> {
const result: Record<string, string> = {};
for (const line of content.split('\n')) {
const trimmed = line.trim();
// Skip empty lines and comments
if (!trimmed || trimmed.startsWith('#')) continue;
const eqIndex = trimmed.indexOf('=');
if (eqIndex > 0) {
const key = trimmed.substring(0, eqIndex).trim();
let value = trimmed.substring(eqIndex + 1);
// Remove surrounding quotes if present
if ((value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
result[key] = value;
}
}
return result;
}
/**
* Merge new variables into existing .env file content.
* - Keeps comments and formatting
* - Updates values for existing keys
* - REMOVES keys that are not in newVars (user deleted them)
* - Appends new keys at the end
*/
function mergeEnvFileContent(
existingContent: string,
newVars: { key: string; value: string }[]
): string {
const newVarsMap = new Map(newVars.map(v => [v.key, v.value]));
const handledKeys = new Set<string>();
const lines = existingContent.split('\n');
const resultLines: string[] = [];
for (const line of lines) {
const trimmed = line.trim();
// Keep comments and blank lines as-is
if (!trimmed || trimmed.startsWith('#')) {
resultLines.push(line);
continue;
}
// Check if this is a variable line
const eqIndex = trimmed.indexOf('=');
if (eqIndex > 0) {
const key = trimmed.substring(0, eqIndex).trim();
if (newVarsMap.has(key)) {
// Update existing variable with new value from UI
resultLines.push(`${key}=${newVarsMap.get(key)}`);
handledKeys.add(key);
}
// If key not in newVarsMap, it was deleted - skip it (don't add to resultLines)
} else {
// Not a valid variable line, keep as-is
resultLines.push(line);
}
}
// Append any new variables that weren't in the original file
for (const v of newVars) {
if (!handledKeys.has(v.key)) {
resultLines.push(`${v.key}=${v.value}`);
}
}
// Ensure file ends with newline
let result = resultLines.join('\n');
if (!result.endsWith('\n')) {
result += '\n';
}
return result;
}
/**
* GET /api/stacks/[name]/env?env=X
* Get all environment variables for a stack.
* Merges variables from database with .env file (file values shown if different).
* Secrets are masked with '***' in the response.
*/
export const GET: RequestHandler = async ({ params, url, cookies }) => {
@@ -25,15 +110,52 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
try {
const stackName = decodeURIComponent(params.name);
const variables = await getStackEnvVars(stackName, envIdNum, true);
return json({
variables: variables.map(v => ({
key: v.key,
value: v.value,
isSecret: v.isSecret
}))
});
// Get variables from database
const dbVariables = await getStackEnvVars(stackName, envIdNum, true);
const dbByKey = new Map(dbVariables.map(v => [v.key, v]));
// Try to read .env file from stack directory
const stacksDir = getStacksDir();
const envFilePath = join(stacksDir, stackName, '.env');
let fileVars: Record<string, string> = {};
if (existsSync(envFilePath)) {
try {
const content = await Bun.file(envFilePath).text();
fileVars = parseEnvFile(content);
} catch (e) {
// Ignore file read errors
}
}
// Merge: start with DB variables, add any new keys from file
const mergedKeys = new Set([...dbByKey.keys(), ...Object.keys(fileVars)]);
const variables: { key: string; value: string; isSecret: boolean }[] = [];
for (const key of mergedKeys) {
const dbVar = dbByKey.get(key);
const fileValue = fileVars[key];
if (dbVar) {
// Variable exists in DB
if (dbVar.isSecret) {
// Keep secret masked
variables.push({ key, value: dbVar.value, isSecret: true });
} else if (fileValue !== undefined && fileValue !== dbVar.value) {
// File has different value - use file value (user may have edited it)
variables.push({ key, value: fileValue, isSecret: false });
} else {
// Use DB value
variables.push({ key, value: dbVar.value, isSecret: false });
}
} else if (fileValue !== undefined) {
// Variable only in file - add it as non-secret
variables.push({ key, value: fileValue, isSecret: false });
}
}
return json({ variables });
} catch (error) {
console.error('Error getting stack env vars:', error);
return json({ error: 'Failed to get environment variables' }, { status: 500 });
@@ -114,6 +236,32 @@ export const PUT: RequestHandler = async ({ params, url, cookies, request }) =>
await setStackEnvVars(stackName, envIdNum, variablesToSave);
// Also write the .env file to the stack directory
// This allows users to see/edit variables outside of Dockhand
const stacksDir = getStacksDir();
const stackDir = join(stacksDir, stackName);
const envFilePath = join(stackDir, '.env');
// Only write if stack directory exists
if (existsSync(stackDir)) {
// Read existing file to preserve comments and formatting
let existingContent = '';
if (existsSync(envFilePath)) {
try {
existingContent = await Bun.file(envFilePath).text();
} catch {
// File read failed, start fresh
}
}
// Merge UI vars with existing file (preserves comments, keeps file vars)
const envContent = mergeEnvFileContent(
existingContent,
variablesToSave.map((v: { key: string; value: string }) => ({ key: v.key, value: v.value }))
);
await Bun.write(envFilePath, envContent);
}
return json({ success: true, count: variablesToSave.length });
} catch (error) {
console.error('Error setting stack env vars:', error);
+8 -2
View File
@@ -128,9 +128,15 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
}, { status: 201 });
} catch (error: any) {
console.error('Failed to create user:', error);
if (error.message?.includes('UNIQUE constraint failed')) {
console.error('Error details:', {
message: error.message,
code: error.code,
name: error.name,
stack: error.stack
});
if (error.message?.includes('UNIQUE constraint failed') || error.code === '23505') {
return json({ error: 'Username already exists' }, { status: 409 });
}
return json({ error: 'Failed to create user' }, { status: 500 });
return json({ error: 'Failed to create user', details: error.message }, { status: 500 });
}
};
+7 -15
View File
@@ -1,9 +1,7 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from '@sveltejs/kit';
import { isEnterprise } from '$lib/server/license';
import {
validateSession,
checkPermission,
generateMfaSetup,
verifyAndEnableMfa,
disableMfa
@@ -11,11 +9,6 @@ import {
// POST /api/users/[id]/mfa - Setup MFA (generate QR code)
export const POST: RequestHandler = async ({ params, request, cookies }) => {
// Check enterprise license
if (!(await isEnterprise())) {
return json({ error: 'Enterprise license required' }, { status: 403 });
}
const currentUser = await validateSession(cookies);
if (!params.id) {
@@ -38,12 +31,16 @@ export const POST: RequestHandler = async ({ params, request, cookies }) => {
return json({ error: 'MFA token is required' }, { status: 400 });
}
const success = await verifyAndEnableMfa(userId, body.token);
if (!success) {
const result = await verifyAndEnableMfa(userId, body.token);
if (!result.success) {
return json({ error: 'Invalid MFA code' }, { status: 400 });
}
return json({ success: true, message: 'MFA enabled successfully' });
return json({
success: true,
message: 'MFA enabled successfully',
backupCodes: result.backupCodes
});
}
// Generate new MFA setup
@@ -64,11 +61,6 @@ export const POST: RequestHandler = async ({ params, request, cookies }) => {
// DELETE /api/users/[id]/mfa - Disable MFA
export const DELETE: RequestHandler = async ({ params, cookies }) => {
// Check enterprise license
if (!(await isEnterprise())) {
return json({ error: 'Enterprise license required' }, { status: 403 });
}
const currentUser = await validateSession(cookies);
if (!params.id) {