From d2cf694882f144e201990c2d19c3bb4967620e86 Mon Sep 17 00:00:00 2001 From: Maurice Date: Tue, 18 Aug 2026 20:18:49 +0200 Subject: [PATCH] feat(photos): read the EXIF of an uploaded photo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the third source of capture metadata (#1614). Provider photos are asked for, uploaded files carry their own answer — and until now nobody read it, so the most common case of all, a photo taken on the trip and uploaded straight into the journey, could never appear on the map. exifr rather than a heavier image library: pure JS, no native build, so no musl-pin dance in the Alpine image. Only four tags are picked, so a large file is not fully parsed. The resolved path is checked against the uploads root before it is opened. One thing worth knowing before someone files a bug about it: 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 more often than not, which is why the empty case is silent. --- package-lock.json | 7 ++++ server/package.json | 1 + server/src/nest/journey/journey.controller.ts | 4 ++ .../photo-capture-backfill.service.ts | 40 +++++++++++++++++++ 4 files changed, 52 insertions(+) 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; + } }