diff --git a/src/core/features/files/FilesController.ts b/src/core/features/files/FilesController.ts index 3c258ac0b..fd50a31f5 100644 --- a/src/core/features/files/FilesController.ts +++ b/src/core/features/files/FilesController.ts @@ -12,14 +12,11 @@ import { IFilesStorage } from '.'; * Files manager for local database */ export class FilesController { - private readonly db; - private readonly fileController; - private readonly workspace; - constructor(db: PGLiteDatabase, fileController: IFilesStorage, workspace: string) { - this.db = db; - this.fileController = fileController; - this.workspace = workspace; - } + constructor( + protected readonly db: PGLiteDatabase, + protected readonly filesStorage: IFilesStorage, + protected readonly workspace: string, + ) {} public async add(file: File) { const db = wrapDB(this.db.get()); @@ -33,21 +30,20 @@ export class FilesController { ); // TODO: delete entry in DB if can't upload file. Or try to upload first and then add file to DB - // TODO: encrypt file // Write file const buffer = await file.arrayBuffer(); - await this.fileController.write(this.getFilePath(fileId), buffer); + await this.filesStorage.write(fileId, buffer); return fileId; } - public async get(id: string) { + public async get(fileId: string) { const db = wrapDB(this.db.get()); // Insert in DB const [fileEntry] = await db .query( - qb.sql`SELECT * FROM files WHERE workspace_id=${this.workspace} AND id=${id}`, + qb.sql`SELECT * FROM files WHERE workspace_id=${this.workspace} AND id=${fileId}`, z.object({ name: z.string(), @@ -60,10 +56,9 @@ export class FilesController { const { name, mimetype } = fileEntry; - const buffer = await this.fileController.get(this.getFilePath(id)); + const buffer = await this.filesStorage.get(fileId); if (!buffer) return null; - // TODO: decrypt file return new File([buffer], name, { type: mimetype }); } @@ -81,9 +76,7 @@ export class FilesController { ); // Delete files - await this.fileController.delete( - filesId.map((filename) => this.getFilePath(filename)), - ); + await this.filesStorage.delete(filesId); } public async query() { @@ -100,8 +93,4 @@ export class FilesController { ) .then(({ rows }) => rows); } - - private getFilePath(filename: string) { - return [this.workspace, filename].join('/'); - } } diff --git a/src/core/features/files/RootedFS.test.ts b/src/core/features/files/RootedFS.test.ts new file mode 100644 index 000000000..c364f0a9f --- /dev/null +++ b/src/core/features/files/RootedFS.test.ts @@ -0,0 +1,91 @@ +// src/core/features/files/RootedFS.nested.test.ts + +import { InMemoryFS } from './InMemoryFS'; +import { RootedFS } from './RootedFS'; + +describe('Nested rooting', () => { + test('Trivial nesting', async () => { + const underlying = new InMemoryFS(); + + await underlying.write('/workspaces/abc/files/img.png', new ArrayBuffer(100)); + await expect(underlying.list()).resolves.toEqual([ + '/workspaces/abc/files/img.png', + ]); + + // Rooted via root + const vaultStorage = new RootedFS(underlying, '/'); + + // Rooted via nested dir + const workspaceFS = new RootedFS(vaultStorage, '/workspaces/abc/files'); + + // List a rooted FS + await expect(workspaceFS.list()).resolves.toEqual(['/img.png']); + + // Write to a rooted FS + await expect( + workspaceFS.write('test.txt', new ArrayBuffer(100)), + ).resolves.not.toThrow(); + await expect(underlying.list()).resolves.toEqual([ + '/workspaces/abc/files/img.png', + '/workspaces/abc/files/test.txt', + ]); + + await expect( + workspaceFS.write('foo/bar/test.txt', new ArrayBuffer(100)), + ).resolves.not.toThrow(); + await expect(underlying.list()).resolves.toEqual([ + '/workspaces/abc/files/img.png', + '/workspaces/abc/files/test.txt', + '/workspaces/abc/files/foo/bar/test.txt', + ]); + + // List an updated rooted FS + await expect(workspaceFS.list()).resolves.toEqual([ + '/img.png', + '/test.txt', + '/foo/bar/test.txt', + ]); + }); + + test('Deep nesting', async () => { + const underlying = new InMemoryFS(); + + await underlying.write('/workspaces/abc/files/img.png', new ArrayBuffer(100)); + await expect(underlying.list()).resolves.toEqual([ + '/workspaces/abc/files/img.png', + ]); + + const vaultStorage = new RootedFS(underlying, '/'); + const workspaces = new RootedFS(vaultStorage, '/workspaces'); + const workspaceAbc = new RootedFS(workspaces, '/abc'); + const workspaceAbcFiles = new RootedFS(workspaceAbc, '/files'); + + // List a rooted FS + await expect(workspaceAbcFiles.list()).resolves.toEqual(['/img.png']); + + // Write to a rooted FS + await expect( + workspaceAbcFiles.write('test.txt', new ArrayBuffer(100)), + ).resolves.not.toThrow(); + await expect(underlying.list()).resolves.toEqual([ + '/workspaces/abc/files/img.png', + '/workspaces/abc/files/test.txt', + ]); + + await expect( + workspaceAbcFiles.write('foo/bar/test.txt', new ArrayBuffer(100)), + ).resolves.not.toThrow(); + await expect(underlying.list()).resolves.toEqual([ + '/workspaces/abc/files/img.png', + '/workspaces/abc/files/test.txt', + '/workspaces/abc/files/foo/bar/test.txt', + ]); + + // List an updated rooted FS + await expect(workspaceAbcFiles.list()).resolves.toEqual([ + '/img.png', + '/test.txt', + '/foo/bar/test.txt', + ]); + }); +}); diff --git a/src/core/features/files/RootedFS.ts b/src/core/features/files/RootedFS.ts index 3258727db..51158de8b 100644 --- a/src/core/features/files/RootedFS.ts +++ b/src/core/features/files/RootedFS.ts @@ -1,20 +1,17 @@ -import { getResolvedPath, joinPathSegments } from '@utils/fs/paths'; +import { getRootedPath, joinPathSegments, normalizePath } from '@utils/fs/paths'; import { OverlayFS } from './OverlayFS'; import { IFilesStorage } from '.'; export class RootedFS extends OverlayFS { - constructor( - storage: IFilesStorage, - private readonly root: string, - ) { + private readonly root; + constructor(storage: IFilesStorage, root: string) { super(storage); + this.root = normalizePath(root); } private getHostPath(path: string) { - const resolvedPath = getResolvedPath(joinPathSegments([this.root, path]), '/'); - const fixedPath = resolvedPath.startsWith(this.root) ? resolvedPath : this.root; - return fixedPath; + return getRootedPath(joinPathSegments([this.root, path]), this.root); } write(path: string, buffer: ArrayBuffer): Promise { @@ -31,10 +28,17 @@ export class RootedFS extends OverlayFS { async list(): Promise { const files = await super.list(); + + const rootSegmentsCount = this.root.split('/').length; return files .values() - .filter((path) => path.startsWith(this.root)) - .map((path) => path.slice(this.root.length)) + .map((path) => { + const rootedPath = getRootedPath(path, this.root); + return this.root === '/' + ? rootedPath + : joinPathSegments(rootedPath.split('/').slice(rootSegmentsCount)); + }) + .filter((path) => path !== '/') .toArray(); } } diff --git a/src/core/features/integrity/FilesIntegrityController.test.ts b/src/core/features/integrity/FilesIntegrityController.test.ts index a2ef8ecf7..5743a1e34 100644 --- a/src/core/features/integrity/FilesIntegrityController.test.ts +++ b/src/core/features/integrity/FilesIntegrityController.test.ts @@ -24,14 +24,10 @@ test('Clear orphaned files', async () => { const attachments = new AttachmentsController(db, FAKE_WORKSPACE_ID); const files = new FilesController(db, fileManager, FAKE_WORKSPACE_ID); - const integrityController = new FilesIntegrityController( - FAKE_WORKSPACE_ID, - fileManager, - { - files, - attachments, - }, - ); + const integrityController = new FilesIntegrityController(fileManager, { + files, + attachments, + }); const NOTE_1 = getUUID(); const NOTE_2 = getUUID(); diff --git a/src/core/features/integrity/FilesIntegrityController.ts b/src/core/features/integrity/FilesIntegrityController.ts index d8211753a..78540c41c 100644 --- a/src/core/features/integrity/FilesIntegrityController.ts +++ b/src/core/features/integrity/FilesIntegrityController.ts @@ -1,5 +1,3 @@ -import { joinPathSegments } from '@utils/fs/paths'; - import { AttachmentsController } from '../attachments/AttachmentsController'; import { IFilesStorage } from '../files'; import { FilesController } from '../files/FilesController'; @@ -7,7 +5,6 @@ import { FilesController } from '../files/FilesController'; // TODO: attach files to a note versions, and never delete attached files that is used at least in one version export class FilesIntegrityController { constructor( - private readonly workspace: string, private readonly files: IFilesStorage, private readonly controllers: { attachments: AttachmentsController; @@ -28,14 +25,14 @@ export class FilesIntegrityController { public async deleteOrphanedFilesInFs() { const filePathsInDB = await this.controllers.files .query() - .then((files) => new Set(files.map((file) => this.getFilePath(file.id)))); + .then((files) => new Set(files.map((file) => `/${file.id}`))); const filesInStorage = await this.files.list(); const orphanedFilePaths = filesInStorage.filter((filePath) => { - const workspacePrefix = this.getFilePath(); - // Skip files out of workspace directory and workspace directory itself - if (!filePath.startsWith(workspacePrefix) || filePath === workspacePrefix) - return false; + const basePath = '/'; + + // Skip files out of workspace directory and a workspace directory itself + if (!filePath.startsWith(basePath) || filePath === basePath) return false; return !filePathsInDB.has(filePath); }); @@ -95,7 +92,7 @@ export class FilesIntegrityController { const filesInfo = await this.controllers.files.query(); filesInfo.forEach((file) => { - if (!filesInStorage.has(this.getFilePath(file.id))) { + if (!filesInStorage.has(`/${file.id}`)) { lostFileIds.push(file.id); } }); @@ -103,8 +100,4 @@ export class FilesIntegrityController { // Delete await this.controllers.files.delete(lostFileIds); } - - private getFilePath(filename?: string) { - return joinPathSegments([this.workspace, filename].filter(Boolean) as string[]); - } } diff --git a/src/features/App/Profile/services/useFilesIntegrityService.tsx b/src/features/App/Profile/services/useFilesIntegrityService.tsx index 55db911c5..c0796775b 100644 --- a/src/features/App/Profile/services/useFilesIntegrityService.tsx +++ b/src/features/App/Profile/services/useFilesIntegrityService.tsx @@ -2,8 +2,10 @@ import { useEffect } from 'react'; import ms from 'ms'; import { AttachmentsController } from '@core/features/attachments/AttachmentsController'; import { FilesController } from '@core/features/files/FilesController'; +import { RootedFS } from '@core/features/files/RootedFS'; import { FilesIntegrityController } from '@core/features/integrity/FilesIntegrityController'; -import { ElectronFilesController, storageApi } from '@electron/requests/storage/renderer'; +import { useVaultStorage } from '@features/files'; +import { getWorkspaceFilesPath } from '@features/files/paths'; import { useService } from '@hooks/useService'; import { useVaultSelector } from '@state/redux/profiles/hooks'; import { @@ -26,6 +28,7 @@ export const useFilesIntegrityService = () => { const { enabled: isServiceEnabled } = useVaultSelector(selectIntegrityServiceConfig); const runService = useService(); + const vaultStorage = useVaultStorage(); useEffect(() => { if (!isServiceEnabled) return; @@ -38,13 +41,12 @@ export const useFilesIntegrityService = () => { const controls = await Promise.all( workspaces.map((workspace) => { - const filesController = new ElectronFilesController( - storageApi, - [profileId, 'files'].join('/'), - encryptionController, + const filesController = new RootedFS( + vaultStorage, + getWorkspaceFilesPath(workspace.id), ); - return new FilesIntegrityController(workspace.id, filesController, { + return new FilesIntegrityController(filesController, { attachments: new AttachmentsController(db, workspace.id), files: new FilesController(db, filesController, workspace.id), }); @@ -91,5 +93,13 @@ export const useFilesIntegrityService = () => { await workerPromise; }; }); - }, [isServiceEnabled, db, encryptionController, profileId, runService, workspaces]); + }, [ + isServiceEnabled, + db, + encryptionController, + profileId, + runService, + workspaces, + vaultStorage, + ]); }; diff --git a/src/features/App/Profiles/hooks/useProfileContainers.ts b/src/features/App/Profiles/hooks/useProfileContainers.ts index 40b9fb4b9..aa79b079f 100644 --- a/src/features/App/Profiles/hooks/useProfileContainers.ts +++ b/src/features/App/Profiles/hooks/useProfileContainers.ts @@ -7,13 +7,14 @@ import { createEncryption } from '@core/features/encryption/createEncryption'; import { IFilesStorage } from '@core/features/files'; import { EncryptedFS } from '@core/features/files/EncryptedFS'; import { FileController } from '@core/features/files/FileController'; +import { RootedFS } from '@core/features/files/RootedFS'; import { WorkspacesController } from '@core/features/workspaces/WorkspacesController'; import { openDatabase, PGLiteDatabase, } from '@core/storage/database/pglite/PGLiteDatabase'; import { ProfileObject } from '@core/storage/ProfilesManager'; -import { ElectronFilesController, storageApi } from '@electron/requests/storage/renderer'; +import { useFilesStorage } from '@features/files'; import { DisposableBox } from '@utils/disposable'; import { createProfilesApi, ProfileEntry } from './profiles'; @@ -56,6 +57,8 @@ export const useProfileContainers = () => { const profiles = useUnit($profiles); const activeProfile = useUnit($activeProfile); + const files = useFilesStorage(); + const { profileOpened, activeProfileChanged } = api.events; const openProfile = useCallback( async ( @@ -64,10 +67,7 @@ export const useProfileContainers = () => { ) => { const cleanups: (() => void)[] = []; - const profileFilesController = new ElectronFilesController( - storageApi, - `/${profile.id}`, - ); + const profileFilesController = new RootedFS(files, `/vaults/${profile.id}`); // Setup encryption let encryptionController: EncryptionController; @@ -111,7 +111,7 @@ export const useProfileContainers = () => { // Setup DB const db = await openDatabase( - new FileController('deepink.db', encryptedProfileFS), + new FileController('vault.db', encryptedProfileFS), ); // Ensure at least one workspace exists @@ -160,7 +160,7 @@ export const useProfileContainers = () => { return newProfile; }, - [activeProfileChanged, profileOpened], + [activeProfileChanged, files, profileOpened], ); return { diff --git a/src/features/App/Profiles/index.tsx b/src/features/App/Profiles/index.tsx index f8626c159..8f4e1e1d9 100644 --- a/src/features/App/Profiles/index.tsx +++ b/src/features/App/Profiles/index.tsx @@ -1,5 +1,6 @@ import React, { FC } from 'react'; import { LexemesRegistry } from '@core/features/notes/controller/LexemesRegistry'; +import { VaultStorage } from '@features/files'; import { useAppDispatch } from '@state/redux/hooks'; import { workspacesApi } from '@state/redux/profiles/profiles'; @@ -42,7 +43,9 @@ export const Profiles: FC = ({ profilesApi }) => { value={controls} key={profile.profile.id} > - + + + ); })} diff --git a/src/features/App/Settings/sections/WorkspaceSettings.tsx b/src/features/App/Settings/sections/WorkspaceSettings.tsx index adbf50619..5a88ce770 100644 --- a/src/features/App/Settings/sections/WorkspaceSettings.tsx +++ b/src/features/App/Settings/sections/WorkspaceSettings.tsx @@ -110,11 +110,10 @@ export const WorkspaceSettings = () => { .then((filesList) => files.delete(filesList.map((file) => file.id))); await filesController.delete([currentWorkspace.workspaceId]); - await new FilesIntegrityController( - currentWorkspace.workspaceId, - filesController, - { files, attachments }, - ).fixAll(); + await new FilesIntegrityController(filesController, { + files, + attachments, + }).fixAll(); dispatch( workspacesApi.setActiveWorkspace({ diff --git a/src/features/App/Workspace/useWorkspace.ts b/src/features/App/Workspace/useWorkspace.ts index b91bee513..a46351722 100644 --- a/src/features/App/Workspace/useWorkspace.ts +++ b/src/features/App/Workspace/useWorkspace.ts @@ -1,17 +1,19 @@ import { useEffect, useState } from 'react'; import { AttachmentsController } from '@core/features/attachments/AttachmentsController'; +import { IFilesStorage } from '@core/features/files'; import { FilesController } from '@core/features/files/FilesController'; import { NotesController } from '@core/features/notes/controller/NotesController'; import { NoteVersions } from '@core/features/notes/history/NoteVersions'; import { TagsController } from '@core/features/tags/controller/TagsController'; -import { ElectronFilesController, storageApi } from '@electron/requests/storage/renderer'; +import { useVaultStorage } from '@features/files'; +import { getWorkspaceFilesPath } from '@features/files/paths'; import { useWorkspaceData } from '@state/redux/profiles/hooks'; import { ProfileContainer } from '../Profiles/hooks/useProfileContainers'; export type WorkspaceContainer = { attachmentsController: AttachmentsController; - filesController: ElectronFilesController; + filesController: IFilesStorage; filesRegistry: FilesController; tagsRegistry: TagsController; notesRegistry: NotesController; @@ -23,25 +25,21 @@ export const useWorkspace = (currentProfile: ProfileContainer) => { const { workspaceId } = useWorkspaceData(); + const files = useVaultStorage(getWorkspaceFilesPath(workspaceId)); useEffect(() => { - const { db, profile, encryptionController } = currentProfile; + const { db } = currentProfile; // Setup files // TODO: implement methods to close the objects after use - const filesController = new ElectronFilesController( - storageApi, - [profile.id, 'files'].join('/'), - encryptionController, - ); setState({ - filesController, + filesController: files, attachmentsController: new AttachmentsController(db, workspaceId), - filesRegistry: new FilesController(db, filesController, workspaceId), + filesRegistry: new FilesController(db, files, workspaceId), tagsRegistry: new TagsController(db, workspaceId), notesRegistry: new NotesController(db, workspaceId), notesHistory: new NoteVersions(db, workspaceId), }); - }, [currentProfile, workspaceId]); + }, [currentProfile, files, workspaceId]); return state; }; diff --git a/src/features/App/index.tsx b/src/features/App/index.tsx index 1884c19b0..a0b8ba789 100644 --- a/src/features/App/index.tsx +++ b/src/features/App/index.tsx @@ -1,8 +1,8 @@ -import React, { FC, useEffect, useState } from 'react'; +import React, { FC, useEffect, useMemo, useState } from 'react'; import { useDebounce } from 'use-debounce'; import { Box } from '@chakra-ui/react'; import { ConfigStorage } from '@core/storage/ConfigStorage'; -import { ElectronFilesController, storageApi } from '@electron/requests/storage/renderer'; +import { useFilesStorage } from '@features/files'; import { SplashScreen } from '@features/SplashScreen'; import { AppServices } from './AppServices'; @@ -14,13 +14,8 @@ import { useRecentProfile } from './useRecentProfile'; import { WorkspaceManager } from './WorkspaceManager'; export const App: FC = () => { - const [config] = useState( - () => - new ConfigStorage( - 'config.json', - new ElectronFilesController(storageApi, '/'), - ), - ); + const files = useFilesStorage(); + const config = useMemo(() => new ConfigStorage('config.json', files), [files]); const profilesList = useProfilesList(); const profileContainers = useProfileContainers(); diff --git a/src/features/App/useProfilesList.ts b/src/features/App/useProfilesList.ts index 35e1f624b..067d76645 100644 --- a/src/features/App/useProfilesList.ts +++ b/src/features/App/useProfilesList.ts @@ -1,10 +1,11 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { bytesToBase64 } from '@core/encryption/utils/encoding'; import { getRandomBytes } from '@core/encryption/utils/random'; import { createEncryption } from '@core/features/encryption/createEncryption'; +import { RootedFS } from '@core/features/files/RootedFS'; import { ProfileObject, ProfilesManager } from '@core/storage/ProfilesManager'; -import { ElectronFilesController, storageApi } from '@electron/requests/storage/renderer'; import { NewProfile } from '@features/App/WorkspaceManager/ProfileCreator'; +import { useFilesStorage } from '@features/files'; export type ProfilesListApi = { isProfilesLoaded: boolean; @@ -16,13 +17,14 @@ export type ProfilesListApi = { * Hook to manage profile accounts */ export const useProfilesList = (): ProfilesListApi => { - const [profilesManager] = useState( + const files = useFilesStorage(); + const profilesManager = useMemo( () => new ProfilesManager( - new ElectronFilesController(storageApi, '/'), - (profileName) => - new ElectronFilesController(storageApi, `/${profileName}`), + files, + (profileName) => new RootedFS(files, `/vaults/${profileName}`), ), + [files], ); const [profiles, setProfiles] = useState([]); diff --git a/src/features/files/index.ts b/src/features/files/index.ts new file mode 100644 index 000000000..9252bc543 --- /dev/null +++ b/src/features/files/index.ts @@ -0,0 +1,19 @@ +import { createContext, useContext, useMemo } from 'react'; +import { IFilesStorage } from '@core/features/files'; +import { RootedFS } from '@core/features/files/RootedFS'; +import { getResolvedPath } from '@utils/fs/paths'; +import { createContextGetterHook } from '@utils/react/createContextGetterHook'; + +export const FilesStorageContext = createContext(null); +export const useFilesStorage = createContextGetterHook(FilesStorageContext); + +export const VaultStorage = createContext(null); +export const useVaultStorage = (root?: string) => { + const storage = useContext(VaultStorage); + if (!storage) throw new Error('Vault storage is not provided'); + + return useMemo( + () => new RootedFS(storage, root ? getResolvedPath(root, '/') : '/'), + [root, storage], + ); +}; diff --git a/src/features/files/paths.ts b/src/features/files/paths.ts new file mode 100644 index 000000000..4dc80e86a --- /dev/null +++ b/src/features/files/paths.ts @@ -0,0 +1,2 @@ +export const getWorkspaceFilesPath = (workspaceId: string) => + `/workspaces/${workspaceId}/files`; diff --git a/src/state/redux/profiles/profiles.ts b/src/state/redux/profiles/profiles.ts index 52968e2cc..12e7c5949 100644 --- a/src/state/redux/profiles/profiles.ts +++ b/src/state/redux/profiles/profiles.ts @@ -1,3 +1,4 @@ +import ms from 'ms'; import z from 'zod'; import { INote, NoteId } from '@core/features/notes'; import { IResolvedTag } from '@core/features/tags'; @@ -14,7 +15,7 @@ export const defaultVaultConfig = { }, snapshots: { enabled: true, - interval: 30_000, + interval: ms('30s'), }, deletion: { confirm: false, diff --git a/src/state/redux/settings/settings.ts b/src/state/redux/settings/settings.ts index 7057a440b..b48e3f996 100644 --- a/src/state/redux/settings/settings.ts +++ b/src/state/redux/settings/settings.ts @@ -43,11 +43,11 @@ export type GlobalSettings = z.output; export const defaultSettings = { checkForUpdates: true, theme: { - name: 'auto', + name: 'zen', accentColor: 'auto', }, editor: { - mode: 'plaintext', + mode: 'richtext', fontFamily: '', fontSize: 16, lineHeight: 1.6, diff --git a/src/utils/fs/paths.test.ts b/src/utils/fs/paths.test.ts index ec30b45bd..3807b8393 100644 --- a/src/utils/fs/paths.test.ts +++ b/src/utils/fs/paths.test.ts @@ -1,4 +1,10 @@ -import { getRelativePath, getResolvedPath, joinPathSegments } from './paths'; +import { + getRelativePath, + getResolvedPath, + getRootedPath, + joinPathSegments, + normalizePath, +} from './paths'; test('joinPathSegments', () => { expect(joinPathSegments([])).toBe('/'); @@ -77,3 +83,34 @@ describe('getRelativePath', () => { expect(getRelativePath('///foo//////bar/x', '/foo/bar/baz')).toBe('../x'); }); }); + +test('Normalized paths must be fully qualified', () => { + expect(normalizePath('.')).toBe('/'); + expect(normalizePath('..')).toBe('/'); + + expect(normalizePath('foo')).toBe('/foo'); + expect(normalizePath('bar')).toBe('/bar'); + expect(normalizePath('foo/bar')).toBe('/foo/bar'); + + expect(normalizePath('/foo/bar')).toBe('/foo/bar'); + expect(normalizePath('///foo/bar')).toBe('/foo/bar'); + expect(normalizePath('../foo/bar')).toBe('/foo/bar'); + + expect(normalizePath('foo/bar/../baz')).toBe('/foo/baz'); +}); + +test('Rooted paths must be always under root', () => { + const basePath = '/foo/bar'; + + expect(getRootedPath('/foo/bar', basePath)).toBe('/foo/bar'); + expect(getRootedPath('/foo/bar/baz', basePath)).toBe('/foo/bar/baz'); + + expect(getRootedPath('/foo', basePath)).toBe(basePath); + expect(getRootedPath('/', basePath)).toBe(basePath); + + expect(getRootedPath('/workspaces/abc/files/img.png', '/')).toBe( + '/workspaces/abc/files/img.png', + ); + + expect(getRootedPath('/foo/barOTHER', basePath)).toBe('/foo/bar'); +}); diff --git a/src/utils/fs/paths.ts b/src/utils/fs/paths.ts index deecadec8..c618c6b4e 100644 --- a/src/utils/fs/paths.ts +++ b/src/utils/fs/paths.ts @@ -87,3 +87,29 @@ export const getRelativePath = (path: string, base: string): string => { return relativePath.length === 0 ? './' : relativePath.join('/'); }; + +/** + * Normalize path to a fully qualified path with leading slash + */ +export const normalizePath = (path: string) => { + const resolvedPath = getResolvedPath(path, '/'); + + return resolvedPath; +}; + +/** + * Ensures the target path will be under a root path + */ +export const getRootedPath = (path: string, root: string) => { + const resolvedRootPath = normalizePath(root); + const resolvedPath = getResolvedPath(path, root); + + if (resolvedRootPath === '/') return resolvedPath; + + const resolvedPathSegments = resolvedPath.split('/'); + const isPathUnderRoot = resolvedRootPath + .split('/') + .every((segment, index) => resolvedPathSegments[index] === segment); + + return isPathUnderRoot ? resolvedPath : resolvedRootPath; +}; diff --git a/src/windows/main/renderer.tsx b/src/windows/main/renderer.tsx index 0f2491fa9..8b70a351e 100644 --- a/src/windows/main/renderer.tsx +++ b/src/windows/main/renderer.tsx @@ -5,8 +5,10 @@ import { createEvent } from 'effector'; import { EventBus } from '@api/events/EventBus'; import { GlobalEventsPayloadMap } from '@api/events/global'; import { patchWindow } from '@electron/requests/electronPatches/renderer'; +import { ElectronFilesController, storageApi } from '@electron/requests/storage/renderer'; import { telemetry } from '@electron/requests/telemetry/renderer'; import { App } from '@features/App/index'; +import { FilesStorageContext } from '@features/files'; import { TelemetryContext } from '@features/telemetry'; import { ThemeProvider } from '@features/ThemeProvider'; import { CommandEventProvider } from '@hooks/commands/CommandEventProvider'; @@ -44,16 +46,20 @@ const globalEventBus = { }, } satisfies EventBus; +const filesController = new ElectronFilesController(storageApi, `/`); + const reactRoot = createRoot(rootNode); reactRoot.render( - - - - - + + + + + + + ,