diff --git a/src/lib/server/stack-paths.ts b/src/lib/server/stack-paths.ts new file mode 100644 index 0000000..b6d15d0 --- /dev/null +++ b/src/lib/server/stack-paths.ts @@ -0,0 +1,15 @@ +/** + * The ONE formula for a stack's folder under a base dir on a host: `/`. + * Deploy staging (write) and backup (read) share it so they can't diverge (#1383 / #1240). + * Pure + dependency-free, so both sides can import it. + */ + +/** Trim + drop trailing slashes: `/data/stacks/` -> `/data/stacks`. */ +export function normalizeBaseDir(base: string): string { + return base.trim().replace(/\/+$/, ''); +} + +/** `/`. Name is used verbatim - do NOT slugify (both sides use it raw). */ +export function stackDirIn(base: string, stackName: string): string { + return `${normalizeBaseDir(base)}/${stackName}`; +} diff --git a/src/routes/api/stacks/[name]/compose/+server.ts b/src/routes/api/stacks/[name]/compose/+server.ts index c051e4c..71d8f25 100644 --- a/src/routes/api/stacks/[name]/compose/+server.ts +++ b/src/routes/api/stacks/[name]/compose/+server.ts @@ -70,22 +70,36 @@ export const PUT: RequestHandler = async ({ params, request, url, cookies }) => return json({ error: 'secretProviderId must be a number or null' }, { status: 400 }); } + // Binding a secret provider resolves its secrets into the container at deploy; + // require the secrets permission so a stacks-only user can't exfiltrate a + // provider's secrets by binding it and reading the container env. + if ( + typeof secretProviderId === 'number' && + auth.authEnabled && + !(await auth.can('secrets', 'view', envIdNum)) + ) { + return json({ error: 'Permission denied: binding a secret provider requires the secrets permission' }, { status: 403 }); + } + // Build options object for custom paths, move operation, file renames, and secret provider binding const pathOptions = (composePath || envPath !== undefined || moveFromDir || oldComposePath || oldEnvPath || secretProviderId !== undefined) ? { composePath, envPath, moveFromDir, oldComposePath, oldEnvPath, secretProviderId } : undefined; + // Persist the submitted content on EVERY accepted PUT, whether or not path fields came + // along. Gating this on pathOptions left Dockhand's stored copy stale while restart:true + // deployed the new content - a later GET then served the old copy, silently reverting the + // change on the next read/edit/deploy round-trip (#1383). saveStackComposeFile handles a + // possibly-undefined pathOptions fine (the non-restart branch already relied on that). + const saveResult = await saveStackComposeFile(name, content, false, envIdNum, pathOptions); + if (!saveResult.success) { + return json({ error: saveResult.error }, { status: 500 }); + } + if (restart) { - // Deploy with docker compose up -d --force-recreate - // Force recreate ensures env var changes are applied - // Save paths first if provided - if (pathOptions) { - const saveResult = await saveStackComposeFile(name, content, false, envIdNum, pathOptions); - if (!saveResult.success) { - return json({ error: saveResult.error }, { status: 500 }); - } - } - // Get authoritative paths from DB/filesystem for deploy + // Deploy with docker compose up -d --force-recreate. + // Force recreate ensures env var changes are applied. + // Get authoritative paths from DB/filesystem for deploy (now reflects the saved content). const composeInfo = await getStackComposeFile(name, envIdNum); // Deploy via SSE to keep connection alive during long operations @@ -112,13 +126,7 @@ export const PUT: RequestHandler = async ({ params, request, url, cookies }) => }, request); } - // Just save the file without restarting (update operation, not create) - const result = await saveStackComposeFile(name, content, false, envIdNum, pathOptions); - - if (!result.success) { - return json({ error: result.error }, { status: 500 }); - } - + // No restart: the content is already persisted above. return json({ success: true }); } catch (error: any) { console.error(`Error updating compose file for stack ${name}:`, error);