Skip to content

Commit 1035f2b

Browse files
committed
fix: prevent overwriting template files via <FileTree>
1 parent 5c1de69 commit 1035f2b

File tree

14 files changed

+172
-27
lines changed

14 files changed

+172
-27
lines changed

docs/tutorialkit.dev/src/components/react-examples/ExampleFileTree.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ export default function ExampleFileTree() {
1313
hiddenFiles={['package-lock.json']}
1414
selectedFile={selectedFile}
1515
onFileSelect={setSelectedFile}
16-
onFileChange={(event) => {
16+
onFileChange={async (event) => {
1717
if (event.method === 'add') {
1818
setFiles([...files, { path: event.value, type: event.type }]);
1919
}

docs/tutorialkit.dev/src/content/docs/reference/configuration.mdx

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,15 +103,29 @@ type I18nText = {
103103
*
104104
* @default 'This action is not allowed'
105105
*/
106-
fileTreeActionNotAllowed?: string,
106+
fileTreeActionNotAllowedText?: string,
107107

108108
/**
109-
* Text shown on dialog describing allowed patterns when file or folder createion failed.
109+
* Text shown on dialog when user attempts create file or folder that already exists on filesystem but is not visible on file tree, e.g. template files.
110+
*
111+
* @default 'File exists on filesystem already'
112+
*/
113+
fileTreeFileExistsAlreadyText?: string,
114+
115+
/**
116+
* Text shown on dialog describing allowed patterns when file or folder creation failed.
110117
*
111118
* @default 'Created files and folders must match following patterns:'
112119
*/
113120
fileTreeAllowedPatternsText?: string,
114121

122+
/**
123+
* Text shown on confirmation buttons on dialogs.
124+
*
125+
* @default 'OK'
126+
*/
127+
confirmationText?: string,
128+
115129
/**
116130
* Text shown on top of the steps section.
117131
*

docs/tutorialkit.dev/src/content/docs/reference/react-components.mdx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,13 +107,15 @@ A component to list files in a tree view.
107107

108108
* `onFileSelect: (file: string) => void` - A callback that will be called when a file is clicked. The path of the file that was clicked will be passed as an argument.
109109

110-
* `onFileChange: (event: FileChangeEvent) => void` - An optional callback that will be called when a new file or folder is created from the file tree's context menu. When callback is not passed, file tree does not allow adding new files.
110+
* `onFileChange: (event: FileChangeEvent) => Promise<void>` - An optional callback that will be called when a new file or folder is created from the file tree's context menu. This callback should throw errors with `FilesystemError` messages.
111111
```ts
112112
interface FileChangeEvent {
113113
type: 'file' | 'folder';
114114
method: 'add' | 'remove' | 'rename';
115115
value: string;
116116
}
117+
118+
type FilesystemError = 'FILE_EXISTS' | 'FOLDER_EXISTS';
117119
```
118120

119121
* `allowEditPatterns?: string[]` - Glob patterns for paths that allow editing files and folders. Disabled by default.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export default 'This file is present on template';

e2e/src/templates/default/folder-on-template/.gitkeep

Whitespace-only changes.

e2e/src/templates/file-server/index.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import http from 'node:http';
21
import fs from 'node:fs';
2+
import http from 'node:http';
33

44
const server = http.createServer((req, res) => {
55
if (req.url === '/' || req.url === '/index.html') {

e2e/test/file-tree.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,3 +222,29 @@ test('user cannot create files or folders in disallowed directories', async ({ p
222222
await expect(dialog).not.toBeVisible();
223223
}
224224
});
225+
226+
test('user cannot create files or folders that exist on template', async ({ page }) => {
227+
await page.goto(`${BASE_URL}/allow-edits-enabled`);
228+
await expect(page.getByRole('heading', { level: 1, name: 'File Tree test - Allow Edits enabled' })).toBeVisible();
229+
230+
// wait for terminal to start
231+
const terminalOutput = page.getByRole('tabpanel', { name: 'Terminal' });
232+
await expect(terminalOutput).toContainText('~/tutorial', { useInnerText: true });
233+
234+
for (const [name, type] of [
235+
['file-on-template.js', 'file'],
236+
['folder-on-template', 'folder'],
237+
] as const) {
238+
await page.getByTestId('file-tree-root-context-menu').click({ button: 'right' });
239+
await page.getByRole('menuitem', { name: `Create ${type}` }).click();
240+
241+
await page.locator('*:focus').fill(name);
242+
await page.locator('*:focus').press('Enter');
243+
244+
const dialog = page.getByRole('dialog', { name: 'This action is not allowed' });
245+
await expect(dialog.getByText('File exists on filesystem already')).toBeVisible();
246+
247+
await dialog.getByRole('button', { name: 'OK' }).click();
248+
await expect(dialog).not.toBeVisible();
249+
}
250+
});

packages/react/src/Panels/WorkspacePanel.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ function EditorSection({ theme, tutorialStore, hasEditor }: PanelProps) {
122122
}
123123
}
124124

125-
function onFileTreeChange({ method, type, value }: FileTreeChangeEvent) {
125+
async function onFileTreeChange({ method, type, value }: FileTreeChangeEvent) {
126126
if (method === 'add' && type === 'file') {
127127
return tutorialStore.addFile(value);
128128
}

packages/react/src/core/ContextMenu.tsx

Lines changed: 41 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Root, Portal, Content, Item, Trigger } from '@radix-ui/react-context-menu';
22
import * as RadixDialog from '@radix-ui/react-dialog';
3-
import { DEFAULT_LOCALIZATION, type FileDescriptor, type I18n } from '@tutorialkit/types';
3+
import { DEFAULT_LOCALIZATION, type FileDescriptor, type I18n, type FilesystemError } from '@tutorialkit/types';
44
import picomatch from 'picomatch/posix';
55
import { useRef, useState, type ComponentProps, type ReactNode } from 'react';
66
import { Button } from '../Button.js';
@@ -18,8 +18,8 @@ interface FileRenameEvent extends FileChangeEvent {
1818
}
1919

2020
interface Props extends ComponentProps<'div'> {
21-
/** Callback invoked when file is changed. */
22-
onFileChange?: (event: FileChangeEvent | FileRenameEvent) => void;
21+
/** Callback invoked when file is changed. This callback should throw errors with {@link FilesystemError} messages. */
22+
onFileChange?: (event: FileChangeEvent | FileRenameEvent) => Promise<void>;
2323

2424
/** Glob patterns for paths that allow editing files and folders. Disabled by default. */
2525
allowEditPatterns?: string[];
@@ -37,6 +37,7 @@ interface Props extends ComponentProps<'div'> {
3737
| 'fileTreeCreateFolderText'
3838
| 'fileTreeActionNotAllowedText'
3939
| 'fileTreeAllowedPatternsText'
40+
| 'fileTreeFileExistsAlreadyText'
4041
| 'confirmationText'
4142
>;
4243

@@ -54,14 +55,16 @@ export function ContextMenu({
5455
triggerProps,
5556
...props
5657
}: Props) {
57-
const [state, setState] = useState<'idle' | 'add_file' | 'add_folder' | 'add_failed'>('idle');
58+
const [state, setState] = useState<
59+
'idle' | 'add_file' | 'add_folder' | 'add_failed_not_allowed' | 'add_failed_exists'
60+
>('idle');
5861
const inputRef = useRef<HTMLInputElement>(null);
5962

6063
if (!allowEditPatterns?.length) {
6164
return children;
6265
}
6366

64-
function onFileNameEnd(event: React.KeyboardEvent<HTMLInputElement> | React.FocusEvent<HTMLInputElement>) {
67+
async function onFileNameEnd(event: React.KeyboardEvent<HTMLInputElement> | React.FocusEvent<HTMLInputElement>) {
6568
if (state !== 'add_file' && state !== 'add_folder') {
6669
return;
6770
}
@@ -72,14 +75,22 @@ export function ContextMenu({
7275
const value = `${directory}/${name}`;
7376
const isAllowed = picomatch.isMatch(value, allowEditPatterns!);
7477

75-
if (isAllowed) {
76-
onFileChange?.({
78+
if (!isAllowed) {
79+
return setState('add_failed_not_allowed');
80+
}
81+
82+
try {
83+
await onFileChange?.({
7784
value,
7885
type: state === 'add_file' ? 'file' : 'folder',
7986
method: 'add',
8087
});
81-
} else {
82-
return setState('add_failed');
88+
} catch (error: any) {
89+
const message: FilesystemError | (string & {}) | undefined = error?.message;
90+
91+
if (message === 'FILE_EXISTS' || message === 'FOLDER_EXISTS') {
92+
return setState('add_failed_exists');
93+
}
8394
}
8495
}
8596

@@ -140,20 +151,20 @@ export function ContextMenu({
140151
</Content>
141152
</Portal>
142153

143-
{state === 'add_failed' && (
154+
{(state === 'add_failed_not_allowed' || state === 'add_failed_exists') && (
144155
<Dialog
145156
title={i18n?.fileTreeActionNotAllowedText || DEFAULT_LOCALIZATION.fileTreeActionNotAllowedText}
146157
confirmText={i18n?.confirmationText || DEFAULT_LOCALIZATION.confirmationText}
147158
onClose={() => setState('idle')}
148159
>
149-
{i18n?.fileTreeAllowedPatternsText || DEFAULT_LOCALIZATION.fileTreeAllowedPatternsText}
150-
<ul className={classNames('mt-2', allowEditPatterns.length > 1 && 'list-disc ml-4')}>
151-
{allowEditPatterns.map((pattern) => (
152-
<li key={pattern} className="mb-1">
153-
<code>{pattern}</code>
154-
</li>
155-
))}
156-
</ul>
160+
{state === 'add_failed_not_allowed' ? (
161+
<>
162+
{i18n?.fileTreeAllowedPatternsText || DEFAULT_LOCALIZATION.fileTreeAllowedPatternsText}
163+
<AllowPatternsList allowEditPatterns={allowEditPatterns} />
164+
</>
165+
) : (
166+
<>{i18n?.fileTreeFileExistsAlreadyText || DEFAULT_LOCALIZATION.fileTreeFileExistsAlreadyText}</>
167+
)}
157168
</Dialog>
158169
)}
159170
</Root>
@@ -203,3 +214,15 @@ function Dialog({
203214
</RadixDialog.Root>
204215
);
205216
}
217+
218+
function AllowPatternsList({ allowEditPatterns }: Required<Pick<Props, 'allowEditPatterns'>>) {
219+
return (
220+
<ul className={classNames('mt-2', allowEditPatterns.length > 1 && 'list-disc ml-4')}>
221+
{allowEditPatterns.map((pattern) => (
222+
<li key={pattern} className="mb-1">
223+
<code>{pattern}</code>
224+
</li>
225+
))}
226+
</ul>
227+
);
228+
}

packages/runtime/src/store/index.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { FileDescriptor, Files, Lesson } from '@tutorialkit/types';
1+
import type { FileDescriptor, Files, FilesystemError, Lesson } from '@tutorialkit/types';
22
import type { WebContainer } from '@webcontainer/api';
33
import { atom, type ReadableAtom } from 'nanostores';
44
import { LessonFilesFetcher } from '../lesson-files.js';
@@ -312,7 +312,7 @@ export class TutorialStore {
312312
this._editorStore.setSelectedFile(filePath);
313313
}
314314

315-
addFile(filePath: string) {
315+
async addFile(filePath: string): Promise<void> {
316316
// always select the existing or newly created file
317317
this.setSelectedFile(filePath);
318318

@@ -321,16 +321,24 @@ export class TutorialStore {
321321
return;
322322
}
323323

324+
if (await this._runner.fileExists(filePath)) {
325+
throw new Error('FILE_EXISTS' satisfies FilesystemError);
326+
}
327+
324328
this._editorStore.addFileOrFolder({ path: filePath, type: 'file' });
325329
this._runner.updateFile(filePath, '');
326330
}
327331

328-
addFolder(folderPath: string) {
332+
async addFolder(folderPath: string) {
329333
// prevent creating duplicates
330334
if (this._editorStore.files.get().some((file) => file.path.startsWith(folderPath))) {
331335
return;
332336
}
333337

338+
if (await this._runner.folderExists(folderPath)) {
339+
throw new Error('FOLDER_EXISTS' satisfies FilesystemError);
340+
}
341+
334342
this._editorStore.addFileOrFolder({ path: folderPath, type: 'folder' });
335343
this._runner.createFolder(folderPath);
336344
}

packages/runtime/src/store/tutorial-runner.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,62 @@ export class TutorialRunner {
189189
);
190190
}
191191

192+
async fileExists(filepath: string) {
193+
const previousLoadPromise = this._currentLoadTask?.promise;
194+
195+
return new Promise<boolean>((resolve, reject) => {
196+
this._currentLoadTask = newTask(
197+
async (signal) => {
198+
await previousLoadPromise;
199+
200+
const webcontainer = await this._webcontainer;
201+
202+
if (signal.aborted) {
203+
reject(new Error('Task was aborted'));
204+
}
205+
206+
signal.throwIfAborted();
207+
208+
try {
209+
await webcontainer.fs.readFile(filepath);
210+
resolve(true);
211+
} catch {
212+
resolve(false);
213+
}
214+
},
215+
{ ignoreCancel: true },
216+
);
217+
});
218+
}
219+
220+
async folderExists(folderPath: string) {
221+
const previousLoadPromise = this._currentLoadTask?.promise;
222+
223+
return new Promise<boolean>((resolve, reject) => {
224+
this._currentLoadTask = newTask(
225+
async (signal) => {
226+
await previousLoadPromise;
227+
228+
const webcontainer = await this._webcontainer;
229+
230+
if (signal.aborted) {
231+
reject(new Error('Task was aborted'));
232+
}
233+
234+
signal.throwIfAborted();
235+
236+
try {
237+
await webcontainer.fs.readdir(folderPath);
238+
resolve(true);
239+
} catch {
240+
resolve(false);
241+
}
242+
},
243+
{ ignoreCancel: true },
244+
);
245+
});
246+
}
247+
192248
/**
193249
* Load the provided files into WebContainer and remove any other files that had been loaded previously.
194250
*

packages/types/src/default-localization.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export const DEFAULT_LOCALIZATION = {
1010
fileTreeCreateFileText: 'Create file',
1111
fileTreeCreateFolderText: 'Create folder',
1212
fileTreeActionNotAllowedText: 'This action is not allowed',
13+
fileTreeFileExistsAlreadyText: 'File exists on filesystem already',
1314
fileTreeAllowedPatternsText: 'Created files and folders must match following patterns:',
1415
confirmationText: 'OK',
1516
prepareEnvironmentTitleText: 'Preparing Environment',

packages/types/src/entities/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ export type * from './nav.js';
66
export type FileDescriptor = { path: string; type: 'file' | 'folder' };
77
export type Files = Record<string, string | Uint8Array>;
88

9+
export type FilesystemError = 'FILE_EXISTS' | 'FOLDER_EXISTS';
10+
911
/**
1012
* This tuple contains a "ref" which points to a file to fetch with the `LessonFilesFetcher` and
1113
* the list of file paths included by that ref.

packages/types/src/schemas/i18n.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,18 @@ export const i18nSchema = z.object({
8282
.optional()
8383
.describe("Text shown on dialog when user attempts to edit files that don't match allowed patterns."),
8484

85+
/**
86+
* Text shown on dialog when user attempts create file or folder that already exists on filesystem but is not visible on file tree, e.g. template files.
87+
*
88+
* @default 'File exists on filesystem already'
89+
*/
90+
fileTreeFileExistsAlreadyText: z
91+
.string()
92+
.optional()
93+
.describe(
94+
'Text shown on dialog when user attempts create file or folder that already exists on filesystem but is not visible on file tree, e.g. template files.',
95+
),
96+
8597
/**
8698
* Text shown on dialog describing allowed patterns when file or folder creation failed.
8799
*

0 commit comments

Comments
 (0)