Sfoglia il codice sorgente

fix(server): harden inserting process, self-healing timestamp info on bad timestamp (#682)

* fix(server): harden inserting process, self-healing timestamp info
Alex 2 anni fa
parent
commit
858ad43d3b

+ 19 - 1
server/apps/immich/src/api-v1/album/album.service.spec.ts

@@ -4,10 +4,13 @@ import { AuthUserDto } from '../../decorators/auth-user.decorator';
 import { BadRequestException, NotFoundException, ForbiddenException } from '@nestjs/common';
 import { AlbumEntity } from '@app/database/entities/album.entity';
 import { AlbumResponseDto } from './response-dto/album-response.dto';
+import { IAssetRepository } from '../asset/asset-repository';
 
 describe('Album service', () => {
   let sut: AlbumService;
   let albumRepositoryMock: jest.Mocked<IAlbumRepository>;
+  let assetRepositoryMock: jest.Mocked<IAssetRepository>;
+
   const authUser: AuthUserDto = Object.freeze({
     id: '1111',
     email: 'auth@test.com',
@@ -118,7 +121,22 @@ describe('Album service', () => {
       getListByAssetId: jest.fn(),
       getCountByUserId: jest.fn(),
     };
-    sut = new AlbumService(albumRepositoryMock);
+
+    assetRepositoryMock = {
+      create: jest.fn(),
+      getAllByUserId: jest.fn(),
+      getAllByDeviceId: jest.fn(),
+      getAssetCountByTimeBucket: jest.fn(),
+      getById: jest.fn(),
+      getDetectedObjectsByUserId: jest.fn(),
+      getLocationsByUserId: jest.fn(),
+      getSearchPropertiesByUserId: jest.fn(),
+      getAssetByTimeBucket: jest.fn(),
+      getAssetByChecksum: jest.fn(),
+      getAssetCountByUserId: jest.fn(),
+    };
+
+    sut = new AlbumService(albumRepositoryMock, assetRepositoryMock);
   });
 
   it('creates album', async () => {

+ 13 - 0
server/apps/immich/src/api-v1/asset/asset.service.ts

@@ -36,6 +36,7 @@ import {
 import { GetAssetCountByTimeBucketDto } from './dto/get-asset-count-by-time-bucket.dto';
 import { GetAssetByTimeBucketDto } from './dto/get-asset-by-time-bucket.dto';
 import { AssetCountByUserIdResponseDto } from './response-dto/asset-count-by-user-id-response.dto';
+import { timeUtils } from '@app/common/utils';
 
 const fileInfo = promisify(stat);
 
@@ -56,6 +57,18 @@ export class AssetService {
     mimeType: string,
     checksum: Buffer,
   ): Promise<AssetEntity> {
+    // Check valid time.
+    const createdAt = createAssetDto.createdAt;
+    const modifiedAt = createAssetDto.modifiedAt;
+
+    if (!timeUtils.checkValidTimestamp(createdAt)) {
+      createAssetDto.createdAt = await timeUtils.getTimestampFromExif(originalPath);
+    }
+
+    if (!timeUtils.checkValidTimestamp(modifiedAt)) {
+      createAssetDto.modifiedAt = await timeUtils.getTimestampFromExif(originalPath);
+    }
+
     const assetEntity = await this._assetRepository.create(
       createAssetDto,
       authUser.id,

+ 1 - 0
server/libs/common/src/index.ts

@@ -1,2 +1,3 @@
 export * from './config';
 export * from './constants';
+export * from './utils';

+ 1 - 0
server/libs/common/src/utils/index.ts

@@ -0,0 +1 @@
+export * from './time-utils';

+ 37 - 0
server/libs/common/src/utils/time-utils.spec.ts

@@ -0,0 +1,37 @@
+// create unit test for time utils
+
+import { timeUtils } from './time-utils';
+
+describe('Time Utilities', () => {
+  describe('checkValidTimestamp', () => {
+    it('check for year 0000', () => {
+      const result = timeUtils.checkValidTimestamp('0000-00-00T00:00:00.000Z');
+      expect(result).toBeFalsy();
+    });
+
+    it('check for 6-digits year with plus sign', () => {
+      const result = timeUtils.checkValidTimestamp('+12345-00-00T00:00:00.000Z');
+      expect(result).toBeFalsy();
+    });
+
+    it('check for 6-digits year with negative sign', () => {
+      const result = timeUtils.checkValidTimestamp('-12345-00-00T00:00:00.000Z');
+      expect(result).toBeFalsy();
+    });
+
+    it('check for current date', () => {
+      const result = timeUtils.checkValidTimestamp(new Date().toISOString());
+      expect(result).toBeTruthy();
+    });
+
+    it('check for year before 1583', () => {
+      const result = timeUtils.checkValidTimestamp('1582-12-31T23:59:59.999Z');
+      expect(result).toBeFalsy();
+    });
+
+    it('check for year after 9999', () => {
+      const result = timeUtils.checkValidTimestamp('10000-00-00T00:00:00.000Z');
+      expect(result).toBeFalsy();
+    });
+  });
+});

+ 48 - 0
server/libs/common/src/utils/time-utils.ts

@@ -0,0 +1,48 @@
+import exifr from 'exifr';
+
+function createTimeUtils() {
+  const checkValidTimestamp = (timestamp: string): boolean => {
+    const parsedTimestamp = Date.parse(timestamp);
+
+    if (isNaN(parsedTimestamp)) {
+      return false;
+    }
+
+    const date = new Date(parsedTimestamp);
+
+    if (date.getFullYear() < 1583 || date.getFullYear() > 9999) {
+      return false;
+    }
+
+    return date.getFullYear() > 0;
+  };
+
+  const getTimestampFromExif = async (originalPath: string): Promise<string> => {
+    try {
+      const exifData = await exifr.parse(originalPath, {
+        tiff: true,
+        ifd0: true as any,
+        ifd1: true,
+        exif: true,
+        gps: true,
+        interop: true,
+        xmp: true,
+        icc: true,
+        iptc: true,
+        jfif: true,
+        ihdr: true,
+      });
+
+      if (exifData && exifData['DateTimeOriginal']) {
+        return exifData['DateTimeOriginal'];
+      } else {
+        return new Date().toISOString();
+      }
+    } catch (error) {
+      return new Date().toISOString();
+    }
+  };
+  return { checkValidTimestamp, getTimestampFromExif };
+}
+
+export const timeUtils = createTimeUtils();

+ 1 - 0
server/package.json

@@ -129,6 +129,7 @@
       "^@app/database(|/.*)$": "<rootDir>/libs/database/src/$1",
       "@app/database/config/(.*)": "<rootDir>/libs/database/src/config/$1",
       "@app/database/config": "<rootDir>/libs/database/src/config",
+      "@app/common": "<rootDir>/libs/common/src",
       "^@app/job(|/.*)$": "<rootDir>/libs/job/src/$1"
     }
   }