Skip to content
Merged
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
31 changes: 10 additions & 21 deletions src/core/features/files/FilesController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
) {}
Comment thread
vitonsky marked this conversation as resolved.

public async add(file: File) {
const db = wrapDB(this.db.get());
Expand All @@ -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(),
Expand All @@ -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 });
}

Expand All @@ -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() {
Expand All @@ -100,8 +93,4 @@ export class FilesController {
)
.then(({ rows }) => rows);
}

private getFilePath(filename: string) {
return [this.workspace, filename].join('/');
}
}
91 changes: 91 additions & 0 deletions src/core/features/files/RootedFS.test.ts
Original file line number Diff line number Diff line change
@@ -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();
Comment thread
vitonsky marked this conversation as resolved.
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',
]);
});
});
24 changes: 14 additions & 10 deletions src/core/features/files/RootedFS.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Comment thread
vitonsky marked this conversation as resolved.

write(path: string, buffer: ArrayBuffer): Promise<void> {
Expand All @@ -31,10 +28,17 @@ export class RootedFS extends OverlayFS {

async list(): Promise<string[]> {
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();
}
}
12 changes: 4 additions & 8 deletions src/core/features/integrity/FilesIntegrityController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
19 changes: 6 additions & 13 deletions src/core/features/integrity/FilesIntegrityController.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
import { joinPathSegments } from '@utils/fs/paths';

import { AttachmentsController } from '../attachments/AttachmentsController';
import { IFilesStorage } from '../files';
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;
Expand All @@ -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);
});
Expand Down Expand Up @@ -95,16 +92,12 @@ 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);
}
});

// Delete
await this.controllers.files.delete(lostFileIds);
}

private getFilePath(filename?: string) {
return joinPathSegments([this.workspace, filename].filter(Boolean) as string[]);
}
}
24 changes: 17 additions & 7 deletions src/features/App/Profile/services/useFilesIntegrityService.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Comment thread
coderabbitai[bot] marked this conversation as resolved.
import { useService } from '@hooks/useService';
import { useVaultSelector } from '@state/redux/profiles/hooks';
import {
Expand All @@ -26,6 +28,7 @@ export const useFilesIntegrityService = () => {
const { enabled: isServiceEnabled } = useVaultSelector(selectIntegrityServiceConfig);

const runService = useService();
const vaultStorage = useVaultStorage();

useEffect(() => {
if (!isServiceEnabled) return;
Expand All @@ -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),
});
Expand Down Expand Up @@ -91,5 +93,13 @@ export const useFilesIntegrityService = () => {
await workerPromise;
};
});
}, [isServiceEnabled, db, encryptionController, profileId, runService, workspaces]);
}, [
isServiceEnabled,
db,
encryptionController,
profileId,
runService,
workspaces,
vaultStorage,
]);
};
14 changes: 7 additions & 7 deletions src/features/App/Profiles/hooks/useProfileContainers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 (
Expand All @@ -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}`);
Comment thread
vitonsky marked this conversation as resolved.

// Setup encryption
let encryptionController: EncryptionController;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -160,7 +160,7 @@ export const useProfileContainers = () => {

return newProfile;
},
[activeProfileChanged, profileOpened],
[activeProfileChanged, files, profileOpened],
);

return {
Expand Down
Loading