diff --git a/server/apps/microservices/src/processors/metadata-extraction.processor.ts b/server/apps/microservices/src/processors/metadata-extraction.processor.ts index 8395b9fdad..32b9199eb5 100644 --- a/server/apps/microservices/src/processors/metadata-extraction.processor.ts +++ b/server/apps/microservices/src/processors/metadata-extraction.processor.ts @@ -22,6 +22,7 @@ import fs from 'node:fs'; import sharp from 'sharp'; import { Repository } from 'typeorm/repository/Repository'; import { promisify } from 'util'; +import { parseLatitude, parseLongitude } from '../utils/coordinates'; const ffprobe = promisify(ffmpeg.ffprobe); @@ -174,8 +175,8 @@ export class MetadataExtractionProcessor { // files MAY return an array of numbers instead. const iso = getExifProperty('ISO'); newExif.iso = Array.isArray(iso) ? iso[0] : iso || null; - newExif.latitude = getExifProperty('GPSLatitude'); - newExif.longitude = getExifProperty('GPSLongitude'); + newExif.latitude = parseLatitude(getExifProperty('GPSLatitude')); + newExif.longitude = parseLongitude(getExifProperty('GPSLongitude')); newExif.livePhotoCID = getExifProperty('MediaGroupUUID'); if (newExif.livePhotoCID && !asset.livePhotoVideoId) { @@ -274,8 +275,8 @@ export class MetadataExtractionProcessor { const match = location.match(locationRegex); if (match?.length === 3) { - newExif.latitude = parseFloat(match[1]); - newExif.longitude = parseFloat(match[2]); + newExif.latitude = parseLatitude(match[1]); + newExif.longitude = parseLongitude(match[2]); } } else if (videoTags && videoTags['com.apple.quicktime.location.ISO6709']) { const location = videoTags['com.apple.quicktime.location.ISO6709'] as string; @@ -283,8 +284,8 @@ export class MetadataExtractionProcessor { const match = location.match(locationRegex); if (match?.length === 4) { - newExif.latitude = parseFloat(match[1]); - newExif.longitude = parseFloat(match[2]); + newExif.latitude = parseLatitude(match[1]); + newExif.longitude = parseLongitude(match[2]); } } diff --git a/server/apps/microservices/src/utils/coordinates.spec.ts b/server/apps/microservices/src/utils/coordinates.spec.ts new file mode 100644 index 0000000000..03b3ea624d --- /dev/null +++ b/server/apps/microservices/src/utils/coordinates.spec.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from '@jest/globals'; +import { parseLatitude, parseLongitude } from './coordinates'; + +describe('parsing latitude from string input', () => { + it('returns null for invalid inputs', () => { + expect(parseLatitude('')).toBeNull(); + expect(parseLatitude('NaN')).toBeNull(); + expect(parseLatitude('Infinity')).toBeNull(); + expect(parseLatitude('-Infinity')).toBeNull(); + expect(parseLatitude('90.001')).toBeNull(); + expect(parseLatitude('-90.000001')).toBeNull(); + expect(parseLatitude('1000')).toBeNull(); + expect(parseLatitude('-1000')).toBeNull(); + }); + + it('returns the numeric coordinate for valid inputs', () => { + expect(parseLatitude('90')).toBeCloseTo(90); + expect(parseLatitude('-90')).toBeCloseTo(-90); + expect(parseLatitude('89.999999')).toBeCloseTo(89.999999); + expect(parseLatitude('-89.9')).toBeCloseTo(-89.9); + expect(parseLatitude('0')).toBeCloseTo(0); + expect(parseLatitude('-0.0')).toBeCloseTo(-0.0); + }); +}); + +describe('parsing longitude from string input', () => { + it('returns null for invalid inputs', () => { + expect(parseLongitude('')).toBeNull(); + expect(parseLongitude('NaN')).toBeNull(); + expect(parseLongitude('Infinity')).toBeNull(); + expect(parseLongitude('-Infinity')).toBeNull(); + expect(parseLongitude('180.001')).toBeNull(); + expect(parseLongitude('-180.000001')).toBeNull(); + expect(parseLongitude('1000')).toBeNull(); + expect(parseLongitude('-1000')).toBeNull(); + }); + + it('returns the numeric coordinate for valid inputs', () => { + expect(parseLongitude('180')).toBeCloseTo(180); + expect(parseLongitude('-180')).toBeCloseTo(-180); + expect(parseLongitude('179.999999')).toBeCloseTo(179.999999); + expect(parseLongitude('-179.9')).toBeCloseTo(-179.9); + expect(parseLongitude('0')).toBeCloseTo(0); + expect(parseLongitude('-0.0')).toBeCloseTo(-0.0); + }); +}); diff --git a/server/apps/microservices/src/utils/coordinates.ts b/server/apps/microservices/src/utils/coordinates.ts new file mode 100644 index 0000000000..6ed81b9206 --- /dev/null +++ b/server/apps/microservices/src/utils/coordinates.ts @@ -0,0 +1,17 @@ +export function parseLatitude(input: string): number | null { + const latitude = Number.parseFloat(input); + + if (latitude < -90 || latitude > 90 || Number.isNaN(latitude)) { + return null; + } + return latitude; +} + +export function parseLongitude(input: string): number | null { + const longitude = Number.parseFloat(input); + + if (longitude < -180 || longitude > 180 || Number.isNaN(longitude)) { + return null; + } + return longitude; +} diff --git a/server/libs/infra/src/repositories/asset.repository.ts b/server/libs/infra/src/repositories/asset.repository.ts index ec836623c4..1c72cc1128 100644 --- a/server/libs/infra/src/repositories/asset.repository.ts +++ b/server/libs/infra/src/repositories/asset.repository.ts @@ -11,7 +11,7 @@ import { } from '@app/domain'; import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { FindOptionsRelations, FindOptionsWhere, In, IsNull, Not, Repository } from 'typeorm'; +import { FindOptionsRelations, FindOptionsWhere, In, IsNull, Not, Raw, Repository } from 'typeorm'; import { AssetEntity, AssetType } from '../entities'; import OptionalBetween from '../utils/optional-between.util'; import { paginate } from '../utils/pagination.util'; @@ -214,6 +214,9 @@ export class AssetRepository implements IAssetRepository { async getMapMarkers(ownerId: string, options: MapMarkerSearchOptions = {}): Promise { const { isFavorite, fileCreatedAfter, fileCreatedBefore } = options; + const coordinateFilter = Raw( + (column) => `${column} IS NOT NULL AND ${column} NOT IN ('NaN', 'Infinity', '-Infinity')`, + ); const assets = await this.repository.find({ select: { @@ -228,8 +231,8 @@ export class AssetRepository implements IAssetRepository { isVisible: true, isArchived: false, exifInfo: { - latitude: Not(IsNull()), - longitude: Not(IsNull()), + latitude: coordinateFilter, + longitude: coordinateFilter, }, isFavorite, fileCreatedAt: OptionalBetween(fileCreatedAfter, fileCreatedBefore),