From fbd13accdaa554e08a980fb516af896fa3f84c94 Mon Sep 17 00:00:00 2001 From: Anso Date: Sun, 24 May 2026 15:48:17 -0400 Subject: [PATCH] feat(stacks): optimistic concurrency on compose and env file writes (#1183) * feat(stacks): optimistic concurrency on compose and env file writes Two browser tabs (or one tab + an out-of-band edit) could silently overwrite each other's compose.yaml or .env edits. GET /api/stacks/:name and GET /api/stacks/:name/env now emit a W/"" ETag header. PUT on the same endpoints reads If-Match and returns 412 with {code: 'stack_file_changed', currentMtimeMs, currentContent} on a stale write, so the editor can recover without losing the user's text. The 412 path in the editor surfaces a confirm dialog: "Overwrite their changes?" Cancel loads the latest content into the editor and exits edit mode. OK retries the PUT with no If-Match header. If-Match is optional. A client that doesn't send it falls through to the previous unconditional-write behavior so partial deploys (file explorer uploads, git source sync) don't gain a surprise 412 surface. * fix(stacks): consistent stat+read for compose mtime via held file handle Promise.all([readFile, stat]) lets a concurrent write interleave between the two calls: the read can return new content while the stat returns the old mtime (or vice versa). The next If-Match check would then either spuriously trigger 412 or silently allow an overwrite. Hold the file descriptor open across stat and read so both ops observe the same inode state. A rename-replace by another writer would not affect the held fd's view. Adds a near-boundary mtime test (1-second bump) to confirm the Math.floor comparison detects whole-second changes on filesystems that round to second-level precision. * fix(stacks): defense-in-depth path validation in new compose mtime methods CodeQL's taint engine on PR #1183 flagged 10 js/path-injection errors across the four new FileSystemService methods (getStackContentWithMtime, saveStackContentIfUnchanged, writeFileIfUnchanged, statMtime). The engine does not follow the existing assertWithinBase guard across the resolveStackDir / getComposeFilePath helper boundary; from its view the stackName flows straight from req.params into a filesystem sink. Eight alerts (getStackContentWithMtime + saveStackContentIfUnchanged) were CodeQL blind-spot: the path WAS validated inside resolveStackDir. Two alerts (writeFileIfUnchanged + statMtime) were genuinely missing service-level guards because those methods accept a raw targetPath from the caller and trusted the route to have validated upstream. Add an explicit this.assertWithinBase(filePath) at the top of each new method. The check is redundant for the two methods that already went through resolveStackDir but makes the safety boundary visible both to readers and to CodeQL's taint follower. While here, port the held-file-descriptor pattern (already applied to getStackContentWithMtime in dd7545eb) to the stat-then-read sequence in saveStackContentIfUnchanged and writeFileIfUnchanged. This closes the two js/file-system-race warnings on those branches so a concurrent rename-replace cannot interleave between the mismatch detection and the currentContent capture returned in the 412 payload. The two remaining js/http-to-file-access warnings ("write to file system depends on untrusted data") are semantic and intentional: this is the save endpoint by design. They stay as warnings (not errors), do not block CI, and are not suppressed because the codebase does not do CodeQL suppression annotations. * fix(stacks): inline path.resolve+startsWith barrier per CodeQL recommendation The previous fix added this.assertWithinBase() calls at the top of each new method. CodeQL's taint-flow analysis does not follow that helper call across the function boundary, so it still saw the path as user-tainted at every fs sink (10 -> 8 alerts after the first attempt). CodeQL's documented js/path-injection sanitizer recognizes the following inline pattern: filePath = path.resolve(ROOT, filePath); if (!filePath.startsWith(ROOT)) { throw / return; } // use the reassigned filePath below The key elements are (1) path.resolve as the normalizer, (2) startsWith check, (3) reject inline, (4) downstream use of the reassigned variable. None of these can hide behind a helper call or the taint tracker re-flags every sink. Inlines the pattern in each of the four new methods (getStackContentWith- Mtime, saveStackContentIfUnchanged, writeFileIfUnchanged, statMtime). The check stays runtime-correct (it's the same logic isPathWithinBase implements) but is now visible to static analysis. writeFileIfUnchanged and statMtime had no service-level guard at all before this commit (they trusted the caller); the inline barrier closes that real gap as well as the CodeQL-recognition gap. * fix(stacks): canonical CodeQL js/path-injection barrier shape The prior inline check used path.resolve(filePath) with a single argument and a compound condition (a !== b && !c.startsWith(d)). CodeQL's path-injection sanitizer recognizer is shape-sensitive: it matches path.resolve(SAFE_ROOT, untrusted) with the safe root as the first argument, followed by a single unary startsWith check on the resolved variable. The compound form and the single-arg resolve fell outside the recognized pattern, leaving 8 alerts unchanged across the four new methods. Rewrite the barrier in each method to match the documented shape verbatim: const baseResolved = path.resolve(this.baseDir); const safePath = path.resolve(baseResolved, untrustedInput); if (!safePath.startsWith(baseResolved + path.sep)) { throw ...; } // sinks consume safePath baseResolved is a local variable (anchors the resolve call against a known-safe root). safePath is the reassigned, sanitized variable that every downstream fs.* call consumes. The single startsWith check with path.sep appended prevents the prefix-match edge case (/foo matches /foobar without the separator). All four affected methods get the same form. This is the third attempt at the CodeQL fix. The first added an assertWithinBase helper (function call, not followed across the boundary). The second inlined path.resolve(x) with a compound check (non-canonical shape). This commit uses the literal recommended sanitizer. --- .../src/__tests__/stack-compose-mtime.test.ts | 236 ++++++++++++++++++ backend/src/routes/stacks.ts | 48 +++- backend/src/services/FileSystemService.ts | 132 ++++++++++ .../EditorLayout/hooks/useEditorViewState.ts | 4 + .../EditorLayout/hooks/useStackActions.ts | 62 ++++- 5 files changed, 467 insertions(+), 15 deletions(-) create mode 100644 backend/src/__tests__/stack-compose-mtime.test.ts diff --git a/backend/src/__tests__/stack-compose-mtime.test.ts b/backend/src/__tests__/stack-compose-mtime.test.ts new file mode 100644 index 00000000..0623d0a8 --- /dev/null +++ b/backend/src/__tests__/stack-compose-mtime.test.ts @@ -0,0 +1,236 @@ +/** + * Integration tests for optimistic concurrency on PUT /api/stacks/:name and + * PUT /api/stacks/:name/env. + * + * GET returns the file content with an `ETag` header carrying the mtimeMs. + * The frontend echoes that as `If-Match` on save. When the file on disk has + * mutated in the interim, the server returns 412 with the current content + * and mtime so the caller can show a "file changed" recovery sheet. + * + * These tests use real fs ops against a temp COMPOSE_DIR rather than mocks + * because the contract is specifically about mtime semantics. + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import request from 'supertest'; +import fs from 'fs'; +import path from 'path'; +import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb'; + +let tmpDir: string; +let composeDir: string; +let app: import('express').Express; +let authCookie: string; + +const STACK = 'web'; + +function seedStack(stackName: string, content: string): string { + const stackDir = path.join(composeDir, stackName); + fs.mkdirSync(stackDir, { recursive: true }); + const filePath = path.join(stackDir, 'compose.yaml'); + fs.writeFileSync(filePath, content, 'utf-8'); + return filePath; +} + +function seedEnv(stackName: string, content: string): string { + const stackDir = path.join(composeDir, stackName); + fs.mkdirSync(stackDir, { recursive: true }); + const envPath = path.join(stackDir, '.env'); + fs.writeFileSync(envPath, content, 'utf-8'); + return envPath; +} + +function parseEtag(etag: string | undefined): number | null { + if (!etag) return null; + const m = etag.match(/(?:W\/)?"(\d+)"/); + return m ? Number(m[1]) : null; +} + +beforeAll(async () => { + tmpDir = await setupTestDb(); + composeDir = process.env.COMPOSE_DIR as string; + ({ app } = await import('../index')); + authCookie = await loginAsTestAdmin(app); +}); + +afterAll(() => { + cleanupTestDb(tmpDir); +}); + +beforeEach(() => { + const stackDir = path.join(composeDir, STACK); + if (fs.existsSync(stackDir)) { + fs.rmSync(stackDir, { recursive: true, force: true }); + } +}); + +describe('GET /api/stacks/:stackName emits ETag with mtime', () => { + it('responds 200 with content body and a W/"" ETag header', async () => { + seedStack(STACK, 'services:\n web:\n image: nginx\n'); + + const res = await request(app) + .get(`/api/stacks/${STACK}`) + .set('Cookie', authCookie); + + expect(res.status).toBe(200); + expect(res.text).toContain('image: nginx'); + const etag = res.headers.etag; + expect(etag).toMatch(/^W\/"\d+"$/); + expect(parseEtag(etag)).toBeGreaterThan(0); + }); +}); + +describe('PUT /api/stacks/:stackName optimistic concurrency', () => { + it('writes successfully when If-Match matches current mtime', async () => { + seedStack(STACK, 'original'); + const getRes = await request(app).get(`/api/stacks/${STACK}`).set('Cookie', authCookie); + const etag = getRes.headers.etag as string; + + const putRes = await request(app) + .put(`/api/stacks/${STACK}`) + .set('Cookie', authCookie) + .set('If-Match', etag) + .send({ content: 'updated' }); + + expect(putRes.status).toBe(200); + expect(putRes.headers.etag).toMatch(/^W\/"\d+"$/); + expect(typeof putRes.body.mtimeMs).toBe('number'); + const filePath = path.join(composeDir, STACK, 'compose.yaml'); + expect(fs.readFileSync(filePath, 'utf-8')).toBe('updated'); + }); + + it('returns 412 with stack_file_changed and the current content on If-Match mismatch', async () => { + seedStack(STACK, 'original'); + const getRes = await request(app).get(`/api/stacks/${STACK}`).set('Cookie', authCookie); + const etag = getRes.headers.etag as string; + + const filePath = path.join(composeDir, STACK, 'compose.yaml'); + fs.writeFileSync(filePath, 'changed-by-other-tab', 'utf-8'); + const future = Date.now() + 5_000; + fs.utimesSync(filePath, future / 1000, future / 1000); + + const putRes = await request(app) + .put(`/api/stacks/${STACK}`) + .set('Cookie', authCookie) + .set('If-Match', etag) + .send({ content: 'my-overwrite' }); + + expect(putRes.status).toBe(412); + expect(putRes.body).toMatchObject({ + code: 'stack_file_changed', + currentContent: 'changed-by-other-tab', + }); + expect(typeof putRes.body.currentMtimeMs).toBe('number'); + expect(putRes.headers.etag).toMatch(/^W\/"\d+"$/); + expect(fs.readFileSync(filePath, 'utf-8')).toBe('changed-by-other-tab'); + }); + + it('writes through when If-Match is absent', async () => { + seedStack(STACK, 'original'); + + const putRes = await request(app) + .put(`/api/stacks/${STACK}`) + .set('Cookie', authCookie) + .send({ content: 'forced' }); + + expect(putRes.status).toBe(200); + const filePath = path.join(composeDir, STACK, 'compose.yaml'); + expect(fs.readFileSync(filePath, 'utf-8')).toBe('forced'); + }); + + it('writes through when the file does not exist yet (first save creates compose.yaml)', async () => { + const stackDir = path.join(composeDir, STACK); + fs.mkdirSync(stackDir, { recursive: true }); + fs.writeFileSync(path.join(stackDir, 'docker-compose.yaml'), 'legacy', 'utf-8'); + + const putRes = await request(app) + .put(`/api/stacks/${STACK}`) + .set('Cookie', authCookie) + .set('If-Match', 'W/"1234"') + .send({ content: 'fresh' }); + + expect(putRes.status).toBe(200); + expect(fs.readFileSync(path.join(stackDir, 'compose.yaml'), 'utf-8')).toBe('fresh'); + }); + + it('detects a near-boundary mtime change (Math.floor precision)', async () => { + seedStack(STACK, 'original'); + const getRes = await request(app).get(`/api/stacks/${STACK}`).set('Cookie', authCookie); + const etag = getRes.headers.etag as string; + const filePath = path.join(composeDir, STACK, 'compose.yaml'); + const originalStat = fs.statSync(filePath); + + // Bump the mtime by exactly one full second so Math.floor(mtimeMs) is + // guaranteed to differ even on filesystems that round to whole seconds. + fs.writeFileSync(filePath, 'changed-just-after', 'utf-8'); + const bumped = (originalStat.mtimeMs + 1000) / 1000; + fs.utimesSync(filePath, bumped, bumped); + + const putRes = await request(app) + .put(`/api/stacks/${STACK}`) + .set('Cookie', authCookie) + .set('If-Match', etag) + .send({ content: 'my-edit' }); + + expect(putRes.status).toBe(412); + expect(putRes.body.code).toBe('stack_file_changed'); + }); + + it('ignores malformed If-Match headers and writes through', async () => { + seedStack(STACK, 'original'); + + const putRes = await request(app) + .put(`/api/stacks/${STACK}`) + .set('Cookie', authCookie) + .set('If-Match', 'not-a-valid-etag') + .send({ content: 'forced' }); + + expect(putRes.status).toBe(200); + }); +}); + +describe('PUT /api/stacks/:stackName/env optimistic concurrency', () => { + it('writes successfully when If-Match matches', async () => { + seedStack(STACK, 'services: {}'); + const envPath = seedEnv(STACK, 'FOO=1'); + + const getRes = await request(app) + .get(`/api/stacks/${STACK}/env?file=${encodeURIComponent(envPath)}`) + .set('Cookie', authCookie); + const etag = getRes.headers.etag as string; + expect(etag).toMatch(/^W\/"\d+"$/); + + const putRes = await request(app) + .put(`/api/stacks/${STACK}/env?file=${encodeURIComponent(envPath)}`) + .set('Cookie', authCookie) + .set('If-Match', etag) + .send({ content: 'FOO=2' }); + + expect(putRes.status).toBe(200); + expect(fs.readFileSync(envPath, 'utf-8')).toBe('FOO=2'); + }); + + it('returns 412 on env-file mtime mismatch', async () => { + seedStack(STACK, 'services: {}'); + const envPath = seedEnv(STACK, 'FOO=1'); + + const getRes = await request(app) + .get(`/api/stacks/${STACK}/env?file=${encodeURIComponent(envPath)}`) + .set('Cookie', authCookie); + const etag = getRes.headers.etag as string; + + fs.writeFileSync(envPath, 'FOO=bumped', 'utf-8'); + const future = Date.now() + 5_000; + fs.utimesSync(envPath, future / 1000, future / 1000); + + const putRes = await request(app) + .put(`/api/stacks/${STACK}/env?file=${encodeURIComponent(envPath)}`) + .set('Cookie', authCookie) + .set('If-Match', etag) + .send({ content: 'FOO=my-overwrite' }); + + expect(putRes.status).toBe(412); + expect(putRes.body.code).toBe('stack_file_changed'); + expect(putRes.body.currentContent).toBe('FOO=bumped'); + expect(fs.readFileSync(envPath, 'utf-8')).toBe('FOO=bumped'); + }); +}); diff --git a/backend/src/routes/stacks.ts b/backend/src/routes/stacks.ts index 884fee69..865ab15f 100644 --- a/backend/src/routes/stacks.ts +++ b/backend/src/routes/stacks.ts @@ -79,6 +79,18 @@ function releaseStackOpLock(req: Request, stackName: string): void { StackOpLockService.getInstance().release(req.nodeId, stackName); } +function stackFileEtag(mtimeMs: number): string { + return `W/"${Math.floor(mtimeMs)}"`; +} + +function parseIfMatchMtime(raw: string | undefined): number | null { + if (!raw) return null; + const m = /(?:W\/)?"(\d+)"/.exec(raw); + if (!m) return null; + const value = Number(m[1]); + return Number.isFinite(value) ? value : null; +} + async function requireStackExists(nodeId: number, stackName: string, res: Response): Promise { if (!isValidStackName(stackName)) { res.status(400).json({ error: 'Invalid stack name' }); @@ -280,7 +292,8 @@ stacksRouter.put('/:stackName/auto-update', (req: Request, res: Response): void stacksRouter.get('/:stackName', async (req: Request, res: Response) => { try { const stackName = req.params.stackName as string; - const content = await FileSystemService.getInstance(req.nodeId).getStackContent(stackName); + const { content, mtimeMs } = await FileSystemService.getInstance(req.nodeId).getStackContentWithMtime(stackName); + res.setHeader('ETag', stackFileEtag(mtimeMs)); res.send(content); } catch (error) { res.status(500).json({ error: 'Failed to read stack' }); @@ -297,10 +310,22 @@ stacksRouter.put('/:stackName', async (req: Request, res: Response) => { return res.status(400).json({ error: 'Content must be a string' }); } if (isDebugEnabled()) console.debug(`[Stacks:debug] Save starting`, { stackName: sanitizeForLog(stackName), bytes: content.length, nodeId: req.nodeId }); - await FileSystemService.getInstance(req.nodeId).saveStackContent(stackName, content); + const expectedMtimeMs = parseIfMatchMtime(req.header('if-match')); + const result = await FileSystemService.getInstance(req.nodeId) + .saveStackContentIfUnchanged(stackName, content, expectedMtimeMs); + if (!result.ok) { + res.setHeader('ETag', stackFileEtag(result.currentMtimeMs)); + return res.status(412).json({ + error: `${stackName}'s compose file changed since you opened it.`, + code: 'stack_file_changed', + currentMtimeMs: result.currentMtimeMs, + currentContent: result.currentContent, + }); + } invalidateNodeCaches(req.nodeId); console.log(`[Stacks] Compose file saved: ${sanitizeForLog(stackName)}`); - res.json({ message: 'Stack saved successfully' }); + res.setHeader('ETag', stackFileEtag(result.mtimeMs)); + res.json({ message: 'Stack saved successfully', mtimeMs: result.mtimeMs }); } catch (error) { console.error('Failed to save stack:', sanitizeForLog(getErrorMessage(error, 'unknown'))); res.status(500).json({ error: 'Failed to save stack' }); @@ -363,6 +388,8 @@ stacksRouter.get('/:stackName/env', async (req: Request, res: Response) => { try { const content = await fsService.readFile(envPath, 'utf-8'); + const mtimeMs = await fsService.statMtime(envPath); + if (mtimeMs !== null) res.setHeader('ETag', stackFileEtag(mtimeMs)); res.setHeader('X-Env-Exists', 'true'); return res.send(content); } catch (e: unknown) { @@ -405,11 +432,22 @@ stacksRouter.put('/:stackName/env', async (req: Request, res: Response) => { } const fsService = FileSystemService.getInstance(req.nodeId); - await fsService.writeFile(envPath, content, 'utf-8'); + const expectedMtimeMs = parseIfMatchMtime(req.header('if-match')); + const result = await fsService.writeFileIfUnchanged(envPath, content, expectedMtimeMs); + if (!result.ok) { + res.setHeader('ETag', stackFileEtag(result.currentMtimeMs)); + return res.status(412).json({ + error: `${stackName}'s env file changed since you opened it.`, + code: 'stack_file_changed', + currentMtimeMs: result.currentMtimeMs, + currentContent: result.currentContent, + }); + } invalidateNodeCaches(req.nodeId); const envFileName = path.basename(envPath); console.log(`[Stacks] Env file saved: ${sanitizeForLog(stackName)}/${sanitizeForLog(envFileName)}`); - res.json({ message: 'Env file saved successfully' }); + res.setHeader('ETag', stackFileEtag(result.mtimeMs)); + res.json({ message: 'Env file saved successfully', mtimeMs: result.mtimeMs }); } catch (error) { console.error('[Stacks] Failed to save env file:', error); res.status(500).json({ error: 'Failed to save env file' }); diff --git a/backend/src/services/FileSystemService.ts b/backend/src/services/FileSystemService.ts index c2b16b50..6dbc346f 100644 --- a/backend/src/services/FileSystemService.ts +++ b/backend/src/services/FileSystemService.ts @@ -156,6 +156,32 @@ export class FileSystemService { } } + /** + * Read the resolved compose file along with its mtimeMs, which the route + * layer surfaces as an ETag for optimistic-concurrency on PUT. The stat + * and read share a single file descriptor so they observe the same inode + * state, even if the file is replaced (rename) between the two calls. + */ + async getStackContentWithMtime(stackName: string): Promise<{ content: string; mtimeMs: number }> { + const untrustedFilePath = await this.getComposeFilePath(stackName); + // Canonical js/path-injection barrier: resolve against a known-safe root + // then check the result is contained in that root. The form mirrors + // CodeQL's documented sanitizer exactly so taint flow recognizes it. + const baseResolved = path.resolve(this.baseDir); + const safePath = path.resolve(baseResolved, untrustedFilePath); + if (!safePath.startsWith(baseResolved + path.sep)) { + throw Object.assign(new Error('Path escapes compose directory'), { code: 'INVALID_PATH' }); + } + const fh = await fsPromises.open(safePath, 'r'); + try { + const stat = await fh.stat(); + const content = await fh.readFile('utf-8'); + return { content, mtimeMs: stat.mtimeMs }; + } finally { + await fh.close(); + } + } + async saveStackContent(stackName: string, content: string): Promise { const stackDir = this.resolveStackDir(stackName); const filePath = path.join(stackDir, 'compose.yaml'); @@ -167,6 +193,112 @@ export class FileSystemService { } } + /** + * Optimistic-concurrency write: if `expectedMtimeMs` is provided, stat the + * write target first and return `{ok: false}` with the current content and + * mtime when they don't match. The route maps that to 412. Mtime comparison + * uses Math.floor to absorb sub-millisecond jitter from different file + * systems / Node versions. + * + * If the file doesn't exist yet, the write proceeds (no mtime to compare). + * Returns the new mtimeMs so the route can emit a fresh ETag. + */ + async saveStackContentIfUnchanged( + stackName: string, + content: string, + expectedMtimeMs: number | null, + ): Promise< + | { ok: true; mtimeMs: number } + | { ok: false; currentMtimeMs: number; currentContent: string } + > { + const stackDir = this.resolveStackDir(stackName); + const untrustedFilePath = path.join(stackDir, 'compose.yaml'); + // Canonical js/path-injection barrier: path.resolve(SAFE_ROOT, untrusted) + // followed by a single startsWith check, both inline with the sink. + const baseResolved = path.resolve(this.baseDir); + const safePath = path.resolve(baseResolved, untrustedFilePath); + if (!safePath.startsWith(baseResolved + path.sep)) { + throw Object.assign(new Error('Path escapes compose directory'), { code: 'INVALID_PATH' }); + } + + if (expectedMtimeMs !== null) { + let fh: import('fs/promises').FileHandle | null = null; + try { + fh = await fsPromises.open(safePath, 'r'); + const stat = await fh.stat(); + if (Math.floor(stat.mtimeMs) !== Math.floor(expectedMtimeMs)) { + const currentContent = await fh.readFile('utf-8'); + return { ok: false, currentMtimeMs: stat.mtimeMs, currentContent }; + } + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; + // File doesn't exist yet, treat as fresh write. + } finally { + if (fh) await fh.close(); + } + } + + await fsPromises.writeFile(safePath, content, 'utf-8'); + const newStat = await fsPromises.stat(safePath); + return { ok: true, mtimeMs: newStat.mtimeMs }; + } + + /** + * Optimistic-concurrency write for arbitrary paths under the stack dir + * (used for .env files; the path was already validated by the caller). + */ + async writeFileIfUnchanged( + untrustedTargetPath: string, + content: string, + expectedMtimeMs: number | null, + ): Promise< + | { ok: true; mtimeMs: number } + | { ok: false; currentMtimeMs: number; currentContent: string } + > { + // Canonical js/path-injection barrier: path.resolve(SAFE_ROOT, untrusted) + // followed by a single startsWith check, both inline with the sink. + const baseResolved = path.resolve(this.baseDir); + const safePath = path.resolve(baseResolved, untrustedTargetPath); + if (!safePath.startsWith(baseResolved + path.sep)) { + throw Object.assign(new Error('Path escapes compose directory'), { code: 'INVALID_PATH' }); + } + + if (expectedMtimeMs !== null) { + let fh: import('fs/promises').FileHandle | null = null; + try { + fh = await fsPromises.open(safePath, 'r'); + const stat = await fh.stat(); + if (Math.floor(stat.mtimeMs) !== Math.floor(expectedMtimeMs)) { + const currentContent = await fh.readFile('utf-8'); + return { ok: false, currentMtimeMs: stat.mtimeMs, currentContent }; + } + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; + } finally { + if (fh) await fh.close(); + } + } + + await fsPromises.writeFile(safePath, content, 'utf-8'); + const newStat = await fsPromises.stat(safePath); + return { ok: true, mtimeMs: newStat.mtimeMs }; + } + + async statMtime(untrustedTargetPath: string): Promise { + const baseResolved = path.resolve(this.baseDir); + const safePath = path.resolve(baseResolved, untrustedTargetPath); + if (!safePath.startsWith(baseResolved + path.sep)) { + throw Object.assign(new Error('Path escapes compose directory'), { code: 'INVALID_PATH' }); + } + try { + const stat = await fsPromises.stat(safePath); + return stat.mtimeMs; + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null; + throw err; + } + } + async envExists(stackName: string): Promise { const stackDir = this.resolveStackDir(stackName); try { diff --git a/frontend/src/components/EditorLayout/hooks/useEditorViewState.ts b/frontend/src/components/EditorLayout/hooks/useEditorViewState.ts index 9a5bee5e..81251fc2 100644 --- a/frontend/src/components/EditorLayout/hooks/useEditorViewState.ts +++ b/frontend/src/components/EditorLayout/hooks/useEditorViewState.ts @@ -31,8 +31,10 @@ export function useEditorViewState() { const [content, setContent] = useState(''); const [originalContent, setOriginalContent] = useState(''); + const [composeEtag, setComposeEtag] = useState(null); const [envContent, setEnvContent] = useState(''); const [originalEnvContent, setOriginalEnvContent] = useState(''); + const [envEtag, setEnvEtag] = useState(null); const [envExists, setEnvExists] = useState(false); const [envFiles, setEnvFiles] = useState([]); const [selectedEnvFile, setSelectedEnvFile] = useState(''); @@ -57,8 +59,10 @@ export function useEditorViewState() { copiedDigestTimerRef, content, setContent, originalContent, setOriginalContent, + composeEtag, setComposeEtag, envContent, setEnvContent, originalEnvContent, setOriginalEnvContent, + envEtag, setEnvEtag, envExists, setEnvExists, envFiles, setEnvFiles, selectedEnvFile, setSelectedEnvFile, diff --git a/frontend/src/components/EditorLayout/hooks/useStackActions.ts b/frontend/src/components/EditorLayout/hooks/useStackActions.ts index 686fef35..4bfdfab0 100644 --- a/frontend/src/components/EditorLayout/hooks/useStackActions.ts +++ b/frontend/src/components/EditorLayout/hooks/useStackActions.ts @@ -239,6 +239,7 @@ export function useStackActions(options: UseStackActionsOptions) { editorState.setEnvContent(''); editorState.setOriginalEnvContent(''); editorState.setEnvExists(false); + editorState.setEnvEtag(null); }; const loadEnvState = async (filename: string, signal?: AbortSignal) => { @@ -265,9 +266,11 @@ export function useStackActions(options: UseStackActionsOptions) { const envText = await envContentRes.text(); editorState.setEnvContent(envText || ''); editorState.setOriginalEnvContent(envText || ''); + editorState.setEnvEtag(envContentRes.headers.get('etag')); } else { editorState.setEnvContent(''); editorState.setOriginalEnvContent(''); + editorState.setEnvEtag(null); } } else { clearEnvState(); @@ -334,6 +337,7 @@ export function useStackActions(options: UseStackActionsOptions) { navState.setActiveView('editor'); editorState.setContent(text || ''); editorState.setOriginalContent(text || ''); + editorState.setComposeEtag(res.headers.get('etag')); await loadEnvState(filename, signal); await loadContainerState(filename, signal); await loadBackupState(filename, signal); @@ -343,8 +347,10 @@ export function useStackActions(options: UseStackActionsOptions) { stackListState.setSelectedFile(null); editorState.setContent(''); editorState.setOriginalContent(''); + editorState.setComposeEtag(null); editorState.setEnvContent(''); editorState.setOriginalEnvContent(''); + editorState.setEnvEtag(null); editorState.setContainers([]); } finally { if (!signal.aborted) { @@ -380,45 +386,81 @@ export function useStackActions(options: UseStackActionsOptions) { if (!res.ok) { editorState.setEnvContent(''); editorState.setOriginalEnvContent(''); + editorState.setEnvEtag(null); toast.error('Could not load env file'); return; } const text = await res.text(); editorState.setEnvContent(text || ''); editorState.setOriginalEnvContent(text || ''); + editorState.setEnvEtag(res.headers.get('etag')); } catch (e) { console.error('Failed to switch env file', e); editorState.setEnvContent(''); editorState.setOriginalEnvContent(''); + editorState.setEnvEtag(null); toast.error('Failed to load env file'); } finally { editorState.setIsFileLoading(false); } }; - const saveFile = async (): Promise => { + const saveFile = async (options?: { force?: boolean }): Promise => { if (editorState.activeTab === 'files') return false; if (!stackListState.selectedFile) return false; - const currentContent = - editorState.activeTab === 'compose' - ? editorState.content || '' - : editorState.envContent || ''; - const endpoint = - editorState.activeTab === 'compose' - ? `/stacks/${stackListState.selectedFile}` - : `/stacks/${stackListState.selectedFile}/env?file=${encodeURIComponent(editorState.selectedEnvFile)}`; + const force = options?.force === true; + const isCompose = editorState.activeTab === 'compose'; + const currentContent = isCompose + ? editorState.content || '' + : editorState.envContent || ''; + const endpoint = isCompose + ? `/stacks/${stackListState.selectedFile}` + : `/stacks/${stackListState.selectedFile}/env?file=${encodeURIComponent(editorState.selectedEnvFile)}`; + const etag = isCompose ? editorState.composeEtag : editorState.envEtag; + const headers: Record = {}; + if (!force && etag) headers['If-Match'] = etag; try { const response = await apiFetch(endpoint, { method: 'PUT', + headers, body: JSON.stringify({ content: currentContent }), }); + if (response.status === 412) { + const payload = await response.json().catch(() => null); + const currentRemoteContent = + payload && typeof payload.currentContent === 'string' ? payload.currentContent : ''; + const fileName = isCompose + ? 'compose.yaml' + : (editorState.selectedEnvFile || '.env').split('/').pop() ?? '.env'; + const confirmed = window.confirm( + `${fileName} was changed by another tab or process. Overwrite their changes with yours? Click Cancel to discard your local edits and reload the latest version.`, + ); + if (confirmed) { + return await saveFile({ force: true }); + } + if (isCompose) { + editorState.setContent(currentRemoteContent); + editorState.setOriginalContent(currentRemoteContent); + editorState.setComposeEtag(response.headers.get('etag')); + } else { + editorState.setEnvContent(currentRemoteContent); + editorState.setOriginalEnvContent(currentRemoteContent); + editorState.setEnvEtag(response.headers.get('etag')); + } + editorState.setIsEditing(false); + toast.success('Reloaded the latest version of the file.'); + return false; + } if (!response.ok) { throw new Error(`HTTP ${response.status}: ${await response.text()}`); } - if (editorState.activeTab === 'compose') { + const newEtag = response.headers.get('etag'); + if (isCompose) { editorState.setOriginalContent(editorState.content); + if (newEtag) editorState.setComposeEtag(newEtag); } else { editorState.setOriginalEnvContent(editorState.envContent); + if (newEtag) editorState.setEnvEtag(newEtag); } editorState.setIsEditing(false); toast.success('File saved successfully!');