1.0.13
This commit is contained in:
1 parent
a88d3d5788
commit
8f3a7eb435
96 files changed
+5235
-1870
No files matched your search
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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: '********'
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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
@@ -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'
|
||||
});
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user