diff --git a/server/src/repositories/view-repository.ts b/server/src/repositories/view-repository.ts index dd5b353c6..da6f889ef 100644 --- a/server/src/repositories/view-repository.ts +++ b/server/src/repositories/view-repository.ts @@ -1,3 +1,4 @@ +import { trim } from 'lodash-es'; import { InjectKysely } from 'nestjs-kysely'; import type { Kysely } from 'kysely'; import { DummyValue, GenerateSql } from 'src/decorators.js'; @@ -5,6 +6,8 @@ import { AssetVisibility } from 'src/enum.js'; import { DB } from 'src/schema/index.js'; import { asUuid, withExif } from 'src/utils/database.js'; +const escapeLikePattern = (value: string) => value.replaceAll(/[\\%_]/g, String.raw`\$&`); + export class ViewRepository { constructor(@InjectKysely() private db: Kysely) {} @@ -28,7 +31,8 @@ export class ViewRepository { @GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] }) async getAssetsByOriginalPath(userId: string, partialPath: string) { - const normalizedPath = partialPath.replaceAll(/\/$/g, ''); + const directory = trim(partialPath, '/'); + const prefix = escapeLikePattern(directory ? `/${directory}/` : '/'); return this.db .selectFrom('asset') @@ -40,8 +44,8 @@ export class ViewRepository { .where('fileCreatedAt', 'is not', null) .where('fileModifiedAt', 'is not', null) .where('localDateTime', 'is not', null) - .where('originalPath', 'like', `%${normalizedPath}/%`) - .where('originalPath', 'not like', `%${normalizedPath}/%/%`) + .where('originalPath', 'like', `${prefix}%`) + .where('originalPath', 'not like', `${prefix}%/%`) .orderBy( (eb) => eb.fn('regexp_replace', ['asset.originalPath', eb.val('.*/(.+)'), eb.val(String.raw`\1`)]), 'asc', diff --git a/server/test/medium.factory.ts b/server/test/medium.factory.ts index d66d40c49..881a0f6fb 100644 --- a/server/test/medium.factory.ts +++ b/server/test/medium.factory.ts @@ -68,6 +68,7 @@ import { TagRepository } from 'src/repositories/tag.repository.js'; import { TelemetryRepository } from 'src/repositories/telemetry.repository.js'; import { UserRepository } from 'src/repositories/user.repository.js'; import { VersionHistoryRepository } from 'src/repositories/version-history.repository.js'; +import { ViewRepository } from 'src/repositories/view-repository.js'; import { WebAuthnRepository } from 'src/repositories/webauthn.repository.js'; import { WebsocketRepository } from 'src/repositories/websocket.repository.js'; import { WorkflowRepository } from 'src/repositories/workflow.repository.js'; @@ -519,6 +520,7 @@ const newRealRepository = (key: T, db: Kysely case SystemMetadataRepository: case UserRepository: case VersionHistoryRepository: + case ViewRepository: case WorkflowRepository: { return new key(db) as InstanceType; } diff --git a/server/test/medium/specs/repositories/view.repository.spec.ts b/server/test/medium/specs/repositories/view.repository.spec.ts new file mode 100644 index 000000000..1e2c7f461 --- /dev/null +++ b/server/test/medium/specs/repositories/view.repository.spec.ts @@ -0,0 +1,46 @@ +import { Kysely } from 'kysely'; +import { LoggingRepository } from 'src/repositories/logging.repository.js'; +import { ViewRepository } from 'src/repositories/view-repository.js'; +import { DB } from 'src/schema/index.js'; +import { BaseService } from 'src/services/base.service.js'; +import { newMediumService } from 'test/medium.factory.js'; +import { getKyselyDB } from 'test/utils.js'; + +let defaultDatabase: Kysely; + +const setup = (db?: Kysely) => { + const { ctx } = newMediumService(BaseService, { + database: db || defaultDatabase, + real: [], + mock: [LoggingRepository], + }); + return { ctx, sut: ctx.get(ViewRepository) }; +}; + +beforeAll(async () => { + defaultDatabase = await getKyselyDB(); +}); + +describe(ViewRepository.name, () => { + describe('getAssetsByOriginalPath', () => { + it('should only return assets directly in the folder', async () => { + const { ctx, sut } = setup(); + const { user } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: user.id, originalPath: '/photos/a_b/1.jpg' }); + await ctx.newAsset({ ownerId: user.id, originalPath: '/photos/a_b/sub/2.jpg' }); + await ctx.newAsset({ ownerId: user.id, originalPath: '/photos/a-b/3.jpg' }); + await ctx.newAsset({ ownerId: user.id, originalPath: '/backup/photos/a_b/4.jpg' }); + const { asset: rootAsset } = await ctx.newAsset({ ownerId: user.id, originalPath: '/5.jpg' }); + + const getIds = async (path: string) => { + const assets = await sut.getAssetsByOriginalPath(user.id, path); + return assets.map(({ id }) => id); + }; + + for (const path of ['/photos/a_b', '/photos/a_b/', 'photos/a_b']) { + await expect(getIds(path)).resolves.toEqual([asset.id]); + } + await expect(getIds('/')).resolves.toEqual([rootAsset.id]); + }); + }); +});