This commit is contained in:
jarek committed 2026-01-28 07:34:12 +01:00
1 parent a88d3d5788
commit 8f3a7eb435
96 files changed
+5235 -1870

No files matched your search

+6 -1
View File
@@ -2,6 +2,7 @@ import { json } from '@sveltejs/kit';
import type { RequestHandler } from '@sveltejs/kit';
import { authorize } from '$lib/server/authorize';
import { getLdapConfigs, createLdapConfig } from '$lib/server/db';
import { auditLdapConfig } from '$lib/server/audit';
// GET /api/auth/ldap - List all LDAP configurations
export const GET: RequestHandler = async ({ cookies }) => {
@@ -31,7 +32,8 @@ export const GET: RequestHandler = async ({ cookies }) => {
};
// POST /api/auth/ldap - Create a new LDAP configuration
export const POST: RequestHandler = async ({ request, cookies }) => {
export const POST: RequestHandler = async (event) => {
const { request, cookies } = event;
const auth = await authorize(cookies);
// Allow access when auth is disabled (setup mode) or when user is admin
@@ -70,6 +72,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
tlsCa: data.tlsCa || undefined
});
// Audit log
await auditLdapConfig(event, 'create', config.id, config.name);
return json({
...config,
bindPassword: config.bindPassword ? '********' : undefined
+25 -4
View File
@@ -2,6 +2,8 @@ import { json } from '@sveltejs/kit';
import type { RequestHandler } from '@sveltejs/kit';
import { authorize } from '$lib/server/authorize';
import { getLdapConfig, updateLdapConfig, deleteLdapConfig } from '$lib/server/db';
import { auditLdapConfig } from '$lib/server/audit';
import { computeAuditDiff } from '$lib/utils/diff';
// GET /api/auth/ldap/[id] - Get a specific LDAP configuration
export const GET: RequestHandler = async ({ params, cookies }) => {
@@ -38,7 +40,8 @@ export const GET: RequestHandler = async ({ params, cookies }) => {
};
// PUT /api/auth/ldap/[id] - Update a LDAP configuration
export const PUT: RequestHandler = async ({ params, request, cookies }) => {
export const PUT: RequestHandler = async (event) => {
const { params, request, cookies } = event;
const auth = await authorize(cookies);
// Allow access when auth is disabled (setup mode) or when user is admin
@@ -89,6 +92,14 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => {
return json({ error: 'Failed to update configuration' }, { status: 500 });
}
// Compute diff for audit (exclude sensitive fields)
const diff = computeAuditDiff(existing, config, {
excludeFields: ['bindPassword', 'tlsCa', 'createdAt', 'updatedAt']
});
// Audit log
await auditLdapConfig(event, 'update', config.id, config.name, diff);
return json({
...config,
bindPassword: config.bindPassword ? '********' : undefined
@@ -100,7 +111,8 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => {
};
// DELETE /api/auth/ldap/[id] - Delete a LDAP configuration
export const DELETE: RequestHandler = async ({ params, cookies }) => {
export const DELETE: RequestHandler = async (event) => {
const { params, cookies } = event;
const auth = await authorize(cookies);
// Allow access when auth is disabled (setup mode) or when user is admin
@@ -118,11 +130,20 @@ export const DELETE: RequestHandler = async ({ params, cookies }) => {
}
try {
const deleted = await deleteLdapConfig(id);
if (!deleted) {
// Get config before deletion for audit
const config = await getLdapConfig(id);
if (!config) {
return json({ error: 'LDAP configuration not found' }, { status: 404 });
}
const deleted = await deleteLdapConfig(id);
if (!deleted) {
return json({ error: 'Failed to delete LDAP configuration' }, { status: 500 });
}
// Audit log
await auditLdapConfig(event, 'delete', id, config.name);
return json({ success: true });
} catch (error) {
console.error('Failed to delete LDAP config:', error);
+5
View File
@@ -41,6 +41,11 @@ export const POST: RequestHandler = async (event) => {
);
}
// Reject local login attempts when DISABLE_LOCAL_LOGIN is set
if (provider === 'local' && process.env.DISABLE_LOCAL_LOGIN === 'true') {
return json({ error: 'Local login is disabled' }, { status: 403 });
}
// Attempt authentication based on provider
let result: any;
let authProviderType: 'local' | 'ldap' | 'oidc' = 'local';
+6 -1
View File
@@ -6,6 +6,7 @@ import {
createOidcConfig,
type OidcConfig
} from '$lib/server/db';
import { auditOidcProvider } from '$lib/server/audit';
// GET /api/auth/oidc - List all OIDC configurations
export const GET: RequestHandler = async ({ cookies }) => {
@@ -36,7 +37,8 @@ export const GET: RequestHandler = async ({ cookies }) => {
};
// POST /api/auth/oidc - Create new OIDC configuration
export const POST: RequestHandler = async ({ request, cookies }) => {
export const POST: RequestHandler = async (event) => {
const { request, cookies } = event;
const auth = await authorize(cookies);
// When auth is enabled, require authentication and settings:edit permission
@@ -77,6 +79,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
roleMappings: data.roleMappings || undefined
});
// Audit log
await auditOidcProvider(event, 'create', config.id, config.name);
return json({
...config,
clientSecret: '********'
+25 -4
View File
@@ -6,6 +6,8 @@ import {
updateOidcConfig,
deleteOidcConfig
} from '$lib/server/db';
import { auditOidcProvider } from '$lib/server/audit';
import { computeAuditDiff } from '$lib/utils/diff';
// GET /api/auth/oidc/[id] - Get specific OIDC configuration
export const GET: RequestHandler = async ({ params, cookies }) => {
@@ -43,7 +45,8 @@ export const GET: RequestHandler = async ({ params, cookies }) => {
};
// PUT /api/auth/oidc/[id] - Update OIDC configuration
export const PUT: RequestHandler = async ({ params, request, cookies }) => {
export const PUT: RequestHandler = async (event) => {
const { params, request, cookies } = event;
const auth = await authorize(cookies);
// When auth is enabled, require authentication and settings:edit permission
@@ -93,6 +96,14 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => {
return json({ error: 'Failed to update OIDC configuration' }, { status: 500 });
}
// Compute diff for audit (exclude sensitive fields)
const diff = computeAuditDiff(existing, config, {
excludeFields: ['clientSecret', 'createdAt', 'updatedAt']
});
// Audit log
await auditOidcProvider(event, 'update', config.id, config.name, diff);
return json({
...config,
clientSecret: config.clientSecret ? '********' : ''
@@ -104,7 +115,8 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => {
};
// DELETE /api/auth/oidc/[id] - Delete OIDC configuration
export const DELETE: RequestHandler = async ({ params, cookies }) => {
export const DELETE: RequestHandler = async (event) => {
const { params, cookies } = event;
const auth = await authorize(cookies);
// When auth is enabled, require authentication and settings:edit permission
@@ -123,11 +135,20 @@ export const DELETE: RequestHandler = async ({ params, cookies }) => {
}
try {
const deleted = await deleteOidcConfig(id);
if (!deleted) {
// Get config before deletion for audit
const config = await getOidcConfig(id);
if (!config) {
return json({ error: 'OIDC configuration not found' }, { status: 404 });
}
const deleted = await deleteOidcConfig(id);
if (!deleted) {
return json({ error: 'Failed to delete OIDC configuration' }, { status: 500 });
}
// Audit log
await auditOidcProvider(event, 'delete', id, config.name);
return json({ success: true });
} catch (error) {
console.error('Failed to delete OIDC config:', error);
+8 -3
View File
@@ -21,8 +21,10 @@ export const GET: RequestHandler = async () => {
const providers: { id: string; name: string; type: 'local' | 'ldap' | 'oidc'; initiateUrl?: string }[] = [];
// Local auth is always available when auth is enabled
providers.push({ id: 'local', name: 'Local', type: 'local' });
// Local auth is available unless DISABLE_LOCAL_LOGIN is set
if (process.env.DISABLE_LOCAL_LOGIN !== 'true') {
providers.push({ id: 'local', name: 'Local', type: 'local' });
}
// Add enabled LDAP providers (enterprise only)
for (const config of ldapConfigs) {
@@ -49,6 +51,9 @@ export const GET: RequestHandler = async () => {
});
} catch (error) {
console.error('Failed to get auth providers:', error);
return json({ providers: [{ id: 'local', name: 'Local', type: 'local' }] });
const fallbackProviders = process.env.DISABLE_LOCAL_LOGIN === 'true'
? []
: [{ id: 'local', name: 'Local', type: 'local' }];
return json({ providers: fallbackProviders });
}
};
+6 -1
View File
@@ -2,6 +2,7 @@ import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getConfigSets, createConfigSet } from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import { auditConfigSet } from '$lib/server/audit';
export const GET: RequestHandler = async ({ cookies }) => {
const auth = await authorize(cookies);
@@ -18,7 +19,8 @@ export const GET: RequestHandler = async ({ cookies }) => {
}
};
export const POST: RequestHandler = async ({ request, cookies }) => {
export const POST: RequestHandler = async (event) => {
const { request, cookies } = event;
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('configsets', 'create')) {
return json({ error: 'Permission denied' }, { status: 403 });
@@ -42,6 +44,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
restartPolicy: body.restartPolicy || 'no'
});
// Audit log
await auditConfigSet(event, 'create', configSet.id, configSet.name);
return json(configSet, { status: 201 });
} catch (error: any) {
console.error('Failed to create config set:', error);
+29 -4
View File
@@ -2,6 +2,8 @@ import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getConfigSet, updateConfigSet, deleteConfigSet } from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import { auditConfigSet } from '$lib/server/audit';
import { computeAuditDiff } from '$lib/utils/diff';
export const GET: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
@@ -27,7 +29,8 @@ export const GET: RequestHandler = async ({ params, cookies }) => {
}
};
export const PUT: RequestHandler = async ({ params, request, cookies }) => {
export const PUT: RequestHandler = async (event) => {
const { params, request, cookies } = event;
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('configsets', 'edit')) {
return json({ error: 'Permission denied' }, { status: 403 });
@@ -39,6 +42,12 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => {
return json({ error: 'Invalid ID' }, { status: 400 });
}
// Get old values before update for diff
const oldConfigSet = await getConfigSet(id);
if (!oldConfigSet) {
return json({ error: 'Config set not found' }, { status: 404 });
}
const body = await request.json();
const configSet = await updateConfigSet(id, {
@@ -56,6 +65,12 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => {
return json({ error: 'Config set not found' }, { status: 404 });
}
// Compute diff for audit
const diff = computeAuditDiff(oldConfigSet, configSet);
// Audit log
await auditConfigSet(event, 'update', configSet.id, configSet.name, diff);
return json(configSet);
} catch (error: any) {
console.error('Failed to update config set:', error);
@@ -66,7 +81,8 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => {
}
};
export const DELETE: RequestHandler = async ({ params, cookies }) => {
export const DELETE: RequestHandler = async (event) => {
const { params, cookies } = event;
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('configsets', 'delete')) {
return json({ error: 'Permission denied' }, { status: 403 });
@@ -78,11 +94,20 @@ export const DELETE: RequestHandler = async ({ params, cookies }) => {
return json({ error: 'Invalid ID' }, { status: 400 });
}
const deleted = await deleteConfigSet(id);
if (!deleted) {
// Get config set name before deletion for audit log
const configSet = await getConfigSet(id);
if (!configSet) {
return json({ error: 'Config set not found' }, { status: 404 });
}
const deleted = await deleteConfigSet(id);
if (!deleted) {
return json({ error: 'Failed to delete config set' }, { status: 500 });
}
// Audit log
await auditConfigSet(event, 'delete', id, configSet.name);
return json({ success: true });
} catch (error) {
console.error('Failed to delete config set:', error);
+10 -3
View File
@@ -1,7 +1,8 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getEnvironments, getEnvironmentByName, createEnvironment, assignUserRole, getRoleByName, getEnvironmentPublicIps, setEnvironmentPublicIp, getEnvUpdateCheckSettings, getEnvironmentTimezone, type Environment } from '$lib/server/db';
import { getEnvironments, getEnvironmentByName, createEnvironment, assignUserRole, getRoleByName, getEnvironmentPublicIps, setEnvironmentPublicIp, getEnvUpdateCheckSettings, getEnvironmentTimezone, getImagePruneSettings, type Environment } from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import { auditEnvironment } from '$lib/server/audit';
import { refreshSubprocessEnvironments } from '$lib/server/subprocess-manager';
import { serializeLabels, parseLabels, MAX_LABELS } from '$lib/utils/label-colors';
import { cleanPem } from '$lib/utils/pem';
@@ -36,16 +37,18 @@ export const GET: RequestHandler = async ({ cookies }) => {
}
}
// Parse labels from JSON string to array, add public IPs, update check settings, and timezone
// Parse labels from JSON string to array, add public IPs, update check settings, image prune settings, and timezone
const envWithParsedLabels = await Promise.all(environments.map(async env => {
const updateSettings = updateCheckSettingsMap.get(env.id);
const timezone = await getEnvironmentTimezone(env.id);
const imagePruneSettings = await getImagePruneSettings(env.id);
return {
...env,
labels: parseLabels(env.labels as string | null),
publicIp: publicIps[env.id.toString()] || null,
updateCheckEnabled: updateSettings?.enabled || false,
updateCheckAutoUpdate: updateSettings?.autoUpdate || false,
imagePruneEnabled: imagePruneSettings?.enabled || false,
timezone
};
}));
@@ -57,7 +60,8 @@ export const GET: RequestHandler = async ({ cookies }) => {
}
};
export const POST: RequestHandler = async ({ request, cookies }) => {
export const POST: RequestHandler = async (event) => {
const { request, cookies } = event;
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('environments', 'create')) {
return json({ error: 'Permission denied' }, { status: 403 });
@@ -128,6 +132,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
}
}
// Audit log
await auditEnvironment(event, 'create', env.id, env.name);
return json(env);
} catch (error) {
console.error('Failed to create environment:', error);
+36 -4
View File
@@ -1,14 +1,16 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getEnvironment, updateEnvironment, deleteEnvironment, getEnvironmentPublicIps, setEnvironmentPublicIp, deleteEnvironmentPublicIp, deleteEnvUpdateCheckSettings, getGitStacksForEnvironmentOnly, deleteGitStack } from '$lib/server/db';
import { getEnvironment, updateEnvironment, deleteEnvironment, getEnvironmentPublicIps, setEnvironmentPublicIp, deleteEnvironmentPublicIp, deleteEnvUpdateCheckSettings, deleteImagePruneSettings, getGitStacksForEnvironmentOnly, deleteGitStack } from '$lib/server/db';
import { clearDockerClientCache } from '$lib/server/docker';
import { deleteGitStackFiles } from '$lib/server/git';
import { authorize } from '$lib/server/authorize';
import { auditEnvironment } from '$lib/server/audit';
import { refreshSubprocessEnvironments } from '$lib/server/subprocess-manager';
import { serializeLabels, parseLabels, MAX_LABELS } from '$lib/utils/label-colors';
import { cleanPem } from '$lib/utils/pem';
import { unregisterSchedule } from '$lib/server/scheduler';
import { closeEdgeConnection } from '$lib/server/hawser';
import { computeAuditDiff } from '$lib/utils/diff';
export const GET: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
@@ -40,7 +42,8 @@ export const GET: RequestHandler = async ({ params, cookies }) => {
}
};
export const PUT: RequestHandler = async ({ params, request, cookies }) => {
export const PUT: RequestHandler = async (event) => {
const { params, request, cookies } = event;
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('environments', 'edit')) {
return json({ error: 'Permission denied' }, { status: 403 });
@@ -48,6 +51,13 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => {
try {
const id = parseInt(params.id);
// Get old values before update for diff
const oldEnv = await getEnvironment(id);
if (!oldEnv) {
return json({ error: 'Environment not found' }, { status: 404 });
}
const data = await request.json();
// Clear cached Docker client before updating
@@ -95,6 +105,14 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => {
const publicIps = await getEnvironmentPublicIps();
const publicIp = publicIps[id.toString()] || null;
// Compute diff for audit (exclude sensitive TLS fields)
const diff = computeAuditDiff(oldEnv, env, {
excludeFields: ['tlsCa', 'tlsCert', 'tlsKey', 'hawserToken', 'labels']
});
// Audit log
await auditEnvironment(event, 'update', env.id, env.name, diff);
// Parse labels from JSON string to array
return json({
...env,
@@ -107,7 +125,8 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => {
}
};
export const DELETE: RequestHandler = async ({ params, cookies }) => {
export const DELETE: RequestHandler = async (event) => {
const { params, cookies } = event;
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('environments', 'delete')) {
return json({ error: 'Permission denied' }, { status: 403 });
@@ -116,6 +135,12 @@ export const DELETE: RequestHandler = async ({ params, cookies }) => {
try {
const id = parseInt(params.id);
// Get environment name before deletion for audit log
const env = await getEnvironment(id);
if (!env) {
return json({ error: 'Environment not found' }, { status: 404 });
}
// Close Edge connection if this is a Hawser Edge environment
// This rejects any pending requests and closes the WebSocket
closeEdgeConnection(id);
@@ -131,7 +156,7 @@ export const DELETE: RequestHandler = async ({ params, cookies }) => {
unregisterSchedule(stack.id, 'git_stack_sync');
}
// Delete git stack files from filesystem
deleteGitStackFiles(stack.id);
await deleteGitStackFiles(stack.id, stack.stackName, stack.environmentId);
// Delete git stack from database
await deleteGitStack(stack.id);
}
@@ -149,9 +174,16 @@ export const DELETE: RequestHandler = async ({ params, cookies }) => {
await deleteEnvUpdateCheckSettings(id);
unregisterSchedule(id, 'env_update_check');
// Clean up image prune settings and unregister schedule
await deleteImagePruneSettings(id);
unregisterSchedule(id, 'image_prune');
// Notify subprocesses to stop collecting from deleted environment
refreshSubprocessEnvironments();
// Audit log
await auditEnvironment(event, 'delete', id, env.name);
return json({ success: true });
} catch (error) {
console.error('Failed to delete environment:', error);
@@ -0,0 +1,121 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import {
getImagePruneSettings,
setImagePruneSettings,
getEnvironment
} from '$lib/server/db';
import { registerSchedule, unregisterSchedule, triggerImagePrune } from '$lib/server/scheduler';
/**
* Get image prune settings for an environment.
*/
export const GET: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('environments', 'view')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const id = parseInt(params.id);
// Verify environment exists
const env = await getEnvironment(id);
if (!env) {
return json({ error: 'Environment not found' }, { status: 404 });
}
const settings = await getImagePruneSettings(id);
return json({
settings: settings || {
enabled: false,
cronExpression: '0 3 * * 0', // Default: 3 AM Sunday
pruneMode: 'dangling'
}
});
} catch (error) {
console.error('Failed to get image prune settings:', error);
return json({ error: 'Failed to get image prune settings' }, { status: 500 });
}
};
/**
* Save image prune settings for an environment.
*/
export const POST: RequestHandler = async ({ params, request, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('environments', 'edit')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const id = parseInt(params.id);
// Verify environment exists
const env = await getEnvironment(id);
if (!env) {
return json({ error: 'Environment not found' }, { status: 404 });
}
const data = await request.json();
// Get existing settings to preserve lastPruned and lastResult
const existingSettings = await getImagePruneSettings(id);
const settings = {
enabled: data.enabled ?? false,
cronExpression: data.cronExpression || '0 3 * * 0',
pruneMode: data.pruneMode || 'dangling',
lastPruned: existingSettings?.lastPruned,
lastResult: existingSettings?.lastResult
};
// Save settings to database
await setImagePruneSettings(id, settings);
// Register or unregister schedule based on enabled state
if (settings.enabled) {
await registerSchedule(id, 'image_prune', id);
} else {
unregisterSchedule(id, 'image_prune');
}
return json({ success: true, settings });
} catch (error) {
console.error('Failed to save image prune settings:', error);
return json({ error: 'Failed to save image prune settings' }, { status: 500 });
}
};
/**
* Manually trigger image prune for an environment.
*/
export const PUT: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('environments', 'edit')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const id = parseInt(params.id);
// Verify environment exists
const env = await getEnvironment(id);
if (!env) {
return json({ error: 'Environment not found' }, { status: 404 });
}
const result = await triggerImagePrune(id);
if (!result.success) {
return json({ error: result.error }, { status: 400 });
}
return json({ success: true });
} catch (error) {
console.error('Failed to trigger image prune:', error);
return json({ error: 'Failed to trigger image prune' }, { status: 500 });
}
};
+6 -1
View File
@@ -6,6 +6,7 @@ import {
type GitAuthType
} from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import { auditGitCredential } from '$lib/server/audit';
export const GET: RequestHandler = async ({ cookies }) => {
const auth = await authorize(cookies);
@@ -33,7 +34,8 @@ export const GET: RequestHandler = async ({ cookies }) => {
}
};
export const POST: RequestHandler = async ({ request, cookies }) => {
export const POST: RequestHandler = async (event) => {
const { request, cookies } = event;
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('git', 'create')) {
return json({ error: 'Permission denied' }, { status: 403 });
@@ -68,6 +70,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
sshPassphrase: data.sshPassphrase
});
// Audit log
await auditGitCredential(event, 'create', credential.id, credential.name);
return json({
id: credential.id,
name: credential.name,
+26 -4
View File
@@ -7,6 +7,8 @@ import {
type GitAuthType
} from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import { auditGitCredential } from '$lib/server/audit';
import { computeAuditDiff } from '$lib/utils/diff';
export const GET: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
@@ -42,7 +44,8 @@ export const GET: RequestHandler = async ({ params, cookies }) => {
}
};
export const PUT: RequestHandler = async ({ params, request, cookies }) => {
export const PUT: RequestHandler = async (event) => {
const { params, request, cookies } = event;
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('git', 'edit')) {
return json({ error: 'Permission denied' }, { status: 403 });
@@ -78,6 +81,15 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => {
return json({ error: 'Failed to update credential' }, { status: 500 });
}
// Compute diff for audit (only non-sensitive fields)
const diff = computeAuditDiff(
{ name: existing.name, authType: existing.authType, username: existing.username },
{ name: credential.name, authType: credential.authType, username: credential.username }
);
// Audit log
await auditGitCredential(event, 'update', credential.id, credential.name, diff);
return json({
id: credential.id,
name: credential.name,
@@ -97,7 +109,8 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => {
}
};
export const DELETE: RequestHandler = async ({ params, cookies }) => {
export const DELETE: RequestHandler = async (event) => {
const { params, cookies } = event;
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('git', 'delete')) {
return json({ error: 'Permission denied' }, { status: 403 });
@@ -109,11 +122,20 @@ export const DELETE: RequestHandler = async ({ params, cookies }) => {
return json({ error: 'Invalid credential ID' }, { status: 400 });
}
const deleted = await deleteGitCredential(id);
if (!deleted) {
// Get credential name before deletion for audit log
const credential = await getGitCredential(id);
if (!credential) {
return json({ error: 'Credential not found' }, { status: 404 });
}
const deleted = await deleteGitCredential(id);
if (!deleted) {
return json({ error: 'Failed to delete credential' }, { status: 500 });
}
// Audit log
await auditGitCredential(event, 'delete', id, credential.name);
return json({ success: true });
} catch (error) {
console.error('Failed to delete git credential:', error);
+92
View File
@@ -0,0 +1,92 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getGitRepository, getGitCredential } from '$lib/server/db';
import { previewRepoEnvFiles } from '$lib/server/git';
import { authorize } from '$lib/server/authorize';
/**
* POST /api/git/preview-env
* Clone a git repository to a temp directory and read env files for preview.
* Used when creating a new git stack to populate the env editor.
*
* Body: {
* repositoryId?: number, // Existing repository
* url?: string, // OR new repo URL
* branch?: string, // Branch (default: main)
* credentialId?: number, // Credential for auth
* composePath: string, // Path to compose file
* envFilePath?: string // Optional additional env file
* }
*
* Returns: {
* vars: Record<string, string>, // Merged env variables
* sources: { // Which file each var came from
* [key: string]: '.env' | 'envFile'
* },
* error?: string
* }
*/
export const POST: RequestHandler = async ({ request, cookies }) => {
const auth = await authorize(cookies);
// Basic permission check - must be able to create stacks
if (auth.authEnabled && !auth.isAuthenticated) {
return json({ error: 'Authentication required' }, { status: 401 });
}
try {
const data = await request.json();
if (!data.composePath || typeof data.composePath !== 'string') {
return json({ error: 'Compose path is required' }, { status: 400 });
}
let repoUrl: string;
let branch: string = 'main';
let credentialId: number | null = null;
if (data.repositoryId) {
// Use existing repository
const repo = await getGitRepository(data.repositoryId);
if (!repo) {
return json({ error: 'Repository not found' }, { status: 404 });
}
repoUrl = repo.url;
branch = repo.branch;
credentialId = repo.credentialId;
} else if (data.url) {
// New repository details
repoUrl = data.url;
branch = data.branch || 'main';
credentialId = data.credentialId || null;
} else {
return json({ error: 'Either repositoryId or url is required' }, { status: 400 });
}
// Get credential if specified
let credential = null;
if (credentialId) {
credential = await getGitCredential(credentialId);
}
const result = await previewRepoEnvFiles({
repoUrl,
branch,
credential,
composePath: data.composePath,
envFilePath: data.envFilePath || null
});
if (result.error) {
return json({ vars: {}, sources: {}, error: result.error }, { status: 400 });
}
return json({
vars: result.vars,
sources: result.sources
});
} catch (error: any) {
console.error('Failed to preview env files:', error);
return json({ error: error.message || 'Failed to preview env files' }, { status: 500 });
}
};
+6 -1
View File
@@ -6,6 +6,7 @@ import {
getGitCredentials
} from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import { auditGitRepository } from '$lib/server/audit';
export const GET: RequestHandler = async ({ url, cookies }) => {
const auth = await authorize(cookies);
@@ -24,7 +25,8 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
}
};
export const POST: RequestHandler = async ({ request, cookies }) => {
export const POST: RequestHandler = async (event) => {
const { request, cookies } = event;
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('git', 'create')) {
return json({ error: 'Permission denied' }, { status: 403 });
@@ -59,6 +61,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
credentialId: data.credentialId || null
});
// Audit log
await auditGitRepository(event, 'create', repository.id, repository.name);
return json(repository);
} catch (error: any) {
console.error('Failed to create git repository:', error);
@@ -8,6 +8,8 @@ import {
} from '$lib/server/db';
import { deleteRepositoryFiles } from '$lib/server/git';
import { authorize } from '$lib/server/authorize';
import { auditGitRepository } from '$lib/server/audit';
import { computeAuditDiff } from '$lib/utils/diff';
export const GET: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
@@ -33,7 +35,8 @@ export const GET: RequestHandler = async ({ params, cookies }) => {
}
};
export const PUT: RequestHandler = async ({ params, request, cookies }) => {
export const PUT: RequestHandler = async (event) => {
const { params, request, cookies } = event;
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('git', 'edit')) {
return json({ error: 'Permission denied' }, { status: 403 });
@@ -74,6 +77,12 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => {
return json({ error: 'Failed to update repository' }, { status: 500 });
}
// Compute diff for audit
const diff = computeAuditDiff(existing, repository);
// Audit log
await auditGitRepository(event, 'update', repository.id, repository.name, diff);
return json(repository);
} catch (error: any) {
console.error('Failed to update git repository:', error);
@@ -84,7 +93,8 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => {
}
};
export const DELETE: RequestHandler = async ({ params, cookies }) => {
export const DELETE: RequestHandler = async (event) => {
const { params, cookies } = event;
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('git', 'delete')) {
return json({ error: 'Permission denied' }, { status: 403 });
@@ -96,14 +106,23 @@ export const DELETE: RequestHandler = async ({ params, cookies }) => {
return json({ error: 'Invalid repository ID' }, { status: 400 });
}
// Get repository name before deletion for audit log
const repository = await getGitRepository(id);
if (!repository) {
return json({ error: 'Repository not found' }, { status: 404 });
}
// Delete repository files first
deleteRepositoryFiles(id);
const deleted = await deleteGitRepository(id);
if (!deleted) {
return json({ error: 'Repository not found' }, { status: 404 });
return json({ error: 'Failed to delete repository' }, { status: 500 });
}
// Audit log
await auditGitRepository(event, 'delete', id, repository.name);
return json({ success: true });
} catch (error) {
console.error('Failed to delete git repository:', error);
+22 -2
View File
@@ -6,12 +6,14 @@ import {
getGitCredentials,
getGitRepository,
createGitRepository,
upsertStackSource
upsertStackSource,
setStackEnvVars
} from '$lib/server/db';
import { deployGitStack } from '$lib/server/git';
import { authorize } from '$lib/server/authorize';
import { registerSchedule } from '$lib/server/scheduler';
import { secureRandomBytes } from '$lib/server/crypto-fallback';
import { auditGitStack } from '$lib/server/audit';
// Stack name validation: must start with alphanumeric, can contain alphanumeric, hyphens, underscores
const STACK_NAME_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/;
@@ -37,7 +39,8 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
}
};
export const POST: RequestHandler = async ({ request, cookies }) => {
export const POST: RequestHandler = async (event) => {
const { request, cookies } = event;
const auth = await authorize(cookies);
try {
@@ -132,9 +135,26 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
await registerSchedule(gitStack.id, 'git_stack_sync', gitStack.environmentId);
}
// Audit log
await auditGitStack(event, 'create', gitStack.id, gitStack.stackName, gitStack.environmentId);
// Save environment variable overrides before deploying
if (data.envVars && Array.isArray(data.envVars) && data.envVars.length > 0) {
await setStackEnvVars(
trimmedStackName,
data.environmentId || null,
data.envVars.filter((v: any) => v.key?.trim()).map((v: any) => ({
key: v.key.trim(),
value: v.value ?? '',
isSecret: v.isSecret ?? false
}))
);
}
// If deployNow is set, deploy immediately
if (data.deployNow) {
const deployResult = await deployGitStack(gitStack.id);
await auditGitStack(event, 'deploy', gitStack.id, gitStack.stackName, gitStack.environmentId);
return json({
...gitStack,
deployResult: deployResult
+35 -4
View File
@@ -1,9 +1,11 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getGitStack, updateGitStack, deleteGitStack, deleteStackSource, updateStackSourceName, updateStackEnvVarsName } from '$lib/server/db';
import { getGitStack, updateGitStack, deleteGitStack, deleteStackSource, updateStackSourceName, updateStackEnvVarsName, setStackEnvVars } from '$lib/server/db';
import { deleteGitStackFiles, deployGitStack } from '$lib/server/git';
import { authorize } from '$lib/server/authorize';
import { registerSchedule, unregisterSchedule } from '$lib/server/scheduler';
import { auditGitStack } from '$lib/server/audit';
import { computeAuditDiff } from '$lib/utils/diff';
// Stack name validation: must start with alphanumeric, can contain alphanumeric, hyphens, underscores
const STACK_NAME_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/;
@@ -30,7 +32,8 @@ export const GET: RequestHandler = async ({ params, cookies }) => {
}
};
export const PUT: RequestHandler = async ({ params, request, cookies }) => {
export const PUT: RequestHandler = async (event) => {
const { params, request, cookies } = event;
const auth = await authorize(cookies);
try {
@@ -84,9 +87,33 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => {
unregisterSchedule(id, 'git_stack_sync');
}
// Compute diff for audit (exclude sensitive fields)
const diff = computeAuditDiff(existing, updated, {
excludeFields: ['webhookSecret', 'createdAt', 'updatedAt', 'lastSync', 'lastCommit', 'syncStatus', 'syncError']
});
// Audit log
await auditGitStack(event, 'update', updated.id, updated.stackName, updated.environmentId, diff);
// Save environment variable overrides before deploying
if (data.envVars && Array.isArray(data.envVars)) {
const stackName = data.stackName || existing.stackName;
const envId = updated.environmentId ?? null;
await setStackEnvVars(
stackName,
envId,
data.envVars.filter((v: any) => v.key?.trim()).map((v: any) => ({
key: v.key.trim(),
value: v.value ?? '',
isSecret: v.isSecret ?? false
}))
);
}
// If deployNow is set, deploy after saving
if (data.deployNow) {
const deployResult = await deployGitStack(id);
await auditGitStack(event, 'deploy', updated.id, updated.stackName, updated.environmentId);
return json({
...updated,
deployResult
@@ -103,7 +130,8 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => {
}
};
export const DELETE: RequestHandler = async ({ params, cookies }) => {
export const DELETE: RequestHandler = async (event) => {
const { params, cookies } = event;
const auth = await authorize(cookies);
try {
@@ -122,7 +150,7 @@ export const DELETE: RequestHandler = async ({ params, cookies }) => {
unregisterSchedule(id, 'git_stack_sync');
// Delete git files first
deleteGitStackFiles(id);
await deleteGitStackFiles(id, existing.stackName, existing.environmentId);
// Delete the stack_sources record to free up the stack name
await deleteStackSource(existing.stackName, existing.environmentId);
@@ -130,6 +158,9 @@ export const DELETE: RequestHandler = async ({ params, cookies }) => {
// Delete from database
await deleteGitStack(id);
// Audit log
await auditGitStack(event, 'delete', id, existing.stackName, existing.environmentId);
return json({ success: true });
} catch (error) {
console.error('Failed to delete git stack:', error);
@@ -3,8 +3,10 @@ import type { RequestHandler } from './$types';
import { getGitStack } from '$lib/server/db';
import { deployGitStack } from '$lib/server/git';
import { authorize } from '$lib/server/authorize';
import { auditGitStack } from '$lib/server/audit';
export const POST: RequestHandler = async ({ params, cookies }) => {
export const POST: RequestHandler = async (event) => {
const { params, cookies } = event;
const auth = await authorize(cookies);
try {
@@ -20,6 +22,10 @@ export const POST: RequestHandler = async ({ params, cookies }) => {
}
const result = await deployGitStack(id);
// Audit log
await auditGitStack(event, 'deploy', id, gitStack.stackName, gitStack.environmentId);
return json(result);
} catch (error) {
console.error('Failed to deploy git stack:', error);
+6 -1
View File
@@ -7,6 +7,7 @@ import {
type NotificationEventType
} from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import { auditNotification } from '$lib/server/audit';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ cookies }) => {
@@ -32,7 +33,8 @@ export const GET: RequestHandler = async ({ cookies }) => {
}
};
export const POST: RequestHandler = async ({ request, cookies }) => {
export const POST: RequestHandler = async (event) => {
const { request, cookies } = event;
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('notifications', 'create')) {
return json({ error: 'Permission denied' }, { status: 403 });
@@ -73,6 +75,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
eventTypes: resolvedEventTypes as NotificationEventType[]
});
// Audit log
await auditNotification(event, 'create', setting.id, setting.name);
return json(setting);
} catch (error: any) {
console.error('Error creating notification setting:', error);
+26 -4
View File
@@ -8,6 +8,8 @@ import {
type NotificationEventType
} from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import { auditNotification } from '$lib/server/audit';
import { computeAuditDiff } from '$lib/utils/diff';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ params, cookies }) => {
@@ -43,7 +45,8 @@ export const GET: RequestHandler = async ({ params, cookies }) => {
}
};
export const PUT: RequestHandler = async ({ params, request, cookies }) => {
export const PUT: RequestHandler = async (event) => {
const { params, request, cookies } = event;
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('notifications', 'edit')) {
return json({ error: 'Permission denied' }, { status: 403 });
@@ -94,6 +97,15 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => {
return json({ error: 'Failed to update notification setting' }, { status: 500 });
}
// Compute diff for audit (exclude config to avoid logging sensitive data)
const diff = computeAuditDiff(
{ name: existing.name, enabled: existing.enabled, eventTypes: existing.eventTypes },
{ name: updated.name, enabled: updated.enabled, eventTypes: updated.eventTypes }
);
// Audit log
await auditNotification(event, 'update', updated.id, updated.name, diff);
// Don't expose passwords in response
const safeSetting = {
...updated,
@@ -110,7 +122,8 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => {
}
};
export const DELETE: RequestHandler = async ({ params, cookies }) => {
export const DELETE: RequestHandler = async (event) => {
const { params, cookies } = event;
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('notifications', 'delete')) {
return json({ error: 'Permission denied' }, { status: 403 });
@@ -122,11 +135,20 @@ export const DELETE: RequestHandler = async ({ params, cookies }) => {
return json({ error: 'Invalid ID' }, { status: 400 });
}
const deleted = await deleteNotificationSetting(id);
if (!deleted) {
// Get notification name before deletion for audit log
const setting = await getNotificationSetting(id);
if (!setting) {
return json({ error: 'Notification setting not found' }, { status: 404 });
}
const deleted = await deleteNotificationSetting(id);
if (!deleted) {
return json({ error: 'Failed to delete notification setting' }, { status: 500 });
}
// Audit log
await auditNotification(event, 'delete', id, setting.name);
return json({ success: true });
} catch (error) {
console.error('Error deleting notification setting:', error);
+4 -3
View File
@@ -45,11 +45,12 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
updatedAt: new Date().toISOString()
};
const success = await testNotification(setting);
const result = await testNotification(setting);
return json({
success,
message: success ? 'Test notification sent successfully' : 'Failed to send test notification'
success: result.success,
message: result.success ? 'Test notification sent successfully' : undefined,
error: result.error || (result.success ? undefined : 'Failed to send test notification')
});
} catch (error: any) {
console.error('Error testing notification:', error);
@@ -45,7 +45,7 @@ export const PUT: RequestHandler = async ({ request, cookies }) => {
const validTerminalFontIds = monospaceFonts.map(f => f.id);
const validFontSizes = ['xsmall', 'small', 'normal', 'medium', 'large', 'xlarge'];
const updates: { lightTheme?: string; darkTheme?: string; font?: string; fontSize?: string; gridFontSize?: string; terminalFont?: string } = {};
const updates: { lightTheme?: string; darkTheme?: string; font?: string; fontSize?: string; gridFontSize?: string; terminalFont?: string; editorFont?: string } = {};
if (data.lightTheme !== undefined) {
if (!validLightThemeIds.includes(data.lightTheme)) {
@@ -89,6 +89,13 @@ export const PUT: RequestHandler = async ({ request, cookies }) => {
updates.terminalFont = data.terminalFont;
}
if (data.editorFont !== undefined) {
if (!validTerminalFontIds.includes(data.editorFont)) {
return json({ error: 'Invalid editor font' }, { status: 400 });
}
updates.editorFont = data.editorFont;
}
await setUserThemePreferences(currentUser.id, updates);
// Return updated preferences
+12 -1
View File
@@ -1,9 +1,11 @@
import { json } from '@sveltejs/kit';
import { pruneAll } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { audit } from '$lib/server/audit';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ url, cookies }) => {
export const POST: RequestHandler = async (event) => {
const { url, cookies } = event;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
@@ -16,6 +18,15 @@ export const POST: RequestHandler = async ({ url, cookies }) => {
try {
const result = await pruneAll(envIdNum);
// Audit log - single entry for prune all operation
await audit(event, 'prune', 'settings', {
environmentId: envIdNum,
entityName: 'system',
description: 'Pruned all unused Docker resources',
details: { result }
});
return json({ success: true, result });
} catch (error: any) {
console.error('Error pruning all:', error?.message || error, error?.stack);
+11 -1
View File
@@ -1,9 +1,11 @@
import { json } from '@sveltejs/kit';
import { pruneContainers } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { audit } from '$lib/server/audit';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ url, cookies }) => {
export const POST: RequestHandler = async (event) => {
const { url, cookies } = event;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
@@ -16,6 +18,14 @@ export const POST: RequestHandler = async ({ url, cookies }) => {
try {
const result = await pruneContainers(envIdNum);
// Audit log
await audit(event, 'prune', 'container', {
environmentId: envIdNum,
description: 'Pruned stopped containers',
details: { result }
});
return json({ success: true, result });
} catch (error) {
console.error('Error pruning containers:', error);
+11 -1
View File
@@ -1,9 +1,11 @@
import { json } from '@sveltejs/kit';
import { pruneImages } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { audit } from '$lib/server/audit';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ url, cookies }) => {
export const POST: RequestHandler = async (event) => {
const { url, cookies } = event;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
@@ -17,6 +19,14 @@ export const POST: RequestHandler = async ({ url, cookies }) => {
try {
const result = await pruneImages(danglingOnly, envIdNum);
// Audit log
await audit(event, 'prune', 'image', {
environmentId: envIdNum,
description: `Pruned ${danglingOnly ? 'dangling' : 'unused'} images`,
details: { danglingOnly, result }
});
return json({ success: true, result });
} catch (error) {
console.error('Error pruning images:', error);
+11 -1
View File
@@ -1,9 +1,11 @@
import { json } from '@sveltejs/kit';
import { pruneNetworks } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { audit } from '$lib/server/audit';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ url, cookies }) => {
export const POST: RequestHandler = async (event) => {
const { url, cookies } = event;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
@@ -16,6 +18,14 @@ export const POST: RequestHandler = async ({ url, cookies }) => {
try {
const result = await pruneNetworks(envIdNum);
// Audit log
await audit(event, 'prune', 'network', {
environmentId: envIdNum,
description: 'Pruned unused networks',
details: { result }
});
return json({ success: true, result });
} catch (error) {
console.error('Error pruning networks:', error);
+11 -1
View File
@@ -1,9 +1,11 @@
import { json } from '@sveltejs/kit';
import { pruneVolumes } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { audit } from '$lib/server/audit';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ url, cookies }) => {
export const POST: RequestHandler = async (event) => {
const { url, cookies } = event;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
@@ -16,6 +18,14 @@ export const POST: RequestHandler = async ({ url, cookies }) => {
try {
const result = await pruneVolumes(envIdNum);
// Audit log
await audit(event, 'prune', 'volume', {
environmentId: envIdNum,
description: 'Pruned unused volumes',
details: { result }
});
return json({ success: true, result });
} catch (error) {
console.error('Error pruning volumes:', error);
+6 -1
View File
@@ -2,6 +2,7 @@ import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getRegistries, createRegistry, setDefaultRegistry } from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import { auditRegistry } from '$lib/server/audit';
export const GET: RequestHandler = async ({ cookies }) => {
const auth = await authorize(cookies);
@@ -23,7 +24,8 @@ export const GET: RequestHandler = async ({ cookies }) => {
}
};
export const POST: RequestHandler = async ({ request, cookies }) => {
export const POST: RequestHandler = async (event) => {
const { request, cookies } = event;
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('registries', 'create')) {
return json({ error: 'Permission denied' }, { status: 403 });
@@ -49,6 +51,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
await setDefaultRegistry(registry.id);
}
// Audit log
await auditRegistry(event, 'create', registry.id, registry.name);
// Don't expose password in response
const { password, ...safeRegistry } = registry;
return json({ ...safeRegistry, hasCredentials: !!password }, { status: 201 });
+28 -3
View File
@@ -2,6 +2,8 @@ import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getRegistry, updateRegistry, deleteRegistry, setDefaultRegistry } from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import { auditRegistry } from '$lib/server/audit';
import { computeAuditDiff } from '$lib/utils/diff';
export const GET: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
@@ -29,7 +31,8 @@ export const GET: RequestHandler = async ({ params, cookies }) => {
}
};
export const PUT: RequestHandler = async ({ params, request, cookies }) => {
export const PUT: RequestHandler = async (event) => {
const { params, request, cookies } = event;
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('registries', 'edit')) {
return json({ error: 'Permission denied' }, { status: 403 });
@@ -41,6 +44,12 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => {
return json({ error: 'Invalid registry ID' }, { status: 400 });
}
// Get old values before update for diff
const oldRegistry = await getRegistry(id);
if (!oldRegistry) {
return json({ error: 'Registry not found' }, { status: 404 });
}
const data = await request.json();
const registry = await updateRegistry(id, {
name: data.name,
@@ -59,6 +68,12 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => {
await setDefaultRegistry(id);
}
// Compute diff for audit
const diff = computeAuditDiff(oldRegistry, registry);
// Audit log
await auditRegistry(event, 'update', registry.id, registry.name, diff);
// Don't expose password
const { password, ...safeRegistry } = registry;
return json({ ...safeRegistry, hasCredentials: !!password });
@@ -71,7 +86,8 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => {
}
};
export const DELETE: RequestHandler = async ({ params, cookies }) => {
export const DELETE: RequestHandler = async (event) => {
const { params, cookies } = event;
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('registries', 'delete')) {
return json({ error: 'Permission denied' }, { status: 403 });
@@ -83,11 +99,20 @@ export const DELETE: RequestHandler = async ({ params, cookies }) => {
return json({ error: 'Invalid registry ID' }, { status: 400 });
}
// Get registry name before deletion for audit log
const registry = await getRegistry(id);
if (!registry) {
return json({ error: 'Registry not found' }, { status: 404 });
}
const deleted = await deleteRegistry(id);
if (!deleted) {
return json({ error: 'Registry not found or cannot be deleted' }, { status: 404 });
return json({ error: 'Registry cannot be deleted' }, { status: 400 });
}
// Audit log
await auditRegistry(event, 'delete', id, registry.name);
return json({ success: true });
} catch (error) {
console.error('Error deleting registry:', error);
+6 -1
View File
@@ -5,6 +5,7 @@ import {
createRole as dbCreateRole
} from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import { auditRole } from '$lib/server/audit';
// GET /api/roles - List all roles
export const GET: RequestHandler = async ({ cookies }) => {
@@ -26,7 +27,8 @@ export const GET: RequestHandler = async ({ cookies }) => {
};
// POST /api/roles - Create a new role
export const POST: RequestHandler = async ({ request, cookies }) => {
export const POST: RequestHandler = async (event) => {
const { request, cookies } = event;
const auth = await authorize(cookies);
// Check enterprise license
@@ -54,6 +56,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
environmentIds: environmentIds ?? null
});
// Audit log
await auditRole(event, 'create', role.id, role.name);
return json(role, { status: 201 });
} catch (error: any) {
console.error('Failed to create role:', error);
+15 -2
View File
@@ -6,6 +6,8 @@ import {
deleteRole as dbDeleteRole
} from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import { auditRole } from '$lib/server/audit';
import { computeAuditDiff } from '$lib/utils/diff';
// GET /api/roles/[id] - Get a specific role
export const GET: RequestHandler = async ({ params, cookies }) => {
@@ -36,7 +38,8 @@ export const GET: RequestHandler = async ({ params, cookies }) => {
};
// PUT /api/roles/[id] - Update a role
export const PUT: RequestHandler = async ({ params, request, cookies }) => {
export const PUT: RequestHandler = async (event) => {
const { params, request, cookies } = event;
const auth = await authorize(cookies);
// Check enterprise license
@@ -72,6 +75,12 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => {
return json({ error: 'Failed to update role' }, { status: 500 });
}
// Compute diff for audit
const diff = computeAuditDiff(existingRole, role);
// Audit log
await auditRole(event, 'update', role.id, role.name, diff);
return json(role);
} catch (error: any) {
console.error('Failed to update role:', error);
@@ -83,7 +92,8 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => {
};
// DELETE /api/roles/[id] - Delete a role
export const DELETE: RequestHandler = async ({ params, cookies }) => {
export const DELETE: RequestHandler = async (event) => {
const { params, cookies } = event;
const auth = await authorize(cookies);
// Check enterprise license
@@ -118,6 +128,9 @@ export const DELETE: RequestHandler = async ({ params, cookies }) => {
return json({ error: 'Failed to delete role' }, { status: 500 });
}
// Audit log
await auditRole(event, 'delete', id, role.name);
return json({ success: true });
} catch (error) {
console.error('Failed to delete role:', error);
+41 -1
View File
@@ -12,6 +12,7 @@ import {
getAllAutoUpdateSettings,
getAllAutoUpdateGitStacks,
getAllEnvUpdateCheckSettings,
getAllImagePruneSettings,
getLastExecutionForSchedule,
getRecentExecutionsForSchedule,
getEnvironment,
@@ -24,7 +25,7 @@ import { getGlobalScannerDefaults, getScannerSettingsWithDefaults } from '$lib/s
export interface ScheduleInfo {
id: number;
type: 'container_update' | 'git_stack_sync' | 'system_cleanup' | 'env_update_check';
type: 'container_update' | 'git_stack_sync' | 'system_cleanup' | 'env_update_check' | 'image_prune';
name: string;
entityName: string;
description?: string;
@@ -164,6 +165,45 @@ export const GET: RequestHandler = async () => {
);
schedules.push(...envUpdateCheckSchedules);
// Get image prune schedules
const imagePruneConfigs = await getAllImagePruneSettings();
const imagePruneSchedules = await Promise.all(
imagePruneConfigs.map(async ({ envId, settings }) => {
const [env, lastExecution, recentExecutions, timezone] = await Promise.all([
getEnvironment(envId),
getLastExecutionForSchedule('image_prune', envId),
getRecentExecutionsForSchedule('image_prune', envId, 5),
getEnvironmentTimezone(envId)
]);
const isEnabled = settings.enabled ?? false;
const nextRun = isEnabled && settings.cronExpression ? getNextRun(settings.cronExpression, timezone) : null;
// Build description based on prune mode
const description = settings.pruneMode === 'all'
? 'Prune all unused images'
: 'Prune dangling images only';
return {
id: envId,
type: 'image_prune' as const,
name: `Prune images: ${env?.name || 'Unknown'}`,
entityName: env?.name || 'Unknown',
description,
environmentId: envId,
environmentName: env?.name ?? null,
enabled: isEnabled,
scheduleType: 'custom',
cronExpression: settings.cronExpression ?? null,
nextRun: nextRun?.toISOString() ?? null,
lastExecution: lastExecution ?? null,
recentExecutions,
isSystem: false,
pruneMode: settings.pruneMode
};
})
);
schedules.push(...imagePruneSchedules);
// Get system schedules
const systemSchedules = await getSystemSchedules();
const sysSchedules = await Promise.all(
@@ -9,7 +9,8 @@ import {
getAutoUpdateSettingById,
deleteAutoUpdateSchedule,
updateGitStack,
deleteEnvUpdateCheckSettings
deleteEnvUpdateCheckSettings,
deleteImagePruneSettings
} from '$lib/server/db';
import { unregisterSchedule } from '$lib/server/scheduler';
@@ -49,6 +50,12 @@ export const DELETE: RequestHandler = async ({ params }) => {
unregisterSchedule(scheduleId, 'env_update_check');
return json({ success: true });
} else if (type === 'image_prune') {
// Delete image prune settings (scheduleId is environmentId)
await deleteImagePruneSettings(scheduleId);
unregisterSchedule(scheduleId, 'image_prune');
return json({ success: true });
} else if (type === 'system_cleanup') {
return json({ error: 'System schedules cannot be removed' }, { status: 400 });
@@ -4,13 +4,13 @@
* POST /api/schedules/[type]/[id]/run - Trigger a manual execution
*
* Path params:
* - type: 'container_update' | 'git_stack_sync' | 'system_cleanup'
* - type: 'container_update' | 'git_stack_sync' | 'system_cleanup' | 'env_update_check' | 'image_prune'
* - id: schedule ID
*/
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { triggerContainerUpdate, triggerGitStackSync, triggerSystemJob, triggerEnvUpdateCheck } from '$lib/server/scheduler';
import { triggerContainerUpdate, triggerGitStackSync, triggerSystemJob, triggerEnvUpdateCheck, triggerImagePrune } from '$lib/server/scheduler';
export const POST: RequestHandler = async ({ params }) => {
try {
@@ -36,6 +36,9 @@ export const POST: RequestHandler = async ({ params }) => {
case 'env_update_check':
result = await triggerEnvUpdateCheck(scheduleId);
break;
case 'image_prune':
result = await triggerImagePrune(scheduleId);
break;
default:
return json({ error: 'Invalid schedule type' }, { status: 400 });
}
@@ -5,7 +5,7 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getAutoUpdateSettingById, updateAutoUpdateSettingById, getGitStack, updateGitStack, getEnvUpdateCheckSettings, setEnvUpdateCheckSettings } from '$lib/server/db';
import { getAutoUpdateSettingById, updateAutoUpdateSettingById, getGitStack, updateGitStack, getEnvUpdateCheckSettings, setEnvUpdateCheckSettings, getImagePruneSettings, setImagePruneSettings } from '$lib/server/db';
import { registerSchedule, unregisterSchedule } from '$lib/server/scheduler';
export const POST: RequestHandler = async ({ params }) => {
@@ -75,6 +75,27 @@ export const POST: RequestHandler = async ({ params }) => {
unregisterSchedule(scheduleId, 'env_update_check');
}
return json({ success: true, enabled: newEnabled });
} else if (type === 'image_prune') {
// scheduleId is environmentId for image prune
const config = await getImagePruneSettings(scheduleId);
if (!config) {
return json({ error: 'Schedule not found' }, { status: 404 });
}
const newEnabled = !config.enabled;
await setImagePruneSettings(scheduleId, {
...config,
enabled: newEnabled
});
// Register or unregister schedule with croner
if (newEnabled && config.cronExpression) {
await registerSchedule(scheduleId, 'image_prune', scheduleId);
} else {
unregisterSchedule(scheduleId, 'image_prune');
}
return json({ success: true, enabled: newEnabled });
} else if (type === 'system_cleanup') {
return json({ error: 'System schedules cannot be paused' }, { status: 400 });
@@ -9,6 +9,7 @@ import {
getAllAutoUpdateSettings,
getAllAutoUpdateGitStacks,
getAllEnvUpdateCheckSettings,
getAllImagePruneSettings,
getLastExecutionForSchedule,
getRecentExecutionsForSchedule,
getEnvironment,
@@ -140,6 +141,45 @@ async function getSchedulesData(): Promise<ScheduleInfo[]> {
);
schedules.push(...envUpdateCheckSchedules);
// Get image prune schedules
const imagePruneConfigs = await getAllImagePruneSettings();
const imagePruneSchedules = await Promise.all(
imagePruneConfigs.map(async ({ envId, settings }) => {
const [env, lastExecution, recentExecutions, timezone] = await Promise.all([
getEnvironment(envId),
getLastExecutionForSchedule('image_prune', envId),
getRecentExecutionsForSchedule('image_prune', envId, 5),
getEnvironmentTimezone(envId)
]);
const isEnabled = settings.enabled ?? false;
const nextRun = isEnabled && settings.cronExpression ? getNextRun(settings.cronExpression, timezone) : null;
// Build description based on prune mode
const description = settings.pruneMode === 'all'
? 'Prune all unused images'
: 'Prune dangling images only';
return {
id: envId,
type: 'image_prune' as const,
name: `Prune images: ${env?.name || 'Unknown'}`,
entityName: env?.name || 'Unknown',
description,
environmentId: envId,
environmentName: env?.name ?? null,
enabled: isEnabled,
scheduleType: 'custom',
cronExpression: settings.cronExpression ?? null,
nextRun: nextRun?.toISOString() ?? null,
lastExecution: lastExecution ?? null,
recentExecutions,
isSystem: false,
pruneMode: settings.pruneMode
};
})
);
schedules.push(...imagePruneSchedules);
// Get system schedules
const systemSchedules = await getSystemSchedules();
const sysSchedules = await Promise.all(
+15 -3
View File
@@ -64,6 +64,7 @@ export interface GeneralSettings {
fontSize: string;
gridFontSize: string;
terminalFont: string;
editorFont: string;
// External stack paths
externalStackPaths: string[];
// Primary stack location
@@ -89,14 +90,16 @@ const DEFAULT_SETTINGS: Omit<GeneralSettings, 'scheduleRetentionDays' | 'eventRe
font: 'system',
fontSize: 'normal',
gridFontSize: 'normal',
terminalFont: 'system-mono'
terminalFont: 'system-mono',
editorFont: 'system-mono'
};
const VALID_LIGHT_THEMES = ['default', 'catppuccin', 'rose-pine', 'nord', 'solarized', 'gruvbox', 'alucard', 'github', 'material', 'atom-one'];
const VALID_DARK_THEMES = ['default', 'catppuccin', 'dracula', 'rose-pine', 'rose-pine-moon', 'tokyo-night', 'nord', 'one-dark', 'gruvbox', 'solarized', 'everforest', 'kanagawa', 'monokai', 'monokai-pro', 'material', 'palenight', 'github'];
const VALID_FONTS = ['system', 'geist', 'inter', 'plus-jakarta', 'dm-sans', 'outfit', 'space-grotesk', 'sofia-sans', 'nunito', 'poppins', 'montserrat', 'raleway', 'manrope', 'roboto', 'open-sans', 'lato', 'source-sans', 'work-sans', 'fira-sans', 'jetbrains-mono', 'fira-code', 'quicksand', 'comfortaa'];
const VALID_FONT_SIZES = ['xsmall', 'small', 'normal', 'medium', 'large', 'xlarge'];
const VALID_TERMINAL_FONTS = ['system-mono', 'jetbrains-mono', 'fira-code', 'source-code-pro', 'cascadia-code', 'menlo', 'consolas', 'sf-mono'];
const VALID_TERMINAL_FONTS = ['system-mono', 'jetbrains-mono', 'fira-code', 'source-code-pro', 'cascadia-code', 'ibm-plex-mono', 'roboto-mono', 'ubuntu-mono', 'space-mono', 'inconsolata', 'hack', 'anonymous-pro', 'dm-mono', 'red-hat-mono', 'menlo', 'consolas', 'sf-mono'];
const VALID_EDITOR_FONTS = VALID_TERMINAL_FONTS;
const VALID_DATE_FORMATS: DateFormat[] = ['MM/DD/YYYY', 'DD/MM/YYYY', 'YYYY-MM-DD', 'DD.MM.YYYY'];
@@ -136,6 +139,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
fontSize,
gridFontSize,
terminalFont,
editorFont,
externalStackPaths,
primaryStackLocation
] = await Promise.all([
@@ -164,6 +168,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
getSetting('theme_font_size'),
getSetting('theme_grid_font_size'),
getSetting('theme_terminal_font'),
getSetting('theme_editor_font'),
getExternalStackPaths(),
getPrimaryStackLocation()
]);
@@ -194,6 +199,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
fontSize: fontSize ?? DEFAULT_SETTINGS.fontSize,
gridFontSize: gridFontSize ?? DEFAULT_SETTINGS.gridFontSize,
terminalFont: terminalFont ?? DEFAULT_SETTINGS.terminalFont,
editorFont: editorFont ?? DEFAULT_SETTINGS.editorFont,
externalStackPaths,
primaryStackLocation
};
@@ -213,7 +219,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
try {
const body = await request.json();
const { confirmDestructive, showStoppedContainers, highlightUpdates, timeFormat, dateFormat, downloadFormat, defaultGrypeArgs, defaultTrivyArgs, scheduleRetentionDays, eventRetentionDays, scheduleCleanupCron, eventCleanupCron, scheduleCleanupEnabled, eventCleanupEnabled, logBufferSizeKb, defaultTimezone, eventCollectionMode, eventPollInterval, metricsCollectionInterval, lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, externalStackPaths, primaryStackLocation } = body;
const { confirmDestructive, showStoppedContainers, highlightUpdates, timeFormat, dateFormat, downloadFormat, defaultGrypeArgs, defaultTrivyArgs, scheduleRetentionDays, eventRetentionDays, scheduleCleanupCron, eventCleanupCron, scheduleCleanupEnabled, eventCleanupEnabled, logBufferSizeKb, defaultTimezone, eventCollectionMode, eventPollInterval, metricsCollectionInterval, lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, externalStackPaths, primaryStackLocation } = body;
if (confirmDestructive !== undefined) {
await setSetting('confirm_destructive', confirmDestructive);
@@ -303,6 +309,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
if (terminalFont !== undefined && VALID_TERMINAL_FONTS.includes(terminalFont)) {
await setSetting('theme_terminal_font', terminalFont);
}
if (editorFont !== undefined && VALID_EDITOR_FONTS.includes(editorFont)) {
await setSetting('theme_editor_font', editorFont);
}
if (externalStackPaths !== undefined && Array.isArray(externalStackPaths)) {
// Filter to valid non-empty strings
const validPaths = externalStackPaths.filter((p: unknown) => typeof p === 'string' && p.trim());
@@ -345,6 +354,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
fontSizeVal,
gridFontSizeVal,
terminalFontVal,
editorFontVal,
externalStackPathsVal,
primaryStackLocationVal
] = await Promise.all([
@@ -373,6 +383,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
getSetting('theme_font_size'),
getSetting('theme_grid_font_size'),
getSetting('theme_terminal_font'),
getSetting('theme_editor_font'),
getExternalStackPaths(),
getPrimaryStackLocation()
]);
@@ -403,6 +414,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
fontSize: fontSizeVal ?? DEFAULT_SETTINGS.fontSize,
gridFontSize: gridFontSizeVal ?? DEFAULT_SETTINGS.gridFontSize,
terminalFont: terminalFontVal ?? DEFAULT_SETTINGS.terminalFont,
editorFont: editorFontVal ?? DEFAULT_SETTINGS.editorFont,
externalStackPaths: externalStackPathsVal,
primaryStackLocation: primaryStackLocationVal
};
+53
View File
@@ -0,0 +1,53 @@
import { json, type RequestHandler } from '@sveltejs/kit';
import { getSetting } from '$lib/server/db';
/**
* Public endpoint for theme settings - no authentication required.
* Used by the login page to apply the app-level theme before user is authenticated.
*/
const DEFAULT_THEME_SETTINGS = {
lightTheme: 'default',
darkTheme: 'default',
font: 'system',
fontSize: 'normal',
gridFontSize: 'normal',
terminalFont: 'system-mono',
editorFont: 'system-mono'
};
export const GET: RequestHandler = async () => {
try {
const [
lightTheme,
darkTheme,
font,
fontSize,
gridFontSize,
terminalFont,
editorFont
] = await Promise.all([
getSetting('theme_light'),
getSetting('theme_dark'),
getSetting('theme_font'),
getSetting('theme_font_size'),
getSetting('theme_grid_font_size'),
getSetting('theme_terminal_font'),
getSetting('theme_editor_font')
]);
return json({
lightTheme: lightTheme ?? DEFAULT_THEME_SETTINGS.lightTheme,
darkTheme: darkTheme ?? DEFAULT_THEME_SETTINGS.darkTheme,
font: font ?? DEFAULT_THEME_SETTINGS.font,
fontSize: fontSize ?? DEFAULT_THEME_SETTINGS.fontSize,
gridFontSize: gridFontSize ?? DEFAULT_THEME_SETTINGS.gridFontSize,
terminalFont: terminalFont ?? DEFAULT_THEME_SETTINGS.terminalFont,
editorFont: editorFont ?? DEFAULT_THEME_SETTINGS.editorFont
});
} catch (error) {
console.error('Failed to get theme settings:', error);
// Return defaults on error
return json(DEFAULT_THEME_SETTINGS);
}
};
+39 -22
View File
@@ -1,8 +1,9 @@
import { json } from '@sveltejs/kit';
import { listComposeStacks, deployStack, saveStackComposeFile, saveStackEnvVars, writeRawStackEnvFile, saveStackEnvVarsToDb } from '$lib/server/stacks';
import { listComposeStacks, deployStack, saveStackComposeFile, writeStackEnvFile, writeRawStackEnvFile, saveStackEnvVarsToDb } from '$lib/server/stacks';
import { EnvironmentNotFoundError } from '$lib/server/docker';
import { upsertStackSource, getStackSources } from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import { auditStack } from '$lib/server/audit';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ url, cookies }) => {
@@ -34,6 +35,14 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
const stackSources = await getStackSources(envIdNum);
const existingNames = new Set(stacks.map((s) => s.name));
// Enrich Docker-discovered stacks with source type from DB
for (const stack of stacks) {
const source = stackSources.find(s => s.stackName === stack.name);
if (source) {
(stack as any).sourceType = source.sourceType;
}
}
for (const source of stackSources) {
// Add stacks from database that aren't already in the Docker list
// This includes internal, git, and external (adopted) stacks that are currently down
@@ -42,8 +51,9 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
name: source.stackName,
containers: [],
containerDetails: [],
status: 'created' as any
});
status: 'created' as any,
sourceType: source.sourceType
} as any);
}
}
@@ -58,7 +68,8 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
}
};
export const POST: RequestHandler = async ({ request, url, cookies }) => {
export const POST: RequestHandler = async (event) => {
const { request, url, cookies } = event;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
@@ -97,19 +108,20 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => {
}
// Save environment variables
// - rawEnvContent: non-secret vars with comments → .env file
// - envVars: ALL vars → DB (secrets stored for shell injection, non-secrets for metadata)
// - rawEnvContent → .env file (non-secrets with comments)
// - secrets only → DB (for shell injection at runtime)
if (rawEnvContent) {
// Write raw content to .env file (should NOT contain secrets)
await writeRawStackEnvFile(name, rawEnvContent, envIdNum, envPath || undefined);
}
// Save ALL vars to DB (secrets for shell injection at runtime)
if (envVars && Array.isArray(envVars) && envVars.length > 0) {
await saveStackEnvVarsToDb(name, envVars, envIdNum);
}
// Fallback: if no rawEnvContent, generate .env from non-secret vars
if (!rawEnvContent && envVars && Array.isArray(envVars) && envVars.length > 0) {
await saveStackEnvVars(name, envVars, envIdNum, envPath || undefined);
const secrets = envVars.filter((v: any) => v.isSecret);
if (secrets.length > 0) {
await saveStackEnvVarsToDb(name, secrets, envIdNum);
}
// Fallback: if no rawEnvContent, generate .env from non-secret vars
if (!rawEnvContent) {
await writeStackEnvFile(name, envVars, envIdNum, envPath || undefined);
}
}
// Record the stack as internally created with custom paths if provided
@@ -121,6 +133,9 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => {
envPath: envPath || undefined
});
// Audit log
await auditStack(event, 'create', name, envIdNum);
return json({ success: true, started: false });
}
@@ -132,19 +147,18 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => {
// Save environment variables BEFORE deploying so they're available during start
if (rawEnvContent || (envVars && Array.isArray(envVars) && envVars.length > 0)) {
// - rawEnvContent: non-secret vars with comments → .env file
// - envVars: ALL vars → DB (secrets stored for shell injection, non-secrets for metadata)
if (rawEnvContent) {
// Write raw content to .env file (should NOT contain secrets)
await writeRawStackEnvFile(name, rawEnvContent, envIdNum, envPath || undefined);
}
// Save ALL vars to DB (secrets for shell injection at runtime)
if (envVars && Array.isArray(envVars) && envVars.length > 0) {
await saveStackEnvVarsToDb(name, envVars, envIdNum);
}
// Fallback: if no rawEnvContent, generate .env from non-secret vars
if (!rawEnvContent && envVars && Array.isArray(envVars) && envVars.length > 0) {
await saveStackEnvVars(name, envVars, envIdNum, envPath || undefined);
const secrets = envVars.filter((v: any) => v.isSecret);
if (secrets.length > 0) {
await saveStackEnvVarsToDb(name, secrets, envIdNum);
}
// Fallback: if no rawEnvContent, generate .env from non-secret vars
if (!rawEnvContent) {
await writeStackEnvFile(name, envVars, envIdNum, envPath || undefined);
}
}
}
@@ -170,6 +184,9 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => {
envPath: envPath || undefined
});
// Audit log (create + deploy in one action)
await auditStack(event, 'deploy', name, envIdNum);
return json({ success: true, started: true, output: result.output });
} catch (error: any) {
console.error('Error creating compose stack:', error);
@@ -70,7 +70,6 @@ export const PUT: RequestHandler = async ({ params, request, url, cookies }) =>
if (restart) {
// Deploy with docker compose up -d --force-recreate
// Force recreate ensures env var changes are applied
// Note: deployStack uses requireComposeFile which will use saved paths
// Save paths first if provided
if (pathOptions) {
const saveResult = await saveStackComposeFile(name, content, false, envIdNum, pathOptions);
@@ -78,11 +77,15 @@ export const PUT: RequestHandler = async ({ params, request, url, cookies }) =>
return json({ error: saveResult.error }, { status: 500 });
}
}
// Get authoritative paths from DB/filesystem for deploy
const composeInfo = await getStackComposeFile(name, envIdNum);
result = await deployStack({
name,
compose: content,
envId: envIdNum,
forceRecreate: true
forceRecreate: true,
composePath: composeInfo.composePath || undefined,
envPath: composeInfo.envPath || undefined
});
} else {
// Just save the file without restarting (update operation, not create)
+33 -55
View File
@@ -57,9 +57,8 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
try {
const stackName = decodeURIComponent(params.name);
// Get variables from database (masked - secrets show as '***')
const dbVariables = await getStackEnvVars(stackName, envIdNum, true);
const dbByKey = new Map(dbVariables.map(v => [v.key, v]));
// Get secrets from database (masked - values show as '***')
const dbSecrets = await getStackEnvVars(stackName, envIdNum, true);
// Check if this stack has a custom compose path configured
const source = await getStackSource(stackName, envIdNum);
@@ -67,63 +66,49 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
// Determine the env file path based on path resolution rules:
// - envPath = '' (empty string) → explicitly no env file
// - envPath = '/path/.env' → use custom path
// - envPath = null with composePath → suggest .env next to compose (but don't auto-load)
// - envPath = null with composePath → .env next to compose
// - envPath = null without composePath → use default location
let envFilePath: string | null = null;
if (source?.envPath === '') {
// Empty string = explicitly no env file
envFilePath = null;
} else if (source?.envPath) {
// Custom env path specified
envFilePath = source.envPath;
} else if (source?.composePath) {
// Custom compose path but no env path - suggest .env next to compose
// For loading, check if it exists (but don't fail if it doesn't)
envFilePath = join(dirname(source.composePath), '.env');
} else {
// Default location - .env in stack directory
const stackDir = await findStackDir(stackName, envIdNum);
if (stackDir) {
envFilePath = join(stackDir, '.env');
}
}
let fileVars: Record<string, string> = {};
if (envFilePath && existsSync(envFilePath)) {
try {
const content = await Bun.file(envFilePath).text();
fileVars = parseEnvFile(content);
} catch (e) {
// Ignore file read errors
}
}
// Merge: DB variables (with secrets masked) + file variables (non-secrets only)
// For non-secrets: file value overrides DB value (user may have edited file)
// For secrets: only DB value exists (masked as '***')
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 (source?.sourceType === 'git') {
// Git stacks: ALL vars (overrides + secrets) come from DB
for (const dbVar of dbSecrets) {
variables.push({ key: dbVar.key, value: dbVar.value, isSecret: dbVar.isSecret });
}
} else {
// Internal/adopted stacks: non-secrets from file, secrets from DB
if (envFilePath && existsSync(envFilePath)) {
try {
const content = await Bun.file(envFilePath).text();
const fileVars = parseEnvFile(content);
for (const [key, value] of Object.entries(fileVars)) {
variables.push({ key, value, isSecret: false });
}
} catch {
// Ignore file read errors
}
}
if (dbVar) {
if (dbVar.isSecret) {
// Secret: use masked value from DB, ignore any file value
variables.push({ key, value: dbVar.value, isSecret: true });
} else if (fileValue !== undefined) {
// Non-secret with file value: file overrides (user may have edited)
variables.push({ key, value: fileValue, isSecret: false });
} else {
// Non-secret only in DB: use DB value
variables.push({ key, value: dbVar.value, isSecret: false });
// Secrets come from the database (never written to file)
for (const secret of dbSecrets) {
if (secret.isSecret) {
variables.push({ key: secret.key, value: secret.value, isSecret: true });
}
} else if (fileValue !== undefined) {
// Variable only in file - add it as non-secret
variables.push({ key, value: fileValue, isSecret: false });
}
}
@@ -136,15 +121,14 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
/**
* PUT /api/stacks/[name]/env?env=X
* Set/replace all environment variables for a stack.
* Body: { variables: [{ key, value, isSecret? }] }
* Save secret environment variables for a stack.
* Body: { variables: [{ key, value, isSecret }] }
*
* SECURITY: Secrets are stored ONLY in the database, NEVER written to .env file.
* For secrets, if the value is '***' (the masked placeholder), the original
* secret value from the database is preserved instead of overwriting with '***'.
* Only secrets are stored in the database. Non-secret variables live in the
* .env file (written by PUT /env/raw) and are read directly by Docker Compose.
*
* The .env file only contains non-secret variables (can be edited manually).
* Secrets are injected via shell environment variables at runtime.
* If a secret's value is '***' (masked placeholder), the original value
* from the database is preserved.
*/
export const PUT: RequestHandler = async ({ params, url, cookies, request }) => {
const auth = await authorize(cookies);
@@ -177,14 +161,12 @@ export const PUT: RequestHandler = async ({ params, url, cookies, request }) =>
if (typeof v.value !== 'string') {
return json({ error: `Invalid variable "${v.key}": value must be a string` }, { status: 400 });
}
// Validate key format (env var naming convention)
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(v.key)) {
return json({ error: `Invalid variable name "${v.key}": must start with a letter or underscore and contain only alphanumeric characters and underscores` }, { status: 400 });
}
}
// Check if any secrets have the masked placeholder '***'
// If so, we need to preserve their original values from the database
// Preserve masked secret values ('***') from the database
const secretsWithMaskedValue = body.variables.filter(
(v: { key: string; value: string; isSecret?: boolean }) =>
v.isSecret && v.value === '***'
@@ -193,16 +175,13 @@ export const PUT: RequestHandler = async ({ params, url, cookies, request }) =>
let variablesToSave = body.variables;
if (secretsWithMaskedValue.length > 0) {
// Get existing variables (unmasked) to preserve secret values
const existingVars = await getStackEnvVars(stackName, envIdNum, false);
const existingByKey = new Map(existingVars.map(v => [v.key, v]));
// Replace masked secrets with their original values
variablesToSave = body.variables.map((v: { key: string; value: string; isSecret?: boolean }) => {
if (v.isSecret && v.value === '***') {
const existing = existingByKey.get(v.key);
if (existing && existing.isSecret) {
// Preserve the original secret value
return { ...v, value: existing.value };
}
}
@@ -210,8 +189,7 @@ export const PUT: RequestHandler = async ({ params, url, cookies, request }) =>
});
}
// Save ALL variables (including secrets) to database
// Note: The .env file is written by PUT /env/raw endpoint, which preserves comments
// Save secrets to database (non-secrets live in the .env file)
await setStackEnvVars(stackName, envIdNum, variablesToSave);
return json({ success: true, count: variablesToSave.length });
@@ -49,6 +49,6 @@ export const GET: RequestHandler = async ({ url }) => {
stackDir,
composePath: `${stackDir}/compose.yaml`,
envPath: `${stackDir}/.env`,
source: location ? 'custom' : 'default'
source: 'default'
});
};
+2 -2
View File
@@ -8,7 +8,7 @@ import {
listNetworks,
getDockerConnectionInfo
} from '$lib/server/docker';
import { listManagedStacks } from '$lib/server/stacks';
import { getStackSources } from '$lib/server/db';
import { isPostgres, isSqlite, getDatabaseSchemaVersion, getPostgresConnectionInfo } from '$lib/server/db/drizzle';
import { hasEnvironments } from '$lib/server/db';
import type { RequestHandler } from './$types';
@@ -149,7 +149,7 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
}
}
const stacks = listManagedStacks();
const stacks = await getStackSources();
const runningContainers = containers.filter(c => c.state === 'running').length;
const stoppedContainers = containers.length - runningContainers;
+6 -1
View File
@@ -11,6 +11,7 @@ import {
} from '$lib/server/db';
import { hashPassword, createUserSession } from '$lib/server/auth';
import { authorize } from '$lib/server/authorize';
import { auditUser } from '$lib/server/audit';
// GET /api/users - List all users
// Free for all - local users are needed for basic auth
@@ -63,7 +64,8 @@ export const GET: RequestHandler = async ({ cookies }) => {
// POST /api/users - Create a new user
// Free for all - local users are needed for basic auth
export const POST: RequestHandler = async ({ request, cookies }) => {
export const POST: RequestHandler = async (event) => {
const { request, cookies } = event;
const auth = await authorize(cookies);
// When auth is enabled and user is logged in, check they can manage users
@@ -116,6 +118,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
autoLoggedIn = true;
}
// Audit log
await auditUser(event, 'create', user.id, user.username);
return json({
id: user.id,
username: user.username,
+21 -2
View File
@@ -14,6 +14,8 @@ import {
} from '$lib/server/db';
import { hashPassword } from '$lib/server/auth';
import { authorize } from '$lib/server/authorize';
import { auditUser } from '$lib/server/audit';
import { computeAuditDiff } from '$lib/utils/diff';
// GET /api/users/[id] - Get a specific user
// Free for all - local users are needed for basic auth
@@ -60,7 +62,8 @@ export const GET: RequestHandler = async ({ params, cookies }) => {
// PUT /api/users/[id] - Update a user
// Free for all - local users are needed for basic auth
export const PUT: RequestHandler = async ({ params, request, cookies }) => {
export const PUT: RequestHandler = async (event) => {
const { params, request, cookies } = event;
const auth = await authorize(cookies);
if (!params.id) {
@@ -203,6 +206,15 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => {
// Compute final isAdmin status
const finalIsAdmin = shouldPromote || (existingUserIsAdmin && !shouldDemote);
// Compute diff for audit (exclude sensitive fields)
const diff = computeAuditDiff(
{ username: existingUser.username, email: existingUser.email, displayName: existingUser.displayName, isActive: existingUser.isActive, isAdmin: existingUserIsAdmin },
{ username: user.username, email: user.email, displayName: user.displayName, isActive: user.isActive, isAdmin: finalIsAdmin }
);
// Audit log
await auditUser(event, 'update', user.id, user.username, diff);
return json({
id: user.id,
username: user.username,
@@ -226,7 +238,8 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => {
// DELETE /api/users/[id] - Delete a user
// Free for all - local users are needed for basic auth
export const DELETE: RequestHandler = async ({ params, url, cookies }) => {
export const DELETE: RequestHandler = async (event) => {
const { params, url, cookies } = event;
const auth = await authorize(cookies);
// When auth is enabled, check permission (free edition allows all, enterprise checks RBAC)
@@ -279,6 +292,9 @@ export const DELETE: RequestHandler = async ({ params, url, cookies }) => {
// Disable authentication
await updateAuthSettings({ authEnabled: false });
// Audit log
await auditUser(event, 'delete', id, user.username);
return json({ success: true, authDisabled: true });
}
}
@@ -291,6 +307,9 @@ export const DELETE: RequestHandler = async ({ params, url, cookies }) => {
return json({ error: 'Failed to delete user' }, { status: 500 });
}
// Audit log
await auditUser(event, 'delete', id, user.username);
return json({ success: true });
} catch (error) {
console.error('Failed to delete user:', error);
+29 -4
View File
@@ -6,9 +6,12 @@ import {
verifyAndEnableMfa,
disableMfa
} from '$lib/server/auth';
import { auditUser } from '$lib/server/audit';
import { getUser } from '$lib/server/db';
// POST /api/users/[id]/mfa - Setup MFA (generate QR code)
export const POST: RequestHandler = async ({ params, request, cookies }) => {
export const POST: RequestHandler = async (event) => {
const { params, request, cookies } = event;
const currentUser = await validateSession(cookies);
if (!params.id) {
@@ -36,6 +39,15 @@ export const POST: RequestHandler = async ({ params, request, cookies }) => {
return json({ error: 'Invalid MFA code' }, { status: 400 });
}
// Audit log - MFA enabled
const targetUser = await getUser(userId);
if (targetUser) {
await auditUser(event, 'update', userId, targetUser.username, {
mfaEnabled: true,
enabledBy: currentUser?.id === userId ? 'self' : currentUser?.username
});
}
return json({
success: true,
message: 'MFA enabled successfully',
@@ -60,7 +72,8 @@ export const POST: RequestHandler = async ({ params, request, cookies }) => {
};
// DELETE /api/users/[id]/mfa - Disable MFA
export const DELETE: RequestHandler = async ({ params, cookies }) => {
export const DELETE: RequestHandler = async (event) => {
const { params, cookies } = event;
const currentUser = await validateSession(cookies);
if (!params.id) {
@@ -75,11 +88,23 @@ export const DELETE: RequestHandler = async ({ params, cookies }) => {
}
try {
const success = await disableMfa(userId);
if (!success) {
// Get user info before disabling for audit
const targetUser = await getUser(userId);
if (!targetUser) {
return json({ error: 'User not found' }, { status: 404 });
}
const success = await disableMfa(userId);
if (!success) {
return json({ error: 'Failed to disable MFA' }, { status: 500 });
}
// Audit log - MFA disabled
await auditUser(event, 'update', userId, targetUser.username, {
mfaDisabled: true,
disabledBy: currentUser?.id === userId ? 'self' : currentUser?.username
});
return json({ success: true, message: 'MFA disabled successfully' });
} catch (error) {
console.error('MFA disable error:', error);
+27 -3
View File
@@ -6,8 +6,10 @@ import {
getUserRoles,
assignUserRole,
removeUserRole,
getUser
getUser,
getRole
} from '$lib/server/db';
import { auditUser } from '$lib/server/audit';
// GET /api/users/[id]/roles - Get roles assigned to a user
export const GET: RequestHandler = async ({ params, cookies }) => {
@@ -37,7 +39,8 @@ export const GET: RequestHandler = async ({ params, cookies }) => {
};
// POST /api/users/[id]/roles - Assign a role to a user
export const POST: RequestHandler = async ({ params, request, cookies }) => {
export const POST: RequestHandler = async (event) => {
const { params, request, cookies } = event;
// Check enterprise license
if (!(await isEnterprise())) {
return json({ error: 'Enterprise license required' }, { status: 403 });
@@ -66,6 +69,14 @@ export const POST: RequestHandler = async ({ params, request, cookies }) => {
}
const userRole = await assignUserRole(userId, roleId, environmentId);
// Audit log - role assigned
const role = await getRole(roleId);
await auditUser(event, 'update', userId, user.username, {
roleAssigned: role?.name || `Role #${roleId}`,
roleId
});
return json(userRole, { status: 201 });
} catch (error) {
console.error('Failed to assign role:', error);
@@ -74,7 +85,8 @@ export const POST: RequestHandler = async ({ params, request, cookies }) => {
};
// DELETE /api/users/[id]/roles - Remove a role from a user
export const DELETE: RequestHandler = async ({ params, request, cookies }) => {
export const DELETE: RequestHandler = async (event) => {
const { params, request, cookies } = event;
// Check enterprise license
if (!(await isEnterprise())) {
return json({ error: 'Enterprise license required' }, { status: 403 });
@@ -97,11 +109,23 @@ export const DELETE: RequestHandler = async ({ params, request, cookies }) => {
return json({ error: 'Role ID is required' }, { status: 400 });
}
// Get user and role info before deletion for audit
const user = await getUser(userId);
const role = await getRole(roleId);
const deleted = await removeUserRole(userId, roleId, environmentId);
if (!deleted) {
return json({ error: 'Role assignment not found' }, { status: 404 });
}
// Audit log - role removed
if (user) {
await auditUser(event, 'update', userId, user.username, {
roleRemoved: role?.name || `Role #${roleId}`,
roleId
});
}
return json({ success: true });
} catch (error) {
console.error('Failed to remove role:', error);