decap-server 3.10.0 → 3.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,8 @@
1
1
  import path from 'path';
2
2
  import { promises as fs } from 'fs';
3
3
 
4
+ import { resolveExistingRepoPath, resolveNewRepoPath } from './path';
5
+
4
6
  async function listFiles(dir: string, extension: string, depth: number): Promise<string[]> {
5
7
  if (depth <= 0) {
6
8
  return [];
@@ -28,17 +30,20 @@ export async function listRepoFiles(
28
30
  extension: string,
29
31
  depth: number,
30
32
  ) {
31
- const files = await listFiles(path.join(repoPath, folder), extension, depth);
32
- return files.map(f => f.slice(repoPath.length + 1));
33
+ const repoRoot = await fs.realpath(path.resolve(repoPath));
34
+ const files = await listFiles(await resolveExistingRepoPath(repoRoot, folder), extension, depth);
35
+ return files.map(f => path.relative(repoRoot, f));
33
36
  }
34
37
 
35
- export async function writeFile(filePath: string, content: Buffer | string) {
36
- await fs.mkdir(path.dirname(filePath), { recursive: true });
37
- await fs.writeFile(filePath, content);
38
+ export async function writeFile(repoPath: string, filePath: string, content: Buffer | string) {
39
+ const resolvedPath = await resolveNewRepoPath(repoPath, filePath);
40
+ await fs.mkdir(path.dirname(resolvedPath), { recursive: true });
41
+ await fs.writeFile(resolvedPath, content);
38
42
  }
39
43
 
40
44
  export async function deleteFile(repoPath: string, filePath: string) {
41
- await fs.unlink(path.join(repoPath, filePath)).catch(() => undefined);
45
+ const resolvedPath = await resolveNewRepoPath(repoPath, filePath);
46
+ await fs.unlink(resolvedPath).catch(() => undefined);
42
47
  }
43
48
 
44
49
  async function moveFile(from: string, to: string) {
@@ -46,23 +51,29 @@ async function moveFile(from: string, to: string) {
46
51
  await fs.rename(from, to);
47
52
  }
48
53
 
49
- export async function move(from: string, to: string, hasSubfolders = true) {
54
+ export async function move(repoPath: string, from: string, to: string, hasSubfolders = true) {
55
+ const resolvedFrom = await resolveExistingRepoPath(repoPath, from);
56
+ const resolvedTo = await resolveNewRepoPath(repoPath, to);
57
+
50
58
  // move file
51
- await moveFile(from, to);
59
+ await moveFile(resolvedFrom, resolvedTo);
52
60
 
53
61
  if (hasSubfolders) {
54
62
  // Legacy behavior (subfolders: true, default): move all files in the directory
55
63
  // This is for collections where all files in a folder represent a single entry
56
- const sourceDir = path.dirname(from);
57
- const destDir = path.dirname(to);
64
+ const sourceDir = path.dirname(resolvedFrom);
65
+ const destDir = path.dirname(resolvedTo);
58
66
  const allFiles = await listFiles(sourceDir, '', 100);
59
67
  await Promise.all(allFiles.map(file => moveFile(file, file.replace(sourceDir, destDir))));
60
68
  }
61
69
  }
62
70
 
63
71
  export async function getUpdateDate(repoPath: string, filePath: string) {
64
- return fs
65
- .stat(path.join(repoPath, filePath))
66
- .then(stat => stat.mtime)
67
- .catch(() => new Date());
72
+ try {
73
+ return await fs
74
+ .stat(await resolveExistingRepoPath(repoPath, filePath))
75
+ .then(stat => stat.mtime);
76
+ } catch (e) {
77
+ return new Date();
78
+ }
68
79
  }
@@ -0,0 +1,27 @@
1
+ import path from 'path';
2
+
3
+ import { resolveRepoPath } from './path';
4
+
5
+ describe('resolveRepoPath', () => {
6
+ const repoPath = path.resolve('projects', 'repo');
7
+
8
+ it('resolves paths within the repository', () => {
9
+ expect(resolveRepoPath(repoPath, 'content/posts/post.md')).toBe(
10
+ path.join(repoPath, 'content', 'posts', 'post.md'),
11
+ );
12
+ });
13
+
14
+ it('rejects sibling paths that share the repository prefix', () => {
15
+ expect(() => resolveRepoPath(repoPath, path.join('..', 'repo-owned', 'secret.txt'))).toThrow(
16
+ 'Path must resolve under the configured repository',
17
+ );
18
+ });
19
+
20
+ it('rejects absolute paths outside the repository', () => {
21
+ const outsidePath = path.resolve(repoPath, '..', 'outside', 'secret.txt');
22
+
23
+ expect(() => resolveRepoPath(repoPath, outsidePath)).toThrow(
24
+ 'Path must resolve under the configured repository',
25
+ );
26
+ });
27
+ });
@@ -0,0 +1,59 @@
1
+ import path from 'path';
2
+ import { promises as fs } from 'fs';
3
+
4
+ const invalidPathMessage = 'Path must resolve under the configured repository';
5
+
6
+ function assertPathUnderRoot(repoRoot: string, resolvedPath: string) {
7
+ const relativePath = path.relative(repoRoot, resolvedPath);
8
+
9
+ if (
10
+ relativePath === '..' ||
11
+ relativePath.startsWith(`..${path.sep}`) ||
12
+ path.isAbsolute(relativePath)
13
+ ) {
14
+ throw new Error(invalidPathMessage);
15
+ }
16
+ }
17
+
18
+ export function resolveRepoPath(repoPath: string, filePath: string) {
19
+ const repoRoot = path.resolve(repoPath);
20
+ const resolvedPath = path.resolve(repoRoot, filePath);
21
+ assertPathUnderRoot(repoRoot, resolvedPath);
22
+
23
+ return resolvedPath;
24
+ }
25
+
26
+ export async function resolveExistingRepoPath(repoPath: string, filePath: string) {
27
+ const repoRoot = await fs.realpath(path.resolve(repoPath));
28
+ const resolvedPath = await fs.realpath(resolveRepoPath(repoPath, filePath));
29
+ assertPathUnderRoot(repoRoot, resolvedPath);
30
+
31
+ return resolvedPath;
32
+ }
33
+
34
+ export async function resolveNewRepoPath(repoPath: string, filePath: string) {
35
+ const repoRoot = await fs.realpath(path.resolve(repoPath));
36
+ const resolvedPath = resolveRepoPath(repoPath, filePath);
37
+ const missingSegments: string[] = [];
38
+ let existingPath = resolvedPath;
39
+ let realExistingPath: string | undefined;
40
+
41
+ while (!realExistingPath) {
42
+ try {
43
+ await fs.lstat(existingPath);
44
+ } catch (e) {
45
+ if ((e as NodeJS.ErrnoException).code !== 'ENOENT') {
46
+ throw e;
47
+ }
48
+ missingSegments.unshift(path.basename(existingPath));
49
+ existingPath = path.dirname(existingPath);
50
+ continue;
51
+ }
52
+
53
+ realExistingPath = await fs.realpath(existingPath);
54
+ }
55
+
56
+ assertPathUnderRoot(repoRoot, realExistingPath);
57
+
58
+ return path.join(realExistingPath, ...missingSegments);
59
+ }