Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion packages/keystatic/src/api/api-node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,27 @@ async function update(
deletions: s.array(s.object({ path: filepath })),
})
);
} catch {
} catch (err) {
if (err instanceof s.StructError) {
return {
status: 400,
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
error: 'Bad data',
path: err.path.join('.'),
type: err.type,
message: err.message,
failures: err
.failures()
.map(f => ({
path: f.path,
type: f.type,
message: f.message,
refinement: f.refinement,
})),
}),
};
}
return { status: 400, body: 'Bad data' };
}
for (const addition of updates.additions) {
Expand Down
55 changes: 55 additions & 0 deletions packages/keystatic/src/app/path-utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/** @jest-environment node */
import { expect, test } from '@jest/globals';
import { normalizePosixPath } from './path-utils';

test('normalizePosixPath resolves a single .. segment', () => {
expect(normalizePosixPath('a/b/../c')).toBe('a/c');
});

test('normalizePosixPath resolves consecutive .. segments', () => {
expect(normalizePosixPath('a/b/c/../../d')).toBe('a/d');
});

test('normalizePosixPath drops . segments', () => {
expect(normalizePosixPath('a/./b/./c')).toBe('a/b/c');
});

test('normalizePosixPath collapses consecutive slashes', () => {
expect(normalizePosixPath('a//b///c')).toBe('a/b/c');
});

test('normalizePosixPath preserves leading .. when no parent to resolve against', () => {
expect(normalizePosixPath('../a/b')).toBe('../a/b');
});

test('normalizePosixPath preserves multiple leading .. segments', () => {
expect(normalizePosixPath('../../a')).toBe('../../a');
});

test('normalizePosixPath drops .. at the root of an absolute path', () => {
expect(normalizePosixPath('/../a')).toBe('/a');
});

test('normalizePosixPath leaves an already-clean relative path alone', () => {
expect(normalizePosixPath('a/b/c.txt')).toBe('a/b/c.txt');
});

test('normalizePosixPath leaves an already-clean absolute path alone', () => {
expect(normalizePosixPath('/a/b/c.txt')).toBe('/a/b/c.txt');
});

test('normalizePosixPath handles the realistic Keystatic update case', () => {
// The bug case: a markdoc body in `src/content/blog/{slug}.mdoc`
// references an image at `../../assets/blog/x.png`. Keystatic's
// editor concatenates the entry base path, the slug, the field name,
// and the image's relative reference into a single wire-format path:
// src/content/blog/{slug}/content/../../assets/blog/x.png
// Before this fix that string was sent verbatim and failed the
// `getIsPathValid` refinement on its `..` segments. After this fix
// it resolves the `..` segments and a valid path reaches the API.
expect(
normalizePosixPath(
'src/content/blog/my-post/content/../../assets/blog/x.png'
)
).toBe('src/content/blog/assets/blog/x.png');
});
35 changes: 35 additions & 0 deletions packages/keystatic/src/app/path-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,41 @@ export function fixPath(path: string) {
return path.replace(/^\.?\/+/, '').replace(/\/*$/, '');
}

/**
* Resolve `..` and `.` segments in a POSIX-style path string in the browser.
*
* Used when constructing addition / deletion file paths from a base directory
* plus a relative reference written inside a content file (e.g. an image
* `../../assets/blog/x.png` referenced from a markdoc body). Without this,
* paths like `src/content/blog/{slug}/{field}/../../assets/blog/x.png` get
* sent to the update API verbatim and fail the `getIsPathValid` refinement
* because any `..` segment is rejected outright.
*
* Mirrors `path.posix.normalize` semantics: leading `..` segments that cannot
* be resolved against an existing parent are preserved (so the path validator
* will still catch genuinely-escaping paths); `.` segments are dropped;
* consecutive slashes collapse to one.
*/
export function normalizePosixPath(path: string): string {
const isAbsolute = path.startsWith('/');
const segments = path.split('/');
const result: string[] = [];
for (const seg of segments) {
if (seg === '' || seg === '.') continue;
if (seg === '..') {
if (result.length > 0 && result[result.length - 1] !== '..') {
result.pop();
} else if (!isAbsolute) {
result.push('..');
}
// for absolute paths, leading `..` at root is dropped (matches POSIX)
continue;
}
result.push(seg);
}
return (isAbsolute ? '/' : '') + result.join('/');
}

const collectionPath = /\/\*\*?(?:$|\/)/;

function getConfiguredCollectionPath(config: Config, collection: string) {
Expand Down
27 changes: 24 additions & 3 deletions packages/keystatic/src/app/updating.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,12 @@ import {
useSetTreeSha,
} from './shell/data';
import { hydrateBlobCache } from './useItemData';
import { FormatInfo, getEntryDataFilepath, getPathPrefix } from './path-utils';
import {
FormatInfo,
getEntryDataFilepath,
getPathPrefix,
normalizePosixPath,
} from './path-utils';
import {
getTreeNodeAtPath,
TreeEntry,
Expand Down Expand Up @@ -311,11 +316,23 @@ export function useUpsertItem(args: {
'no-cors': '1',
},
body: JSON.stringify({
// Normalize path strings at the wire boundary so that any
// unresolved `..` segments (introduced when relative image
// references like `../../assets/blog/x.png` get concatenated
// with the entry's base path during serialization) are
// collapsed before they hit the API's `getIsPathValid`
// refinement, which rejects any `..` segment outright. The
// upstream diff math runs on raw paths so additions and
// deletions still cancel symmetrically; only the final wire
// payload is normalized.
additions: additions.map(addition => ({
...addition,
path: normalizePosixPath(addition.path),
contents: base64Encode(addition.contents),
})),
deletions,
deletions: deletions.map(d => ({
path: normalizePosixPath(d.path),
})),
}),
});
if (!res.ok) {
Expand Down Expand Up @@ -451,7 +468,11 @@ export function useDeleteItem(args: {
},
body: JSON.stringify({
additions: [],
deletions: deletions.map(path => ({ path })),
// Normalize wire-boundary paths — see corresponding comment in
// the upsert path above.
deletions: deletions.map(path => ({
path: normalizePosixPath(path),
})),
}),
});
if (!res.ok) {
Expand Down