diff --git a/package-lock.json b/package-lock.json index 065d9c419..01761e4ce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10920,6 +10920,12 @@ "resolved": "https://registry.npmjs.org/exif-parser/-/exif-parser-0.1.12.tgz", "integrity": "sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw==" }, + "node_modules/exifr": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/exifr/-/exifr-7.1.3.tgz", + "integrity": "sha512-g/aje2noHivrRSLbAUtBPWFbxKdKhgj/xr1vATDdUXPOFYJlQ62Ft0oy+72V6XLIpDJfHs6gXLbBLAolqOXYRw==", + "license": "MIT" + }, "node_modules/expand-template": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", @@ -21230,6 +21236,7 @@ "cors": "^2.8.5", "cron": "^4.4.0", "dotenv": "^16.4.1", + "exifr": "7.1.3", "express": "^4.18.3", "fast-xml-parser": "^5.5.10", "helmet": "^8.1.0", diff --git a/server/package.json b/server/package.json index 10a5f58d1..e9d81749b 100644 --- a/server/package.json +++ b/server/package.json @@ -45,6 +45,7 @@ "cors": "^2.8.5", "cron": "^4.4.0", "dotenv": "^16.4.1", + "exifr": "7.1.3", "express": "^4.18.3", "fast-xml-parser": "^5.5.10", "helmet": "^8.1.0", diff --git a/server/src/nest/journey/journey.controller.ts b/server/src/nest/journey/journey.controller.ts index 27f3486e9..155d5a826 100644 --- a/server/src/nest/journey/journey.controller.ts +++ b/server/src/nest/journey/journey.controller.ts @@ -212,6 +212,7 @@ export class JourneyController { if (!results.length) { throw new HttpException({ error: 'Not allowed' }, 403); } + this.backfillCapture(results, user.id); return { photos: results }; } @@ -293,6 +294,9 @@ export class JourneyController { if (!photos.length) { throw new HttpException({ error: 'Not allowed' }, 403); } + // An uploaded file carries its own EXIF; reading it is what puts the photo on + // the map later. Detached, like the provider branch. + this.backfillCapture(photos, user.id); return { photos }; } diff --git a/server/src/nest/memories/photo-capture-backfill.service.ts b/server/src/nest/memories/photo-capture-backfill.service.ts index 3d4184514..9399eb796 100644 --- a/server/src/nest/memories/photo-capture-backfill.service.ts +++ b/server/src/nest/memories/photo-capture-backfill.service.ts @@ -1,4 +1,7 @@ import { Injectable } from '@nestjs/common'; +import path from 'node:path'; +import exifr from 'exifr'; +import { UPLOADS_ROOT } from './uploads-root'; import { PhotoResolverService } from './photo-resolver.service'; import { TrekPhotosRepository } from '../photos/trek-photos.repository'; @@ -37,6 +40,14 @@ export class PhotoCaptureBackfillService { // per photo is the expensive part of an album import. if (!photo || (photo.taken_at && photo.lat != null && photo.lng != null)) continue; + // A local file has no provider to ask — the answer is in its own EXIF, and + // getPhotoInfo would only hand back what the DB row already says. + if (photo.provider === 'local') { + const meta = await this.readLocalExif(photo.file_path); + if (meta) this.photos.recordCaptureMetadata(id, meta); + continue; + } + const info = await this.resolver.getPhotoInfo(id, userId); if (!info.success) continue; @@ -50,4 +61,33 @@ export class PhotoCaptureBackfillService { } } } + + /** + * EXIF of an uploaded file. + * + * Note for anyone chasing "my phone photo has no location": the client converts + * HEIC before upload and that conversion drops GPS, so an iPhone photo arrives + * here already stripped. Nothing to read is the expected outcome far more often + * than not — hence no logging on the empty case. + */ + private async readLocalExif( + filePath?: string | null, + ): Promise<{ takenAt: string | null; lat: number | null; lng: number | null } | null> { + if (!filePath) return null; + // Never let a stored path climb out of the uploads tree. + const abs = path.resolve(UPLOADS_ROOT, filePath); + if (abs !== UPLOADS_ROOT && !abs.startsWith(UPLOADS_ROOT + path.sep)) return null; + + const parsed = await exifr.parse(abs, { pick: ['DateTimeOriginal', 'CreateDate', 'latitude', 'longitude'] }) + .catch(() => null) as + | { DateTimeOriginal?: Date; CreateDate?: Date; latitude?: number; longitude?: number } + | null; + if (!parsed) return null; + + const taken = parsed.DateTimeOriginal ?? parsed.CreateDate ?? null; + const takenAt = taken instanceof Date && !Number.isNaN(taken.getTime()) ? taken.toISOString() : null; + const lat = typeof parsed.latitude === 'number' ? parsed.latitude : null; + const lng = typeof parsed.longitude === 'number' ? parsed.longitude : null; + return takenAt || lat != null ? { takenAt, lat, lng } : null; + } }