From 5b00bc499ff71307cab7300086edfdace1721d51 Mon Sep 17 00:00:00 2001
From: Jonathan Jogenfors <jonathan@jogenfors.se>
Date: Mon, 7 Oct 2024 21:43:21 +0200
Subject: [PATCH] fix(server): Allow commas and braces in import paths (#13259)

fix commas and braces in paths
---
 e2e/src/api/specs/library.e2e-spec.ts         | 56 +++++++++++++++++++
 server/src/repositories/storage.repository.ts | 15 +++--
 2 files changed, 65 insertions(+), 6 deletions(-)

diff --git a/e2e/src/api/specs/library.e2e-spec.ts b/e2e/src/api/specs/library.e2e-spec.ts
index 20bd230159..9f5adc4e27 100644
--- a/e2e/src/api/specs/library.e2e-spec.ts
+++ b/e2e/src/api/specs/library.e2e-spec.ts
@@ -347,6 +347,62 @@ describe('/libraries', () => {
       expect(assets.items.find((asset) => asset.originalPath.includes('directoryB'))).toBeDefined();
     });
 
+    it('should scan multiple import paths with commas', async () => {
+      // https://github.com/immich-app/immich/issues/10699
+      const library = await utils.createLibrary(admin.accessToken, {
+        ownerId: admin.userId,
+        importPaths: [`${testAssetDirInternal}/temp/folder, a`, `${testAssetDirInternal}/temp/folder, b`],
+      });
+
+      utils.createImageFile(`${testAssetDir}/temp/folder, a/assetA.png`);
+      utils.createImageFile(`${testAssetDir}/temp/folder, b/assetB.png`);
+
+      const { status } = await request(app)
+        .post(`/libraries/${library.id}/scan`)
+        .set('Authorization', `Bearer ${admin.accessToken}`)
+        .send();
+      expect(status).toBe(204);
+
+      await utils.waitForQueueFinish(admin.accessToken, 'library');
+
+      const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id });
+
+      expect(assets.count).toBe(2);
+      expect(assets.items.find((asset) => asset.originalPath.includes('folder, a'))).toBeDefined();
+      expect(assets.items.find((asset) => asset.originalPath.includes('folder, b'))).toBeDefined();
+
+      utils.removeImageFile(`${testAssetDir}/temp/folder, a/assetA.png`);
+      utils.removeImageFile(`${testAssetDir}/temp/folder, b/assetB.png`);
+    });
+
+    it('should scan multiple import paths with braces', async () => {
+      // https://github.com/immich-app/immich/issues/10699
+      const library = await utils.createLibrary(admin.accessToken, {
+        ownerId: admin.userId,
+        importPaths: [`${testAssetDirInternal}/temp/folder{ a`, `${testAssetDirInternal}/temp/folder} b`],
+      });
+
+      utils.createImageFile(`${testAssetDir}/temp/folder{ a/assetA.png`);
+      utils.createImageFile(`${testAssetDir}/temp/folder} b/assetB.png`);
+
+      const { status } = await request(app)
+        .post(`/libraries/${library.id}/scan`)
+        .set('Authorization', `Bearer ${admin.accessToken}`)
+        .send();
+      expect(status).toBe(204);
+
+      await utils.waitForQueueFinish(admin.accessToken, 'library');
+
+      const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id });
+
+      expect(assets.count).toBe(2);
+      expect(assets.items.find((asset) => asset.originalPath.includes('folder{ a'))).toBeDefined();
+      expect(assets.items.find((asset) => asset.originalPath.includes('folder} b'))).toBeDefined();
+
+      utils.removeImageFile(`${testAssetDir}/temp/folder{ a/assetA.png`);
+      utils.removeImageFile(`${testAssetDir}/temp/folder} b/assetB.png`);
+    });
+
     it('should reimport a modified file', async () => {
       const library = await utils.createLibrary(admin.accessToken, {
         ownerId: admin.userId,
diff --git a/server/src/repositories/storage.repository.ts b/server/src/repositories/storage.repository.ts
index 6fd9bb8b04..b957449984 100644
--- a/server/src/repositories/storage.repository.ts
+++ b/server/src/repositories/storage.repository.ts
@@ -156,7 +156,9 @@ export class StorageRepository implements IStorageRepository {
       return Promise.resolve([]);
     }
 
-    return glob(this.asGlob(pathsToCrawl), {
+    const globbedPaths = pathsToCrawl.map((path) => this.asGlob(path));
+
+    return glob(globbedPaths, {
       absolute: true,
       caseSensitiveMatch: false,
       onlyFiles: true,
@@ -172,7 +174,9 @@ export class StorageRepository implements IStorageRepository {
       return emptyGenerator();
     }
 
-    const stream = globStream(this.asGlob(pathsToCrawl), {
+    const globbedPaths = pathsToCrawl.map((path) => this.asGlob(path));
+
+    const stream = globStream(globbedPaths, {
       absolute: true,
       caseSensitiveMatch: false,
       onlyFiles: true,
@@ -206,10 +210,9 @@ export class StorageRepository implements IStorageRepository {
     return () => watcher.close();
   }
 
-  private asGlob(pathsToCrawl: string[]): string {
-    const escapedPaths = pathsToCrawl.map((path) => escapePath(path));
-    const base = escapedPaths.length === 1 ? escapedPaths[0] : `{${escapedPaths.join(',')}}`;
+  private asGlob(pathToCrawl: string): string {
+    const escapedPath = escapePath(pathToCrawl);
     const extensions = `*{${mimeTypes.getSupportedFileExtensions().join(',')}}`;
-    return `${base}/**/${extensions}`;
+    return `${escapedPath}/**/${extensions}`;
   }
 }