diff --git a/server/src/nest/memories/memories.helpers.ts b/server/src/nest/memories/memories.helpers.ts index 1f7c91cac..bc5777514 100644 --- a/server/src/nest/memories/memories.helpers.ts +++ b/server/src/nest/memories/memories.helpers.ts @@ -189,6 +189,79 @@ export function dayStartEpochSeconds(day: string, tzOffsetMinutes = 0): number { } +// 'YYYY:MM:DD HH:MM:SS' as EXIF writes it. The dashed date is what some +// converters produce and what exifr's own reviver accepted, so it keeps working, +// and so does a field written with one digit ('2026:5:30 9:05:00'), which that +// reviver read as well. Whatever follows the time is checked on its own below. +// No end anchor on purpose: a greedy tail in front of '$' makes a long run of +// digits cost quadratic time, and the stamp is text any upload can set. +const EXIF_STAMP = /^(\d{4})[:-](\d{1,2})[:-](\d{1,2})[ T](\d{1,2}):(\d{1,2}):(\d{1,2})(?:\.\d+)?/; +// A stamp with nanoseconds and a zone is 36 characters. A longer one is no +// stamp, and is not worth reading. +const EXIF_STAMP_MAX = 64; +// '+02:00' is what the spec asks for; '+0200', 'Z', 'UTC' and 'GMT' say the same thing. +const EXIF_ZONE = /^(?:(Z|UTC|GMT)|([+-])(\d{2}):?(\d{2}))$/i; + +/** An EXIF zone ('+02:00', an OffsetTime* tag or a stamp's own) in minutes east of UTC, or null when it is not one. */ +function exifZoneMinutes(value: unknown): number | null { + if (typeof value !== 'string') return null; + const m = EXIF_ZONE.exec(value.trim()); + if (!m) return null; + if (m[1]) return 0; + const hours = Number(m[3]); + const minutes = Number(m[4]); + // Real zones run from -12:00 to +14:00; anything past that is a broken tag. + if (hours > 14 || minutes > 59) return null; + return (m[2] === '-' ? -1 : 1) * (hours * 60 + minutes); +} + + +/** + * The instant an EXIF capture stamp names, as an ISO string, or null. + * + * EXIF stores the photographer's wall clock ('2026:05:30 15:52:19') and, in a + * separate tag, the zone it was read in ('+02:00'). Handing the file to exifr + * with its defaults turns the stamp into a Date in the server's zone and never + * looks at the offset, so a photo taken in France and uploaded to a server in + * Chicago was stored seven hours late (#2512). This takes the raw stamp and + * applies the first well-formed offset in `offsets`. A stamp that carries its + * own zone ('... UTC', 'Z', '+02:00', the form exifr's reviver comment names) + * needs none of them: that zone is the most specific answer there is. + * + * Without one, the server's zone is all there is to go on. That is what every + * upload got before, and it stays right for a server in the photographer's zone. + * + * A stamp that is not a real moment (blank, the zeros a camera without a clock + * writes, 30 February, 25 o'clock) is null, not whatever Date rolls it over to. + */ +export function exifCaptureInstant(stamp: unknown, offsets: unknown[]): string | null { + if (typeof stamp !== 'string') return null; + const text = stamp.trim(); + if (text.length > EXIF_STAMP_MAX) return null; + const m = EXIF_STAMP.exec(text); + if (!m) return null; + const suffix = text.slice(m[0].length).trim(); + // After the time there is either nothing or a zone; anything else ('CEST', + // '+2', 'later') is not a stamp. A zone that is out of range leaves the + // decision to the tags. + if (suffix && !EXIF_ZONE.test(suffix)) return null; + const [year, month, day, hour, minute, second] = m.slice(1, 7).map(Number); + const wall = new Date(Date.UTC(year, month - 1, day, hour, minute, second)); + // Date.UTC quietly rolls an impossible part into the next one (and reads + // years below 100 as 19xx), so the parts have to survive the round trip. It + // is checked on the UTC reading so a local DST gap does not count as invalid. + if (wall.getUTCFullYear() !== year || wall.getUTCMonth() !== month - 1 || wall.getUTCDate() !== day + || wall.getUTCHours() !== hour || wall.getUTCMinutes() !== minute || wall.getUTCSeconds() !== second) { + return null; + } + const offset = exifZoneMinutes(suffix) ?? offsets.map(exifZoneMinutes).find(o => o !== null); + const instant = offset == null + ? new Date(year, month - 1, day, hour, minute, second) + : new Date(wall.getTime() - offset * 60000); + return instant.toISOString(); +} + + export type AssetInfo = { id: string; takenAt: string | null; diff --git a/server/src/nest/memories/photo-capture-backfill.service.ts b/server/src/nest/memories/photo-capture-backfill.service.ts index b7894d6fa..3eb649ed2 100644 --- a/server/src/nest/memories/photo-capture-backfill.service.ts +++ b/server/src/nest/memories/photo-capture-backfill.service.ts @@ -3,6 +3,60 @@ import exifr from 'exifr'; import { PhotoResolverService } from './photo-resolver.service'; import { StorageService } from '../storage/storage.service'; import { TrekPhotosRepository } from '../photos/trek-photos.repository'; +import { exifCaptureInstant } from './memories.helpers'; + +/** + * What readCapture asks exifr for. + * + * `latitude` and `longitude` are not tags. exifr works them out from the four + * GPS tags below, and only when it has read those. A pick of just the two + * derived names reads no GPS at all, which is how every upload used to lose + * its location (#2512). + */ +const CAPTURE_TAGS = [ + 'DateTimeOriginal', + 'CreateDate', + 'OffsetTimeOriginal', + 'OffsetTimeDigitized', + 'OffsetTime', + 'GPSLatitude', + 'GPSLatitudeRef', + 'GPSLongitude', + 'GPSLongitudeRef', +] as const; + +/** When and where an uploaded file says it was taken. */ +type LocalCapture = { takenAt: string | null; lat: number | null; lng: number | null }; + +type Exif = Partial>; + +function coordinate(value: unknown, limit: number): number | null { + return typeof value === 'number' && Number.isFinite(value) && Math.abs(value) <= limit ? value : null; +} + +/** + * The capture time and place in a file's EXIF, or null when it names neither. + * Throws for a file exifr cannot read at all. + */ +async function readCapture(abs: string): Promise { + // Raw values on purpose: see exifCaptureInstant for why the stamps are not + // left to exifr's reviver. + const parsed = (await exifr.parse(abs, { pick: [...CAPTURE_TAGS], reviveValues: false })) as Exif | undefined; + if (!parsed) return null; + + // Each stamp has an offset tag of its own. The others stand in for a missing + // one: a camera writes all three from the same clock. + const { OffsetTimeOriginal: original, OffsetTimeDigitized: digitized, OffsetTime: modified } = parsed; + const takenAt = exifCaptureInstant(parsed.DateTimeOriginal, [original, digitized, modified]) + ?? exifCaptureInstant(parsed.CreateDate, [digitized, original, modified]); + const lat = coordinate(parsed.latitude, 90); + const lng = coordinate(parsed.longitude, 180); + // A receiver without a fix can write zeros into the GPS tags instead of + // leaving them out. Taken at its word, 0,0 pins the photo to open sea in the + // Gulf of Guinea on the journey map, so it counts as no location. + const hasPair = lat != null && lng != null && !(lat === 0 && lng === 0); + return takenAt || hasPair ? { takenAt, lat: hasPair ? lat : null, lng: hasPair ? lng : null } : null; +} /** * Ask the provider when and where a photo was taken, and record it (#1614). @@ -70,9 +124,7 @@ export class PhotoCaptureBackfillService { * 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> { + private async readLocalExif(filePath?: string | null): Promise { if (!filePath) return null; // photos.file_path is uploads-relative 'journey/' by every writer; // anything else reads as a miss (same rule as photo-resolver). Central key @@ -81,25 +133,12 @@ export class PhotoCaptureBackfillService { if (!filePath.startsWith('journey/')) return null; const name = filePath.slice('journey/'.length); - type Exif = { DateTimeOriginal?: Date; CreateDate?: Date; latitude?: number; longitude?: number }; - let parsed: Exif | null; try { - parsed = await this.storage.withLocalFile('journey', name, async abs => - (await exifr.parse(abs, { - pick: ['DateTimeOriginal', 'CreateDate', 'latitude', 'longitude'], - })) as Exif | null, - ); + return await this.storage.withLocalFile('journey', name, readCapture); } catch { // A vanished object, an invalid key, not an image, a truncated upload, // a video — none of it is an error here. return 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; } } diff --git a/server/src/nest/storage/storage.service.ts b/server/src/nest/storage/storage.service.ts index 4c4f21e65..ae765cb55 100644 --- a/server/src/nest/storage/storage.service.ts +++ b/server/src/nest/storage/storage.service.ts @@ -253,9 +253,10 @@ export class StorageService { } const tmp = path.join(this.tempDir(), randomUUID()); - const { stream } = await driver.getStream(key); - await pipeline(stream, fs.createWriteStream(tmp)); try { + const { stream } = await driver.getStream(key); + // Inside the try, so a download cut short does not leave its part behind. + await pipeline(stream, fs.createWriteStream(tmp)); return await fn(tmp); } finally { await fs.promises.rm(tmp, { force: true }); diff --git a/server/tests/helpers/exif-jpeg.ts b/server/tests/helpers/exif-jpeg.ts new file mode 100644 index 000000000..35ed7877e --- /dev/null +++ b/server/tests/helpers/exif-jpeg.ts @@ -0,0 +1,138 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +/** + * A real JPEG carrying the capture tags a phone writes, built byte by byte so + * no actual person's photo has to live in the repo. + * + * The image data is tests/fixtures/small-image.jpg (a decodable 1x1 JPEG); its + * JFIF APP0 is swapped for an Exif APP1 the way a camera lays the file out: + * SOI, APP1 "Exif", then the rest. Every value is written as the raw tag string + * or rational, so a test can also hand in a malformed one. + */ +export interface ExifJpegTags { + /** 0x9003, e.g. '2026:05:30 15:52:19'. */ + DateTimeOriginal?: string; + /** 0x9004 (DateTimeDigitized). */ + CreateDate?: string; + /** 0x9010, the offset of the IFD0 DateTime. */ + OffsetTime?: string; + /** 0x9011, the offset of DateTimeOriginal. */ + OffsetTimeOriginal?: string; + /** 0x9012, the offset of DateTimeDigitized. */ + OffsetTimeDigitized?: string; + /** Decimal degrees, stored as the usual degree/minute/second rationals plus N/S and E/W. */ + gps?: { lat: number; lng: number }; +} + +const BASE_JPEG = path.join(__dirname, '../fixtures/small-image.jpg'); + +const BYTE = 1; +const ASCII = 2; +const LONG = 4; +const RATIONAL = 5; + +interface Entry { tag: number; type: number; count: number; data: Buffer } + +function ascii(tag: number, value: string): Entry { + const data = Buffer.from(`${value}\0`, 'latin1'); + return { tag, type: ASCII, count: data.length, data }; +} + +function long(tag: number, value: number): Entry { + const data = Buffer.alloc(4); + data.writeUInt32LE(value); + return { tag, type: LONG, count: 1, data }; +} + +function rationals(tag: number, pairs: Array<[number, number]>): Entry { + const data = Buffer.alloc(pairs.length * 8); + pairs.forEach(([num, den], i) => { + data.writeUInt32LE(num, i * 8); + data.writeUInt32LE(den, i * 8 + 4); + }); + return { tag, type: RATIONAL, count: pairs.length, data }; +} + +function dms(deg: number): Array<[number, number]> { + const abs = Math.abs(deg); + const d = Math.floor(abs); + const m = Math.floor((abs - d) * 60); + const s = Math.round(((abs - d) * 60 - m) * 60 * 10000); + return [[d, 1], [m, 1], [s, 10000]]; +} + +function ifdSize(entries: Entry[]): number { + const outOfLine = entries.reduce((sum, e) => sum + (e.data.length > 4 ? e.data.length + (e.data.length % 2) : 0), 0); + return 2 + entries.length * 12 + 4 + outOfLine; +} + +/** One IFD at TIFF offset `at`, entries sorted by tag as the spec wants. */ +function writeIfd(entries: Entry[], at: number): Buffer { + const sorted = [...entries].sort((a, b) => a.tag - b.tag); + const head = Buffer.alloc(2 + sorted.length * 12 + 4); + const tail: Buffer[] = []; + let dataAt = at + head.length; + head.writeUInt16LE(sorted.length, 0); + sorted.forEach((e, i) => { + const o = 2 + i * 12; + head.writeUInt16LE(e.tag, o); + head.writeUInt16LE(e.type, o + 2); + head.writeUInt32LE(e.count, o + 4); + if (e.data.length <= 4) { + e.data.copy(head, o + 8); + } else { + head.writeUInt32LE(dataAt, o + 8); + const padded = e.data.length % 2 ? Buffer.concat([e.data, Buffer.alloc(1)]) : e.data; + tail.push(padded); + dataAt += padded.length; + } + }); + head.writeUInt32LE(0, head.length - 4); + return Buffer.concat([head, ...tail]); +} + +export function jpegWithExif(tags: ExifJpegTags): Buffer { + const exifEntries: Entry[] = []; + if (tags.DateTimeOriginal !== undefined) exifEntries.push(ascii(0x9003, tags.DateTimeOriginal)); + if (tags.CreateDate !== undefined) exifEntries.push(ascii(0x9004, tags.CreateDate)); + if (tags.OffsetTime !== undefined) exifEntries.push(ascii(0x9010, tags.OffsetTime)); + if (tags.OffsetTimeOriginal !== undefined) exifEntries.push(ascii(0x9011, tags.OffsetTimeOriginal)); + if (tags.OffsetTimeDigitized !== undefined) exifEntries.push(ascii(0x9012, tags.OffsetTimeDigitized)); + + const gpsEntries: Entry[] = []; + if (tags.gps) { + gpsEntries.push({ tag: 0x0000, type: BYTE, count: 4, data: Buffer.from([2, 2, 0, 0]) }); + gpsEntries.push(ascii(0x0001, tags.gps.lat < 0 ? 'S' : 'N')); + gpsEntries.push(rationals(0x0002, dms(tags.gps.lat))); + gpsEntries.push(ascii(0x0003, tags.gps.lng < 0 ? 'W' : 'E')); + gpsEntries.push(rationals(0x0004, dms(tags.gps.lng))); + } + + // IFD0 holds only the two pointers, so its size is known before the offsets are. + const ifd0Count = (exifEntries.length ? 1 : 0) + (gpsEntries.length ? 1 : 0); + const ifd0At = 8; + const exifAt = ifd0At + 2 + ifd0Count * 12 + 4; + const gpsAt = exifAt + (exifEntries.length ? ifdSize(exifEntries) : 0); + const ifd0Entries: Entry[] = []; + if (exifEntries.length) ifd0Entries.push(long(0x8769, exifAt)); + if (gpsEntries.length) ifd0Entries.push(long(0x8825, gpsAt)); + + const tiffHeader = Buffer.from([0x49, 0x49, 0x2a, 0x00, 0x08, 0x00, 0x00, 0x00]); + const tiff = Buffer.concat([ + tiffHeader, + writeIfd(ifd0Entries, ifd0At), + exifEntries.length ? writeIfd(exifEntries, exifAt) : Buffer.alloc(0), + gpsEntries.length ? writeIfd(gpsEntries, gpsAt) : Buffer.alloc(0), + ]); + const payload = Buffer.concat([Buffer.from('Exif\0\0', 'latin1'), tiff]); + const app1 = Buffer.alloc(4); + app1.writeUInt16BE(0xffe1, 0); + app1.writeUInt16BE(payload.length + 2, 2); + + const base = fs.readFileSync(BASE_JPEG); + // SOI, then skip the JFIF APP0 (marker + its big-endian length). + const app0Length = base.readUInt16BE(4); + const rest = base.subarray(4 + app0Length); + return Buffer.concat([base.subarray(0, 2), app1, payload, rest]); +} diff --git a/server/tests/integration/journey.test.ts b/server/tests/integration/journey.test.ts index 57790c6a9..a6a098e30 100644 --- a/server/tests/integration/journey.test.ts +++ b/server/tests/integration/journey.test.ts @@ -2,7 +2,7 @@ * Journey API integration tests. * Covers JOURNEY-INT-001 through JOURNEY-INT-020. */ -import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; +import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from 'vitest'; import request from 'supertest'; import type { Application } from 'express'; import type { INestApplication } from '@nestjs/common'; @@ -72,6 +72,7 @@ import { addJourneyContributor, } from '../helpers/factories'; import { authCookie } from '../helpers/auth'; +import { jpegWithExif } from '../helpers/exif-jpeg'; import { invalidatePermissionsCache } from '../../src/nest/permissions/permissions-cache'; let nestApp: INestApplication; @@ -1223,4 +1224,90 @@ describe('Journey upload parity', () => { const diskName = res.body.cover_image.replace(/^journey\//, ''); expect(fsMod.existsSync(pathMod.join(journeyDir, diskName))).toBe(true); }); + + describe('capture metadata read from the uploaded file (#2512)', () => { + // A server whose zone is neither UTC nor the photographer's: the setup the + // report came from, and the only one where a stamp read in the wrong zone shows. + let prevTz: string | undefined; + beforeEach(() => { + prevTz = process.env.TZ; + process.env.TZ = 'America/Chicago'; + }); + afterEach(() => { + if (prevTz === undefined) delete process.env.TZ; + else process.env.TZ = prevTz; + }); + + const phonePhoto = () => jpegWithExif({ + DateTimeOriginal: '2026:05:30 15:52:19', + CreateDate: '2026:05:30 15:52:19', + OffsetTime: '+02:00', + OffsetTimeOriginal: '+02:00', + OffsetTimeDigitized: '+02:00', + gps: { lat: 49.274523, lng: -0.703421 }, + }); + + type CaptureRow = { taken_at: string | null; lat: number | null; lng: number | null }; + // The backfill runs detached after the response, so wait for it to land. + async function captureOf(filePath: string): Promise { + let row: CaptureRow | undefined; + await vi.waitFor(() => { + row = testDb.prepare('SELECT taken_at, lat, lng FROM trek_photos WHERE file_path = ?').get(filePath) as CaptureRow; + expect(row?.taken_at).toBeTruthy(); + }); + return row!; + } + + it('JOURNEY-P10: an entry photo stores the instant from DateTimeOriginal plus its offset, and its GPS', async () => { + const { user } = createUser(testDb); + const journey = createJourney(testDb, user.id); + const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-05-30' }); + + const res = await request(app) + .post(`/api/journeys/entries/${entry.id}/photos`) + .set('Cookie', authCookie(user.id)) + .attach('photos', phonePhoto(), { filename: 'PXL_20260530_135219.jpg', contentType: 'image/jpeg' }); + expect(res.status).toBe(201); + + const row = await captureOf(res.body.photos[0].file_path); + // Not 2026-05-30T20:52:19.000Z, which is the stamp read as Chicago time. + expect(row.taken_at).toBe('2026-05-30T13:52:19.000Z'); + expect(row.lat).toBeCloseTo(49.274523, 5); + expect(row.lng).toBeCloseTo(-0.703421, 5); + }); + + it('JOURNEY-P11: a gallery photo gets the same', async () => { + const { user } = createUser(testDb); + const journey = createJourney(testDb, user.id); + + const res = await request(app) + .post(`/api/journeys/${journey.id}/gallery/photos`) + .set('Cookie', authCookie(user.id)) + .attach('photos', phonePhoto(), { filename: 'PXL_20260530_135219.jpg', contentType: 'image/jpeg' }); + expect(res.status).toBe(201); + + const row = await captureOf(res.body.photos[0].file_path); + expect(row.taken_at).toBe('2026-05-30T13:52:19.000Z'); + expect(row.lat).toBeCloseTo(49.274523, 5); + expect(row.lng).toBeCloseTo(-0.703421, 5); + }); + + it('JOURNEY-P12: zeros in the GPS block, as a receiver without a fix writes them, put no pin at 0,0', async () => { + const { user } = createUser(testDb); + const journey = createJourney(testDb, user.id); + + const res = await request(app) + .post(`/api/journeys/${journey.id}/gallery/photos`) + .set('Cookie', authCookie(user.id)) + .attach('photos', jpegWithExif({ + DateTimeOriginal: '2026:05:30 15:52:19', + OffsetTimeOriginal: '+02:00', + gps: { lat: 0, lng: 0 }, + }), { filename: 'nofix.jpg', contentType: 'image/jpeg' }); + expect(res.status).toBe(201); + + const row = await captureOf(res.body.photos[0].file_path); + expect(row).toEqual({ taken_at: '2026-05-30T13:52:19.000Z', lat: null, lng: null }); + }); + }); }); diff --git a/server/tests/unit/nest/memories.helpers.test.ts b/server/tests/unit/nest/memories.helpers.test.ts index b5aa95f47..5be3755df 100644 --- a/server/tests/unit/nest/memories.helpers.test.ts +++ b/server/tests/unit/nest/memories.helpers.test.ts @@ -7,10 +7,11 @@ * headings in the picker mean something, so it is pinned here rather than * inside either provider suite. */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { dayStartEpochSeconds, describeFetchFailure, + exifCaptureInstant, isWithinLocalDayRange, shiftCalendarDay, sortAssetsByTakenAtDesc, @@ -181,3 +182,149 @@ describe('describeFetchFailure', () => { expect(describeFetchFailure(new TypeError('fetch failed', { cause }))).toBe('fetch failed (self-signed certificate)'); }); }); + +describe('exifCaptureInstant (#2512)', () => { + // Pinned to a zone that is neither UTC nor the photographer's, because that + // is the only setup where reading the stamp in the server's zone shows. + let prevTz: string | undefined; + beforeEach(() => { + prevTz = process.env.TZ; + process.env.TZ = 'America/Chicago'; + }); + afterEach(() => { + if (prevTz === undefined) delete process.env.TZ; + else process.env.TZ = prevTz; + }); + + it('MEM-EXIF-001: applies the offset, so the stamp becomes the instant it names', () => { + expect(exifCaptureInstant('2026:05:30 15:52:19', ['+02:00'])).toBe('2026-05-30T13:52:19.000Z'); + expect(exifCaptureInstant('2026:05:30 15:52:19', ['-07:00'])).toBe('2026-05-30T22:52:19.000Z'); + expect(exifCaptureInstant('2026:05:30 15:52:19', ['+05:45'])).toBe('2026-05-30T10:07:19.000Z'); + expect(exifCaptureInstant('2026:05:30 15:52:19', ['+00:00'])).toBe('2026-05-30T15:52:19.000Z'); + }); + + it('MEM-EXIF-002: an evening photo east of UTC stays on its own day', () => { + expect(exifCaptureInstant('2026:05:30 22:30:00', ['+02:00'])).toBe('2026-05-30T20:30:00.000Z'); + }); + + it('MEM-EXIF-003: takes the first offset that is well formed', () => { + expect(exifCaptureInstant('2026:05:30 15:52:19', [undefined, ' : ', '+2:00', '+09:00', '+02:00'])) + .toBe('2026-05-30T06:52:19.000Z'); + }); + + it('MEM-EXIF-004: accepts the widest real zones and refuses what is past them', () => { + expect(exifCaptureInstant('2026:05:30 12:00:00', ['+14:00'])).toBe('2026-05-29T22:00:00.000Z'); + expect(exifCaptureInstant('2026:05:30 12:00:00', ['-12:00'])).toBe('2026-05-31T00:00:00.000Z'); + // Out of range falls through to the server zone, like no offset at all. + const local = exifCaptureInstant('2026:05:30 12:00:00', []); + expect(exifCaptureInstant('2026:05:30 12:00:00', ['+15:00'])).toBe(local); + expect(exifCaptureInstant('2026:05:30 12:00:00', ['+02:60'])).toBe(local); + }); + + it('MEM-EXIF-005: without an offset the stamp is read in the server zone, as before', () => { + // 15:52 CDT is 20:52 UTC. + expect(exifCaptureInstant('2026:05:30 15:52:19', [])).toBe('2026-05-30T20:52:19.000Z'); + expect(exifCaptureInstant('2026:05:30 15:52:19', [null, 42, '0200', '+02:00:00', '+2', 'CEST'])).toBe('2026-05-30T20:52:19.000Z'); + }); + + it('MEM-EXIF-006: a wall clock inside a DST gap is still a date, not garbage', () => { + // 02:30 on 8 March 2026 does not exist in Chicago; the old reader moved it an + // hour on and so does this one. What matters is that it is not dropped. + expect(exifCaptureInstant('2026:03:08 02:30:00', [])).toBe(new Date(2026, 2, 8, 2, 30, 0).toISOString()); + expect(exifCaptureInstant('2026:03:08 02:30:00', ['+01:00'])).toBe('2026-03-08T01:30:00.000Z'); + }); + + it('MEM-EXIF-007: tolerates dashes, a T and fractional seconds', () => { + expect(exifCaptureInstant('2026-05-30 15:52:19', ['+02:00'])).toBe('2026-05-30T13:52:19.000Z'); + expect(exifCaptureInstant('2026:05:30T15:52:19', ['+02:00'])).toBe('2026-05-30T13:52:19.000Z'); + expect(exifCaptureInstant('2026:05:30 15:52:19.123', ['+02:00'])).toBe('2026-05-30T13:52:19.000Z'); + expect(exifCaptureInstant(' 2026:05:30 15:52:19 ', ['+02:00'])).toBe('2026-05-30T13:52:19.000Z'); + expect(exifCaptureInstant('2028:02:29 08:00:00', ['+00:00'])).toBe('2028-02-29T08:00:00.000Z'); + }); + + it('MEM-EXIF-008: a stamp that is not a real moment is null, not a rolled over date', () => { + for (const stamp of [ + '0000:00:00 00:00:00', // camera without a clock + ' : : : : ', // blanked as the spec allows + '2026:02:30 10:00:00', + '2026:02:29 10:00:00', + '2026:13:01 10:00:00', + '2026:05:30 24:00:00', + '2026:05:30 15:60:00', + '2026:05:30 15:52:60', + '0099:05:30 15:52:19', // Date would read this as 1999 + '2026:05:30', + '2026:05:30 15:52', + 'yesterday', + '', + ]) { + expect(exifCaptureInstant(stamp, ['+02:00']), stamp).toBeNull(); + expect(exifCaptureInstant(stamp, []), stamp).toBeNull(); + } + }); + + it('MEM-EXIF-009: anything but a string is null', () => { + expect(exifCaptureInstant(undefined, ['+02:00'])).toBeNull(); + expect(exifCaptureInstant(null, [])).toBeNull(); + expect(exifCaptureInstant(new Date('2026-05-30T13:52:19Z'), [])).toBeNull(); + expect(exifCaptureInstant(1780149139, [])).toBeNull(); + }); + + it('MEM-EXIF-010: an offset tag written without the colon, or as Z or UTC, says the same thing', () => { + expect(exifCaptureInstant('2026:05:30 15:52:19', ['+0200'])).toBe('2026-05-30T13:52:19.000Z'); + expect(exifCaptureInstant('2026:05:30 15:52:19', ['-0330'])).toBe('2026-05-30T19:22:19.000Z'); + expect(exifCaptureInstant('2026:05:30 15:52:19', ['Z'])).toBe('2026-05-30T15:52:19.000Z'); + expect(exifCaptureInstant('2026:05:30 15:52:19', ['utc'])).toBe('2026-05-30T15:52:19.000Z'); + }); + + it('MEM-EXIF-011: a zone written onto the stamp itself is honoured, and wins over the offset tags', () => { + // exifr's reviver names this form, and read it as Chicago time: 20:52:19Z. + expect(exifCaptureInstant('2026:05:30 15:52:19 UTC', [])).toBe('2026-05-30T15:52:19.000Z'); + expect(exifCaptureInstant('2009-09-23 17:40:52 UTC', [])).toBe('2009-09-23T17:40:52.000Z'); + // The reviver turned these into local midnight of the day. + expect(exifCaptureInstant('2026:05:30 15:52:19Z', [])).toBe('2026-05-30T15:52:19.000Z'); + expect(exifCaptureInstant('2026:05:30 15:52:19+02:00', [])).toBe('2026-05-30T13:52:19.000Z'); + expect(exifCaptureInstant('2026-05-30T15:52:19.250-07:00', [])).toBe('2026-05-30T22:52:19.000Z'); + expect(exifCaptureInstant('2026:05:30 15:52:19 +0200', [])).toBe('2026-05-30T13:52:19.000Z'); + // The stamp's own zone is the more specific answer. + expect(exifCaptureInstant('2026:05:30 15:52:19+02:00', ['+09:00'])).toBe('2026-05-30T13:52:19.000Z'); + expect(exifCaptureInstant('2026:05:30 15:52:19 UTC', ['+02:00'])).toBe('2026-05-30T15:52:19.000Z'); + }); + + it('MEM-EXIF-012: a zone on the stamp that is out of range leaves the tags to decide', () => { + expect(exifCaptureInstant('2026:05:30 15:52:19+15:00', ['+02:00'])).toBe('2026-05-30T13:52:19.000Z'); + expect(exifCaptureInstant('2026:05:30 15:52:19+15:00', [])).toBe('2026-05-30T20:52:19.000Z'); + }); + + it('MEM-EXIF-014: a field written with one digit, and a GMT zone, still read as the old reviver read them', () => { + expect(exifCaptureInstant('2026:5:30 15:52:19', ['+02:00'])).toBe('2026-05-30T13:52:19.000Z'); + expect(exifCaptureInstant('2026:05:30 5:52:19', ['+02:00'])).toBe('2026-05-30T03:52:19.000Z'); + expect(exifCaptureInstant('2026:05:30 15:52:19 GMT', ['+02:00'])).toBe('2026-05-30T15:52:19.000Z'); + expect(exifCaptureInstant('2026:05:30 15:52:19', ['GMT'])).toBe('2026-05-30T15:52:19.000Z'); + // One digit is not a licence for three. + expect(exifCaptureInstant('2026:005:30 15:52:19', ['+02:00'])).toBeNull(); + expect(exifCaptureInstant('2026:05:30 15:52:190', ['+02:00'])).toBeNull(); + }); + + it('MEM-EXIF-013: anything else after the time is still not a stamp', () => { + for (const stamp of ['2026:05:30 15:52:19 CEST', '2026:05:30 15:52:19 +2', '2026:05:30 15:52:19 UTC+2', '2026:05:30 15:52:19 later']) { + expect(exifCaptureInstant(stamp, ['+02:00']), stamp).toBeNull(); + } + }); + + it('MEM-EXIF-015: a huge stamp from a hostile upload is turned down at once, not backtracked over', () => { + // A run of digits after the seconds, then a line break: a greedy tail in + // front of an end anchor tries every split of it. 200k of them took 20 s. + const hostile = '2026:05:30 15:52:19.' + '1'.repeat(200_000) + '\nx'; + const started = performance.now(); + const read = exifCaptureInstant(hostile, ['+02:00']); + const withHostileOffset = exifCaptureInstant('2026:05:30 15:52:19', [hostile]); + expect(performance.now() - started).toBeLessThan(250); + expect(read).toBeNull(); + expect(withHostileOffset).toBe('2026-05-30T20:52:19.000Z'); + // Up to the limit a stamp still reads, and a line break is no zone. + expect(exifCaptureInstant('2026:05:30 15:52:19.' + '1'.repeat(38) + '+02:00', [])).toBe('2026-05-30T13:52:19.000Z'); + expect(exifCaptureInstant('2026:05:30 15:52:19.' + '1'.repeat(39) + '+02:00', [])).toBeNull(); + expect(exifCaptureInstant('2026:05:30 15:52:19\nx', ['+02:00'])).toBeNull(); + }); +}); diff --git a/server/tests/unit/nest/photo-capture-backfill.exif.test.ts b/server/tests/unit/nest/photo-capture-backfill.exif.test.ts new file mode 100644 index 000000000..7049cafc1 --- /dev/null +++ b/server/tests/unit/nest/photo-capture-backfill.exif.test.ts @@ -0,0 +1,177 @@ +/** + * PhotoCaptureBackfillService against real files (#2512). + * + * The sibling suite mocks exifr, which is how a pick that could never return GPS + * and a date read in the wrong zone both passed it: the mock handed back exactly + * what the code hoped exifr would. Here exifr reads real JPEG bytes carrying the + * tags a phone writes, through a real StorageService, with the process in a zone + * that is neither UTC nor the photographer's. + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { Readable } from 'node:stream'; +import { PhotoCaptureBackfillService } from '../../../src/nest/memories/photo-capture-backfill.service'; +import type { PhotoResolverService } from '../../../src/nest/memories/photo-resolver.service'; +import type { TrekPhotosRepository } from '../../../src/nest/photos/trek-photos.repository'; +import { jpegWithExif, type ExifJpegTags } from '../../helpers/exif-jpeg'; +import { makeStorageFixture, type StorageFixture } from '../../helpers/storage-fixture'; + +// Normandy, west of Greenwich, so a dropped sign would show. +const NORMANDY = { lat: 49.274523, lng: -0.703421 }; + +let fx: StorageFixture; +let prevTz: string | undefined; +let seq = 0; + +beforeAll(() => { fx = makeStorageFixture('journey/'); }); +afterAll(() => fx.cleanup()); +beforeEach(() => { + prevTz = process.env.TZ; + process.env.TZ = 'America/Chicago'; +}); +afterEach(() => { + if (prevTz === undefined) delete process.env.TZ; + else process.env.TZ = prevTz; +}); + +/** Stores `bytes` as a local journey upload, runs the backfill on it, returns what was recorded. */ +async function backfill(bytes: Buffer): Promise { + const name = `upload-${++seq}.jpg`; + await fx.storage.put('journey', name, Readable.from(bytes), { contentType: 'image/jpeg' }); + const recordCaptureMetadata = vi.fn(); + const photos = { + resolve: (id: number) => ({ id, provider: 'local', file_path: `journey/${name}`, taken_at: null, lat: null, lng: null }), + recordCaptureMetadata, + } as unknown as TrekPhotosRepository; + const svc = new PhotoCaptureBackfillService({} as PhotoResolverService, photos, fx.storage); + await svc.run([7], 1); + return recordCaptureMetadata.mock.calls; +} + +const photo = (tags: ExifJpegTags) => backfill(jpegWithExif(tags)); + +describe('PhotoCaptureBackfillService reading real EXIF', () => { + it('EXIF-001: the reported phone photo gets its true instant and its GPS', async () => { + const calls = await photo({ + DateTimeOriginal: '2026:05:30 15:52:19', + CreateDate: '2026:05:30 15:52:19', + OffsetTime: '+02:00', + OffsetTimeOriginal: '+02:00', + OffsetTimeDigitized: '+02:00', + gps: NORMANDY, + }); + + expect(calls).toHaveLength(1); + const [id, meta] = calls[0] as [number, { takenAt: string; lat: number; lng: number }]; + expect(id).toBe(7); + // Not 20:52:19Z, which is 15:52 read as Chicago time. + expect(meta.takenAt).toBe('2026-05-30T13:52:19.000Z'); + expect(meta.lat).toBeCloseTo(NORMANDY.lat, 5); + expect(meta.lng).toBeCloseTo(NORMANDY.lng, 5); + }); + + it('EXIF-002: the offset is found in OffsetTimeDigitized when it is the only one', async () => { + const calls = await photo({ DateTimeOriginal: '2026:05:30 15:52:19', OffsetTimeDigitized: '+02:00' }); + expect(calls).toEqual([[7, { takenAt: '2026-05-30T13:52:19.000Z', lat: null, lng: null }]]); + }); + + it('EXIF-003: the offset is found in OffsetTime when it is the only one', async () => { + const calls = await photo({ DateTimeOriginal: '2026:05:30 15:52:19', OffsetTime: '+09:00' }); + expect(calls).toEqual([[7, { takenAt: '2026-05-30T06:52:19.000Z', lat: null, lng: null }]]); + }); + + it('EXIF-004: DateTimeOriginal goes with its own offset when the tags disagree', async () => { + const calls = await photo({ + DateTimeOriginal: '2026:05:30 15:52:19', + OffsetTimeOriginal: '+02:00', + OffsetTimeDigitized: '+05:00', + OffsetTime: '-03:00', + }); + expect(calls).toEqual([[7, { takenAt: '2026-05-30T13:52:19.000Z', lat: null, lng: null }]]); + }); + + it('EXIF-005: CreateDate stands in for a missing DateTimeOriginal, with its own offset first', async () => { + const calls = await photo({ + CreateDate: '2026:05:30 21:10:00', + OffsetTimeDigitized: '+01:00', + OffsetTimeOriginal: '+02:00', + }); + expect(calls).toEqual([[7, { takenAt: '2026-05-30T20:10:00.000Z', lat: null, lng: null }]]); + }); + + it('EXIF-006: CreateDate also stands in when DateTimeOriginal is garbage', async () => { + const calls = await photo({ + DateTimeOriginal: '0000:00:00 00:00:00', + CreateDate: '2026:05:30 15:52:19', + OffsetTimeOriginal: '+02:00', + }); + expect(calls).toEqual([[7, { takenAt: '2026-05-30T13:52:19.000Z', lat: null, lng: null }]]); + }); + + it('EXIF-007: with no offset the stamp is read in the server zone, exactly as before', async () => { + const calls = await photo({ DateTimeOriginal: '2026:05:30 15:52:19' }); + // 15:52 CDT. + expect(calls).toEqual([[7, { takenAt: '2026-05-30T20:52:19.000Z', lat: null, lng: null }]]); + }); + + it('EXIF-008: a malformed offset is ignored like a missing one', async () => { + for (const bad of ['+2:00', ' : ', '+99:00', 'CEST']) { + const calls = await photo({ DateTimeOriginal: '2026:05:30 15:52:19', OffsetTimeOriginal: bad }); + expect(calls, bad).toEqual([[7, { takenAt: '2026-05-30T20:52:19.000Z', lat: null, lng: null }]]); + } + }); + + it('EXIF-009: an impossible or garbage date is not stored, and does not cost the location', async () => { + for (const bad of ['0000:00:00 00:00:00', '2026:02:30 25:61:00', ' : : : : ', 'not a date']) { + const calls = await photo({ DateTimeOriginal: bad, OffsetTimeOriginal: '+02:00', gps: NORMANDY }); + expect(calls, bad).toHaveLength(1); + const [, meta] = calls[0] as [number, { takenAt: string | null; lat: number; lng: number }]; + expect(meta.takenAt, bad).toBeNull(); + expect(meta.lat, bad).toBeCloseTo(NORMANDY.lat, 5); + expect(meta.lng, bad).toBeCloseTo(NORMANDY.lng, 5); + } + }); + + it('EXIF-010: an impossible date with nothing else to go on records nothing', async () => { + expect(await photo({ DateTimeOriginal: '2026:02:30 10:00:00' })).toEqual([]); + }); + + it('EXIF-011: GPS without any date still puts the photo on the map', async () => { + const calls = await photo({ gps: { lat: -33.856784, lng: 151.215297 } }); + expect(calls).toHaveLength(1); + const [, meta] = calls[0] as [number, { takenAt: string | null; lat: number; lng: number }]; + expect(meta.takenAt).toBeNull(); + expect(meta.lat).toBeCloseTo(-33.856784, 5); + expect(meta.lng).toBeCloseTo(151.215297, 5); + }); + + it('EXIF-012: a JPEG without EXIF and a file that is not an image both record nothing', async () => { + const plain = fs.readFileSync(path.join(__dirname, '../../fixtures/small-image.jpg')); + expect(await backfill(plain)).toEqual([]); + expect(await backfill(Buffer.from('definitely not a jpeg'))).toEqual([]); + }); + + it('EXIF-013: a GPS block of zeros, as a receiver without a fix writes it, is no location', async () => { + const zeros = { lat: 0, lng: 0 }; + expect(await photo({ DateTimeOriginal: '2026:05:30 15:52:19', OffsetTimeOriginal: '+02:00', gps: zeros })) + .toEqual([[7, { takenAt: '2026-05-30T13:52:19.000Z', lat: null, lng: null }]]); + // Nothing else to go on: nothing is recorded, rather than a pin in the Gulf of Guinea. + expect(await photo({ gps: zeros })).toEqual([]); + }); + + it('EXIF-014: a real place on the equator or the prime meridian keeps its location', async () => { + for (const gps of [{ lat: 0, lng: 32.58 }, { lat: 51.4779, lng: 0 }]) { + const calls = await photo({ gps }); + expect(calls, JSON.stringify(gps)).toHaveLength(1); + const [, meta] = calls[0] as [number, { lat: number; lng: number }]; + expect(meta.lat).toBeCloseTo(gps.lat, 5); + expect(meta.lng).toBeCloseTo(gps.lng, 5); + } + }); + + it('EXIF-015: a stamp that carries its own zone is read in that zone', async () => { + const calls = await photo({ DateTimeOriginal: '2026:05:30 15:52:19 UTC', OffsetTimeOriginal: '+02:00' }); + expect(calls).toEqual([[7, { takenAt: '2026-05-30T15:52:19.000Z', lat: null, lng: null }]]); + }); +}); diff --git a/server/tests/unit/nest/photo-capture-backfill.service.test.ts b/server/tests/unit/nest/photo-capture-backfill.service.test.ts index d1970adfb..c175c4d75 100644 --- a/server/tests/unit/nest/photo-capture-backfill.service.test.ts +++ b/server/tests/unit/nest/photo-capture-backfill.service.test.ts @@ -131,8 +131,10 @@ describe('PhotoCaptureBackfillService — local files', () => { } it('CAPTURE-007: reads a local file rather than asking a provider', async () => { + // Raw values, as exifr hands them over with reviveValues off (#2512). vi.mocked(exifr.parse).mockResolvedValue({ - DateTimeOriginal: new Date('2026-03-15T10:20:00Z'), + DateTimeOriginal: '2026:03:15 11:20:00', + OffsetTimeOriginal: '+01:00', latitude: 48.8584, longitude: 2.2945, }); @@ -149,7 +151,7 @@ describe('PhotoCaptureBackfillService — local files', () => { }); it('CAPTURE-008: falls back to CreateDate when the original timestamp is missing', async () => { - vi.mocked(exifr.parse).mockResolvedValue({ CreateDate: new Date('2026-03-16T08:00:00Z') }); + vi.mocked(exifr.parse).mockResolvedValue({ CreateDate: '2026:03:16 08:00:00', OffsetTimeDigitized: '+00:00' }); const { svc, recordCaptureMetadata } = localBuild([ { id: 7, provider: 'local', file_path: 'journey/a.jpg' }, ]); @@ -161,6 +163,35 @@ describe('PhotoCaptureBackfillService — local files', () => { }); }); + it('CAPTURE-016: a coordinate that is not a real one drops the pair, not the capture time', async () => { + // 0,0 is what a receiver without a fix writes, not a place (#2512). + for (const [latitude, longitude] of [[Number.NaN, 2.2945], [48.8584, 200], [91, 2.2945], [48.8584, undefined], [0, 0]]) { + vi.mocked(exifr.parse).mockResolvedValue({ + DateTimeOriginal: '2026:03:15 11:20:00', OffsetTimeOriginal: '+01:00', latitude, longitude, + }); + const { svc, recordCaptureMetadata } = localBuild([ + { id: 7, provider: 'local', file_path: 'journey/a.jpg' }, + ]); + + await svc.run([7], 1); + + expect(recordCaptureMetadata).toHaveBeenCalledWith(7, { + takenAt: '2026-03-15T10:20:00.000Z', lat: null, lng: null, + }); + } + }); + + it('CAPTURE-017: a bad coordinate with no usable date records nothing', async () => { + vi.mocked(exifr.parse).mockResolvedValue({ DateTimeOriginal: 'garbage', latitude: 48.8584 }); + const { svc, recordCaptureMetadata } = localBuild([ + { id: 7, provider: 'local', file_path: 'journey/a.jpg' }, + ]); + + await svc.run([7], 1); + + expect(recordCaptureMetadata).not.toHaveBeenCalled(); + }); + it('CAPTURE-009: a file with nothing readable is left alone', async () => { vi.mocked(exifr.parse).mockResolvedValue({}); const { svc, recordCaptureMetadata } = localBuild([ @@ -224,7 +255,7 @@ describe('PhotoCaptureBackfillService — local files', () => { }); it('CAPTURE-015: schedule kicks the run off for a non-empty batch', async () => { - vi.mocked(exifr.parse).mockResolvedValue({ DateTimeOriginal: new Date('2026-03-15T10:20:00Z') }); + vi.mocked(exifr.parse).mockResolvedValue({ DateTimeOriginal: '2026:03:15 10:20:00', OffsetTimeOriginal: '+00:00' }); const { svc, recordCaptureMetadata } = localBuild([ { id: 7, provider: 'local', file_path: 'journey/a.jpg' }, ]); diff --git a/server/tests/unit/nest/storage/storage.service.test.ts b/server/tests/unit/nest/storage/storage.service.test.ts index 5b56eb1f6..8c304a47d 100644 --- a/server/tests/unit/nest/storage/storage.service.test.ts +++ b/server/tests/unit/nest/storage/storage.service.test.ts @@ -51,7 +51,7 @@ function makeFixture(keyPrefix = 'files/'): Fixture { } /** A driver with no getLocalPath — the remote-driver branch every helper must handle. */ -function makeStreamOnlyFixture(contents: string): Fixture & { driverCalls: string[] } { +function makeStreamOnlyFixture(contents: string, body = (bytes: Buffer): Readable => Readable.from(bytes)): Fixture & { driverCalls: string[] } { const fx = makeFixture(''); const driverCalls: string[] = []; const bytes = Buffer.from(contents); @@ -61,7 +61,7 @@ function makeStreamOnlyFixture(contents: string): Fixture & { driverCalls: strin put: async () => undefined, getStream: async (key: string) => { driverCalls.push(`getStream:${key}`); - return { stream: Readable.from(bytes), stat: { ...stat, key } }; + return { stream: body(bytes), stat: { ...stat, key } }; }, stat: async (key: string) => ({ ...stat, key }), delete: async () => undefined, @@ -216,6 +216,22 @@ describe('StorageService withLocalFile', () => { expect(fs.existsSync(seenPath)).toBe(false); // cleaned up on throw too }); + it('leaves nothing in tempDir when the download itself fails midway', async () => { + const cut = new Error('socket hang up'); + const remote = makeStreamOnlyFixture('first half of the bytes', bytes => + Readable.from((async function* () { + yield bytes; + throw cut; + })()), + ); + let called = false; + + await expect(remote.storage.withLocalFile('files', 'remote.bin', async () => { called = true; })).rejects.toBe(cut); + + expect(called).toBe(false); + expect(fs.readdirSync(remote.tempDir)).toEqual([]); + }); + it('falls through to the stream branch when getLocalPath resolves but the file is not actually on disk', async () => { // Composite-driver stand-in (task C9-i): the façade must fs-check the // local path itself rather than trust driver.stat() as "the file is at