Fix a batch of reported bugs (#1145)

* fix(maps): fall back to OSM/Wikipedia for place photos and normalize non-standard language codes (#1137)

* fix(auth): refuse password reset for OIDC/SSO-linked accounts (#1129)

* fix(docker): ship server/assets (airports + atlas geo) in the runtime image (#1133, #1119)

* fix(unraid): point the template at a PNG icon Unraid can render (#1073)

* fix(offline): serve cached file blobs when offline or on network failure (#1046, #1069)

* fix(map): centre the selected pin in the visible map area above the bottom panel (#1125)

* fix(pdf): render persisted place-photo proxy URLs as images (#1130)

* fix(planner): show the selected place category in the edit form (#1134)

* fix(dashboard): collapse list-view trip cards to a compact row on mobile (#1132)
This commit is contained in:
Maurice
2026-06-11 13:31:43 +02:00
committed by GitHub
parent 3c040fab11
commit e65acb3de7
17 changed files with 385 additions and 105 deletions
+6 -2
View File
@@ -1194,9 +1194,13 @@ export function requestPasswordReset(rawEmail: string, createdIp: string | null)
if (!user) {
return { tokenForDelivery: null, userId: null, userEmail: null, reason: 'no_user' };
}
// OIDC-only account (no local password) — we can't reset what isn't there.
// SSO-linked account — refuse a reset. OIDC users are created with a random
// bcrypt hash (so password_hash is never empty), which is why we must key off
// oidc_sub rather than a missing hash. Letting the reset proceed would set a
// local password and revoke session/credential state, which breaks the SSO
// login; admins (or the user, with their current password) can still set one.
// The client still gets the generic "if that email exists…" response.
if (!user.password_hash && user.oidc_sub) {
if (user.oidc_sub) {
return { tokenForDelivery: null, userId: user.id, userEmail: user.email, reason: 'oidc_only' };
}
+108 -87
View File
@@ -70,6 +70,24 @@ interface GooglePlaceDetails extends GooglePlaceResult {
const UA = 'TREK Travel Planner (https://github.com/mauriceboe/TREK)';
// TREK's internal language codes mostly coincide with valid BCP-47 codes, but a
// couple don't: 'br' is Brazilian Portuguese here (BCP-47 'pt-BR'; bare 'br' is
// Breton) and 'gr' is Greek (BCP-47 'el'). Outbound geo APIs (Google Places,
// Nominatim) expect BCP-47, so normalise before sending — otherwise names and
// opening hours come back in the wrong language. Codes not listed here pass
// through unchanged (they are already valid), as do locale forms the client
// sometimes sends (e.g. 'pt-BR').
const API_LANG_OVERRIDES: Record<string, string> = {
br: 'pt-BR',
gr: 'el',
'el-GR': 'el',
};
function toApiLang(lang: string | undefined, fallback = 'en'): string {
const code = (lang || '').trim();
if (!code) return fallback;
return API_LANG_OVERRIDES[code] ?? code;
}
// ── Photo cache (disk-backed) ────────────────────────────────────────────────
import * as placePhotoCache from './placePhotoCache';
@@ -115,7 +133,7 @@ export async function searchNominatim(query: string, lang?: string) {
format: 'json',
addressdetails: '1',
limit: '10',
'accept-language': lang || 'en',
'accept-language': toApiLang(lang),
});
const response = await fetch(`https://nominatim.openstreetmap.org/search?${params}`, {
headers: { 'User-Agent': UA },
@@ -148,7 +166,7 @@ export async function lookupNominatim(osmType: string, osmId: string, lang?: str
const params = new URLSearchParams({
osm_ids: `${typePrefix}${osmId}`,
format: 'json',
'accept-language': lang || 'en',
'accept-language': toApiLang(lang),
});
try {
const res = await fetch(`https://nominatim.openstreetmap.org/lookup?${params}`, {
@@ -339,7 +357,7 @@ export async function searchPlaces(userId: number, query: string, lang?: string)
'X-Goog-Api-Key': apiKey,
'X-Goog-FieldMask': 'places.id,places.displayName,places.formattedAddress,places.location,places.rating,places.websiteUri,places.nationalPhoneNumber,places.types',
},
body: JSON.stringify({ textQuery: query, languageCode: lang || 'en' }),
body: JSON.stringify({ textQuery: query, languageCode: toApiLang(lang) }),
});
const data = await response.json() as { places?: GooglePlaceResult[]; error?: { message?: string } };
@@ -381,7 +399,7 @@ export async function autocompletePlaces(
const body: Record<string, unknown> = {
input,
languageCode: lang || 'en',
languageCode: toApiLang(lang),
};
if (locationBias) {
body.locationBias = {
@@ -472,7 +490,7 @@ export async function getPlaceDetails(userId: number, placeId: string, lang?: st
}
// Google details
const langKey = lang || 'de';
const langKey = toApiLang(lang, 'de');
const apiKey = getMapsKey(userId);
if (!apiKey) {
throw Object.assign(new Error('Google Maps API key not configured'), { status: 400 });
@@ -532,7 +550,7 @@ export async function getPlaceDetails(userId: number, placeId: string, lang?: st
}
export async function getPlaceDetailsExpanded(userId: number, placeId: string, lang?: string, refresh = false): Promise<{ place: Record<string, unknown> }> {
const langKey = lang || 'de';
const langKey = toApiLang(lang, 'de');
const apiKey = getMapsKey(userId);
if (!apiKey) throw Object.assign(new Error('Google Maps API key not configured'), { status: 400 });
@@ -628,90 +646,93 @@ export async function getPlacePhoto(
const apiKey = getMapsKey(userId);
const isCoordLookup = placeId.startsWith('coords:');
// No Google key or coordinate-only lookup → try Wikimedia (URL-based, not byte-cached)
if (!apiKey || isCoordLookup) {
if (!isNaN(lat) && !isNaN(lng)) {
try {
const wiki = await fetchWikimediaPhoto(lat, lng, name);
if (wiki) {
// Wikimedia photos: fetch bytes and cache to disk. Follow redirects
// manually so each hop (the image URL can 3xx to a CDN host) is
// re-validated against the SSRF guard, not just the first URL.
const imgRes = await safeFetchFollow(wiki.photoUrl, undefined, { bypassInternalIpAllowed: true });
if (imgRes.ok) {
const bytes = Buffer.from(await imgRes.arrayBuffer());
const cached = await placePhotoCache.put(placeId, bytes, wiki.attribution);
return { filePath: cached.filePath, attribution: cached.attribution };
}
}
} catch { /* fall through */ }
// Coordinate-based Wikipedia/Wikimedia lookup. Used for coordinate-only
// (right-click) places and as a fallback when a Google place yields no photo,
// so a place added via search still gets a marker image when Google returns
// nothing. Returns null (without marking an error) so the caller decides.
const fetchWikimediaFallback = async (): Promise<{ filePath: string; attribution: string | null } | null> => {
if (isNaN(lat) || isNaN(lng)) return null;
try {
const wiki = await fetchWikimediaPhoto(lat, lng, name);
if (!wiki) return null;
// Follow redirects manually so each hop (the image URL can 3xx to a CDN
// host) is re-validated against the SSRF guard, not just the first URL.
const imgRes = await safeFetchFollow(wiki.photoUrl, undefined, { bypassInternalIpAllowed: true });
if (!imgRes.ok) return null;
const bytes = Buffer.from(await imgRes.arrayBuffer());
const cached = await placePhotoCache.put(placeId, bytes, wiki.attribution);
return { filePath: cached.filePath, attribution: cached.attribution };
} catch {
return null;
}
placePhotoCache.markError(placeId);
return null;
};
// Google Places photo for a Google place_id. Returns null (without marking an
// error) on any miss — no key, URL-shaped id, request rejected, no photos, or
// a failed media download — so the caller can fall back to Wikimedia.
const fetchGooglePhoto = async (): Promise<{ filePath: string; attribution: string | null } | null> => {
// URL-shaped placeIds aren't Google IDs — legacy DBs may store raw photo URLs in image_url
if (!apiKey || /^https?:\/\//i.test(placeId)) return null;
// Fetch details to get the photo name
const detailsRes = await googleFetch(`https://places.googleapis.com/v1/places/${placeId}`, `getPlacePhoto/details(${placeId})`, {
headers: {
'X-Goog-Api-Key': apiKey,
'X-Goog-FieldMask': 'photos',
},
});
const body = await detailsRes.text();
if (!detailsRes.ok) {
console.error('Google Places photo details error:', detailsRes.status, body.slice(0, 200));
return null;
}
let details: GooglePlaceDetails & { error?: { message?: string } };
try { details = body ? JSON.parse(body) : { photos: [] }; }
catch { return null; }
if (!details.photos?.length) return null;
const photo = details.photos[0];
const photoName = photo.name;
const attribution = photo.authorAttributions?.[0]?.displayName || null;
// Fetch actual image bytes
const mediaRes = await googleFetch(
`https://places.googleapis.com/v1/${photoName}/media?maxHeightPx=400`,
`getPlacePhoto/media(${placeId})`,
{ headers: { 'X-Goog-Api-Key': apiKey } }
);
if (!mediaRes.ok) return null;
const bytes = Buffer.from(await mediaRes.arrayBuffer());
if (!bytes.length) return null;
const cached = await placePhotoCache.put(placeId, bytes, attribution);
// Persist stable proxy URL to database
try {
db.prepare(
'UPDATE places SET image_url = ?, updated_at = CURRENT_TIMESTAMP WHERE google_place_id = ? AND (image_url IS NULL OR image_url = \'\')'
).run(cached.photoUrl, placeId);
} catch (dbErr) {
console.error('Failed to persist photo URL to database:', dbErr);
}
return { filePath: cached.filePath, attribution };
};
// Prefer the Google photo (higher quality); if Google yields nothing, fall
// back to the same coordinate-based Wikipedia/OSM lookup that right-click
// places use. Coordinate-only ids skip Google entirely.
if (!isCoordLookup) {
const googlePhoto = await fetchGooglePhoto();
if (googlePhoto) return googlePhoto;
}
// Reject URL-shaped placeIds — legacy DBs may store raw photo URLs in image_url
if (/^https?:\/\//i.test(placeId)) {
placePhotoCache.markError(placeId);
return null;
}
const fallback = await fetchWikimediaFallback();
if (fallback) return fallback;
// Google Photos — fetch details to get photo name
const detailsRes = await googleFetch(`https://places.googleapis.com/v1/places/${placeId}`, `getPlacePhoto/details(${placeId})`, {
headers: {
'X-Goog-Api-Key': apiKey,
'X-Goog-FieldMask': 'photos',
},
});
const body = await detailsRes.text();
if (!detailsRes.ok) {
console.error('Google Places photo details error:', detailsRes.status, body.slice(0, 200));
placePhotoCache.markError(placeId);
return null;
}
let details: GooglePlaceDetails & { error?: { message?: string } };
try { details = body ? JSON.parse(body) : { photos: [] }; }
catch { placePhotoCache.markError(placeId); return null; }
if (!details.photos?.length) {
placePhotoCache.markError(placeId);
return null;
}
const photo = details.photos[0];
const photoName = photo.name;
const attribution = photo.authorAttributions?.[0]?.displayName || null;
// Fetch actual image bytes
const mediaRes = await googleFetch(
`https://places.googleapis.com/v1/${photoName}/media?maxHeightPx=400`,
`getPlacePhoto/media(${placeId})`,
{ headers: { 'X-Goog-Api-Key': apiKey } }
);
if (!mediaRes.ok) {
placePhotoCache.markError(placeId);
return null;
}
const bytes = Buffer.from(await mediaRes.arrayBuffer());
if (!bytes.length) {
placePhotoCache.markError(placeId);
return null;
}
const cached = await placePhotoCache.put(placeId, bytes, attribution);
// Persist stable proxy URL to database
try {
db.prepare(
'UPDATE places SET image_url = ?, updated_at = CURRENT_TIMESTAMP WHERE google_place_id = ? AND (image_url IS NULL OR image_url = \'\')'
).run(cached.photoUrl, placeId);
} catch (dbErr) {
console.error('Failed to persist photo URL to database:', dbErr);
}
return { filePath: cached.filePath, attribution };
placePhotoCache.markError(placeId);
return null;
} finally {
releasePhotoFetchSlot();
}
@@ -729,7 +750,7 @@ export async function getPlacePhoto(
export async function reverseGeocode(lat: string, lng: string, lang?: string): Promise<{ name: string | null; address: string | null }> {
const params = new URLSearchParams({
lat, lon: lng, format: 'json', addressdetails: '1', zoom: '18',
'accept-language': lang || 'en',
'accept-language': toApiLang(lang),
});
const response = await fetch(`https://nominatim.openstreetmap.org/reverse?${params}`, {
headers: { 'User-Agent': UA },
@@ -85,6 +85,7 @@ import {
validateInviteToken,
registerUser,
loginUser,
requestPasswordReset,
changePassword,
verifyMfaLogin,
createMcpToken,
@@ -106,6 +107,35 @@ beforeEach(() => resetTestDb(testDb));
afterAll(() => testDb.close());
// ---------------------------------------------------------------------------
// requestPasswordReset — OIDC/SSO accounts (#1129)
// ---------------------------------------------------------------------------
describe('requestPasswordReset — OIDC/SSO accounts', () => {
it('AUTH-DB-PR1: refuses a reset for an OIDC-linked account that has a (random) password hash', () => {
const { user } = createUser(testDb);
// OIDC users are created with a random bcrypt hash, so password_hash is set —
// the old guard keyed off a missing hash and therefore let the reset through.
testDb.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ? WHERE id = ?')
.run('sub-1129', 'https://idp.example', user.id);
const result = requestPasswordReset(user.email, null);
expect(result.reason).toBe('oidc_only');
expect(result.tokenForDelivery).toBeNull();
const { n } = testDb.prepare('SELECT COUNT(*) AS n FROM password_reset_tokens WHERE user_id = ?')
.get(user.id) as { n: number };
expect(n).toBe(0);
});
it('AUTH-DB-PR2: still issues a reset for a normal local (non-SSO) account', () => {
const { user } = createUser(testDb);
const result = requestPasswordReset(user.email, null);
expect(result.reason).toBe('issued');
expect(result.tokenForDelivery).toBeTruthy();
});
});
// ---------------------------------------------------------------------------
// updateSettings
// ---------------------------------------------------------------------------
@@ -1049,6 +1049,26 @@ describe('getPlaceDetails (fetch stubbed)', () => {
expect(place.summary).toBeNull();
});
it('MAPS-041b2: normalises non-standard TREK language codes for Google (br→pt-BR, gr→el)', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ id: 'ChIJ1', displayName: { text: 'X' }, location: { latitude: 0, longitude: 0 } }),
});
mockDbGet.mockReturnValue({ maps_api_key: 'gkey' });
vi.stubGlobal('fetch', fetchMock);
const { getPlaceDetails } = await import('../../../src/services/mapsService');
await getPlaceDetails(1, 'ChIJ-br', 'br');
expect(String(fetchMock.mock.calls[0][0])).toContain('languageCode=pt-BR');
await getPlaceDetails(1, 'ChIJ-gr', 'gr');
expect(String(fetchMock.mock.calls[1][0])).toContain('languageCode=el');
// A code that is already valid passes through unchanged.
await getPlaceDetails(1, 'ChIJ-de', 'de');
expect(String(fetchMock.mock.calls[2][0])).toContain('languageCode=de');
});
it('MAPS-041c: throws with status when Google API returns non-ok response', async () => {
mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' });
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
@@ -1354,4 +1374,36 @@ describe('getPlacePhoto (fetch stubbed)', () => {
expect(result.photoUrl).toBe(`/api/maps/place-photo/${encodeURIComponent(uniqueId)}/bytes`);
expect(mockCachePut).toHaveBeenCalledOnce();
});
it('MAPS-044g: falls back to Wikipedia/OSM for a Google place_id when the Google photo call fails', async () => {
// A key is present and the placeId is a Google id, but Google rejects the
// photo request (e.g. 403). The lookup must still return an image via the
// coordinate-based Wikipedia fallback instead of giving up with a 404 —
// matching what right-click (coords:) places already do.
mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' });
vi.stubGlobal('fetch', vi.fn()
// 1) Google photo details → 403
.mockResolvedValueOnce({
ok: false,
status: 403,
text: async () => JSON.stringify({ error: { message: 'PERMISSION_DENIED' } }),
})
// 2) Wikipedia pageimages → thumbnail
.mockResolvedValueOnce({
ok: true,
json: async () => ({ query: { pages: { '1': { thumbnail: { source: 'https://wiki.org/guinness.jpg' } } } } }),
})
// 3) image bytes
.mockResolvedValueOnce({
ok: true,
arrayBuffer: async () => new ArrayBuffer(200),
})
);
const { getPlacePhoto } = await import('../../../src/services/mapsService');
const placeId = `ChIJFallback-${Date.now()}`;
const result = await getPlacePhoto(1, placeId, 53.34, -6.28, 'Guinness Storehouse');
expect(result.photoUrl).toBe(`/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`);
expect(result.attribution).toBe('Wikipedia');
expect(mockCachePut).toHaveBeenCalledOnce();
});
});