fix(journey): resolve Immich photos on public share by matching trek_photos.id

validateShareTokenForPhoto was querying journey_photos by jp.id but the
public page sends p.photo_id (trek_photos.id) in the URL. In a fresh
database the IDs coincidentally match, masking the bug. In production
instances with many Immich-synced photos the trek_photos autoincrement
is far ahead of journey_photos, causing a 404 for every Immich photo
on the public share page.

Fix: change the lookup to jp.photo_id = ? so validation is keyed on
trek_photos.id, which is what the client sends and what streamPhoto
needs. Updated the test helper to return trekId and added a regression
test that pre-populates trek_photos to produce diverging IDs. Closes #675.
This commit is contained in:
jubnl
2026-04-16 15:37:24 +02:00
parent 6c1a795460
commit da70388f4b
2 changed files with 31 additions and 4 deletions
+1 -1
View File
@@ -63,7 +63,7 @@ export function validateShareTokenForPhoto(token: string, photoId: number): { jo
FROM journey_photos jp
JOIN trek_photos tkp ON tkp.id = jp.photo_id
JOIN journey_entries je ON jp.entry_id = je.id
WHERE jp.id = ? AND je.journey_id = ?
WHERE jp.photo_id = ? AND je.journey_id = ?
`).get(photoId, row.journey_id) as any;
if (!photo) return null;
const journey = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(row.journey_id) as any;
@@ -58,7 +58,7 @@ afterAll(() => {
// -- Helpers ------------------------------------------------------------------
/** Insert a journey_photos row and return its id. */
/** Insert a trek_photos + journey_photos row and return the trek_photos id (used as photoId in public URLs). */
function insertJourneyPhoto(
entryId: number,
opts: { filePath?: string; assetId?: string; ownerId?: number } = {}
@@ -70,11 +70,13 @@ function insertJourneyPhoto(
VALUES (?, ?, ?, ?, ?)
`).run(provider, opts.assetId ?? null, filePath, opts.ownerId ?? null, Date.now());
const trekId = trekResult.lastInsertRowid as number;
const result = testDb.prepare(`
testDb.prepare(`
INSERT INTO journey_photos (entry_id, photo_id, caption, sort_order, created_at)
VALUES (?, ?, NULL, 0, ?)
`).run(entryId, trekId, Date.now());
return result.lastInsertRowid as number;
// Return trek_photos.id — this is p.photo_id in the public API response
// and the value the client sends to /api/public/journey/:token/photos/:photoId/:kind
return trekId;
}
// -- Tests --------------------------------------------------------------------
@@ -237,6 +239,31 @@ describe('validateShareTokenForPhoto', () => {
expect(result).not.toBeNull();
expect(result!.ownerId).toBe(user.id);
});
it('JOURNEY-SHARE-016: resolves correctly when trek_photos.id differs from journey_photos.id (Immich bulk-sync scenario)', () => {
// Simulate a user who has many trek_photos from Immich syncs before adding a journey photo.
// trek_photos.id will be higher than journey_photos.id — the previous bug matched on jp.id
// instead of jp.photo_id, causing a 404 for Immich photos in public shares.
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const entry = createJourneyEntry(testDb, journey.id, user.id);
// Pre-populate trek_photos to push the autoincrement higher
for (let i = 0; i < 5; i++) {
testDb.prepare(`INSERT INTO trek_photos (provider, asset_id, owner_id, created_at) VALUES ('immich', ?, ?, ?)`).run(`bulk-asset-${i}`, user.id, Date.now());
}
// This trek_photos row gets a high id (e.g. 6) while journey_photos id will be 1
const trekPhotoId = insertJourneyPhoto(entry.id, { assetId: 'journey-asset-xyz', ownerId: user.id });
const { token } = createOrUpdateJourneyShareLink(journey.id, user.id, {});
// photoId = trek_photos.id (6), not journey_photos.id (1)
const result = validateShareTokenForPhoto(token, trekPhotoId);
expect(result).not.toBeNull();
expect(result!.ownerId).toBe(user.id);
expect(result!.journeyId).toBe(journey.id);
});
});
describe('validateShareTokenForAsset', () => {