diff --git a/Dockerfile b/Dockerfile index 6f1fa400..5d4c2031 100644 --- a/Dockerfile +++ b/Dockerfile @@ -68,6 +68,11 @@ ENV QT_QPA_PLATFORM=offscreen ENV KITINERARY_EXTRACTOR_PATH=/usr/local/bin/kitinerary-extractor COPY --from=server-builder /app/server/dist ./server/dist +# Runtime data assets read from server/assets at runtime: airports.json (flight +# transport search) and atlas/*.geojson.gz (Atlas country/region map). The build +# only emits dist, so these must be copied explicitly or the features silently +# degrade to empty in the image. +COPY --from=server-builder /app/server/assets ./server/assets # tsconfig-paths/register reads this at runtime to resolve MCP SDK paths. COPY server/tsconfig.json ./server/ COPY --from=shared-builder /app/shared/dist ./shared/dist diff --git a/client/src/components/Map/MapView.tsx b/client/src/components/Map/MapView.tsx index 75493f4f..eeb0b10e 100644 --- a/client/src/components/Map/MapView.tsx +++ b/client/src/components/Map/MapView.tsx @@ -131,10 +131,21 @@ function SelectionController({ places, selectedPlaceId, dayPlaces, paddingOpts } useEffect(() => { if (selectedPlaceId && selectedPlaceId !== prev.current) { - // Pan to the selected place without changing zoom + // Pan to the selected place without changing zoom. Offset the centre by the + // side-panel + bottom-inspector padding so the pin lands in the middle of the + // *visible* map area rather than the geometric centre (where the bottom panel + // would cover it). Reuses the same paddingOpts the fit-bounds path uses. const selected = places.find(p => p.id === selectedPlaceId) - if (selected?.lat && selected?.lng) { - map.panTo([selected.lat, selected.lng], { animate: true }) + if (selected?.lat != null && selected?.lng != null) { + const latlng: [number, number] = [selected.lat, selected.lng] + const tl = paddingOpts.paddingTopLeft as [number, number] | undefined + const br = paddingOpts.paddingBottomRight as [number, number] | undefined + if (tl && br && typeof map.project === 'function' && typeof map.unproject === 'function') { + const point = map.project(latlng).add([(br[0] - tl[0]) / 2, (br[1] - tl[1]) / 2]) + map.panTo(map.unproject(point), { animate: true }) + } else { + map.panTo(latlng, { animate: true }) + } } } prev.current = selectedPlaceId diff --git a/client/src/components/Map/MapViewGL.tsx b/client/src/components/Map/MapViewGL.tsx index 9e6f4745..cfbacc5f 100644 --- a/client/src/components/Map/MapViewGL.tsx +++ b/client/src/components/Map/MapViewGL.tsx @@ -553,6 +553,10 @@ export function MapViewGL({ zoom: Math.max(map.getZoom(), 14), pitch: mapbox3d ? 45 : 0, duration: 400, + // Account for the side panels and the bottom inspector / day-detail panel + // so the selected pin lands in the centre of the *visible* map area rather + // than the geometric centre (where the bottom panel would cover it). + padding: paddingOpts, }) } catch { /* noop */ } }, [selectedPlaceId, mapbox3d]) // eslint-disable-line react-hooks/exhaustive-deps diff --git a/client/src/components/PDF/TripPDF.test.ts b/client/src/components/PDF/TripPDF.test.ts index 77e33eac..b01868c2 100644 --- a/client/src/components/PDF/TripPDF.test.ts +++ b/client/src/components/PDF/TripPDF.test.ts @@ -259,6 +259,23 @@ describe('downloadTripPDF', () => { expect(iframe!.srcdoc).toContain('colosseum.jpg') }) + it('FE-COMP-TRIPPDF-018b: renders a persisted place-photo proxy image_url as an , not the category icon (#1130)', async () => { + const args = { + ...richArgs, + assignments: { + '10': [{ + ...assignmentForDay, + place: { ...placeWithDetails, image_url: '/api/maps/place-photo/ChIJabc/bytes' }, + }], + } as any, + } + await downloadTripPDF(args) + const iframe = getIframe() + // The proxy path (no file extension) must still embed as an absolute . + expect(iframe!.srcdoc).toContain('http://localhost:3000/api/maps/place-photo/ChIJabc/bytes') + expect(iframe!.srcdoc).toContain('class="place-thumb"') + }) + it('FE-COMP-TRIPPDF-019: fetches google place photos for places with google_place_id', async () => { let photoCalled = false server.use( diff --git a/client/src/components/PDF/TripPDF.tsx b/client/src/components/PDF/TripPDF.tsx index c8a57697..4e493011 100644 --- a/client/src/components/PDF/TripPDF.tsx +++ b/client/src/components/PDF/TripPDF.tsx @@ -55,6 +55,10 @@ function absUrl(url) { function safeImg(url) { if (!url) return null if (url.startsWith('https://') || url.startsWith('http://')) return url + // The in-app place-photo proxy always streams a JPEG but has no file extension + // (it ends in …/bytes), so the extension check below would wrongly reject it — + // which is why persisted place photos showed as category icons in the PDF. + if (url.startsWith('/api/maps/place-photo/')) return absUrl(url) return /\.(jpe?g|png|webp|bmp|tiff?)(\?.*)?$/i.test(url) ? absUrl(url) : null } @@ -254,9 +258,10 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor const cat = categories.find(c => c.id === place.category_id) const color = cat?.color || '#6366f1' - // Image: direct > google photo > fallback icon + // Image: direct > google photo > fallback icon. Both go through safeImg + // so the proxy path is resolved to an absolute URL the PDF can load. const directImg = safeImg(place.image_url) - const googleImg = photoMap[place.id] || null + const googleImg = safeImg(photoMap[place.id]) const img = directImg || googleImg const iconSvg = categoryIconSvg(cat?.icon, color, 24) diff --git a/client/src/components/Planner/PlaceFormModal.test.tsx b/client/src/components/Planner/PlaceFormModal.test.tsx index 2dea519d..f054a628 100644 --- a/client/src/components/Planner/PlaceFormModal.test.tsx +++ b/client/src/components/Planner/PlaceFormModal.test.tsx @@ -270,6 +270,18 @@ describe('PlaceFormModal', () => { expect(screen.getByText(/No category/i)).toBeInTheDocument(); }); + it('FE-PLANNER-PLACEFORM-023b: editing a place shows its assigned category, not the placeholder (#1134)', () => { + // Regression: form.category_id is a string but the option values were numbers, + // so CustomSelect's strict-equality match failed and the trigger fell back to + // "No category". With string option values the chosen category renders. + const cat = buildCategory({ name: 'Museums' }); + const place = buildPlace({ name: 'Louvre', category_id: cat.id }); + render(); + // Dropdown is closed, so the only place the category name can appear is the trigger. + expect(screen.getByText('Museums')).toBeInTheDocument(); + expect(screen.queryByText(/No category/i)).not.toBeInTheDocument(); + }); + it('FE-PLANNER-PLACEFORM-024: onCategoryCreated is called when creating a category', async () => { const onCategoryCreated = vi.fn().mockResolvedValue({ id: 99, name: 'Beaches', color: '#6366f1', icon: 'MapPin' }); // Directly invoke handleCreateCategory by setting showNewCategory via the category name input diff --git a/client/src/components/Planner/PlaceFormModal.tsx b/client/src/components/Planner/PlaceFormModal.tsx index fc76fb79..48defabb 100644 --- a/client/src/components/Planner/PlaceFormModal.tsx +++ b/client/src/components/Planner/PlaceFormModal.tsx @@ -636,7 +636,10 @@ export default function PlaceFormModal(props: PlaceFormModalProps) { options={[ { value: '', label: t('places.noCategory') }, ...(categories || []).map(c => ({ - value: c.id, + // form.category_id is a string; CustomSelect matches options by + // strict equality, so the option value must be a string too — + // otherwise the chosen category never renders in the trigger. + value: String(c.id), label: c.name, })), ]} diff --git a/client/src/db/offlineDb.ts b/client/src/db/offlineDb.ts index 224794c6..308fcb85 100644 --- a/client/src/db/offlineDb.ts +++ b/client/src/db/offlineDb.ts @@ -148,6 +148,24 @@ export async function upsertSyncMeta(meta: SyncMeta): Promise { await offlineDb.syncMeta.put(meta); } +/** + * Read a pre-downloaded file blob for offline use. Returns null when the file + * was never cached (or on any read error). The stored MIME is reapplied so the + * caller's inline-vs-download decision stays correct even if the persisted Blob + * lost its type. + */ +export async function getCachedBlob(url: string): Promise { + try { + const entry = await offlineDb.blobCache.get(url); + if (!entry) return null; + return entry.blob.type + ? entry.blob + : new Blob([entry.blob], { type: entry.mime || 'application/octet-stream' }); + } catch { + return null; + } +} + // ── Eviction / cleanup ──────────────────────────────────────────────────────── /** Delete all cached data for one trip (eviction or explicit clear). */ diff --git a/client/src/styles/dashboard.css b/client/src/styles/dashboard.css index 589d9cbc..53518cd5 100644 --- a/client/src/styles/dashboard.css +++ b/client/src/styles/dashboard.css @@ -580,6 +580,23 @@ .trek-dash .trips { grid-template-columns: 1fr; gap: 16px; margin-bottom: 28px; } .trek-dash .add-trip-card { min-height: 180px; } + /* Compact list row on mobile — keeps the list view distinct from the grid. The + desktop list row uses a 520px cover, which overflowed the phone width: the + cover was clipped, the body pushed off-screen, and the fixed 100px cover + height left a white strip beneath it. Use a fitting cover that stretches to + the row, and show just the title + dates (the counts live in grid view and + on the trip itself). */ + .trek-dash .trips.list-view .trip-card { grid-template-columns: 42% 1fr; min-height: 92px; } + .trek-dash .trips.list-view .trip-cover { height: auto; aspect-ratio: unset; } + .trek-dash .trips.list-view .trip-cover-content { left: 14px; right: 14px; bottom: 12px; } + .trek-dash .trips.list-view .trip-name { + font-size: 17px; overflow: hidden; text-overflow: ellipsis; + display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; + } + .trek-dash .trips.list-view .trip-body { display: flex; align-items: center; justify-content: flex-start; padding: 12px 16px; } + .trek-dash .trips.list-view .trip-dates { margin-bottom: 0; justify-content: flex-start; } + .trek-dash .trips.list-view .trip-meta { display: none; } + /* Tools — stacked full-width cards (mockup) */ .trek-dash .page-sidebar { flex-direction: column; flex-wrap: nowrap; gap: 14px; margin: 0; padding: 0; } .trek-dash .page-sidebar .tool { flex: none; width: auto; } diff --git a/client/src/utils/fileDownload.ts b/client/src/utils/fileDownload.ts index 10e05fd0..be65616f 100644 --- a/client/src/utils/fileDownload.ts +++ b/client/src/utils/fileDownload.ts @@ -1,3 +1,5 @@ +import { getCachedBlob } from '../db/offlineDb' + // MIME types safe to open inline (will not execute script in any browser). // Everything else (text/html, image/svg+xml, text/javascript, …) is forced to // download so a maliciously-named upload cannot run code in the TREK origin. @@ -39,17 +41,46 @@ function isIosStandalone(): boolean { return (navigator as any).standalone === true } +/** + * Resolves a protected file to a Blob, preferring the live server but falling + * back to the offline cache (pre-downloaded by the trip sync manager). This is + * what lets attachments open in a PWA / airplane mode. When offline we go + * straight to the cache; when online we fetch live and only fall back if the + * network actually fails — which also covers flaky links where navigator.onLine + * still reports true ("sometimes it works, sometimes it doesn't"). + */ +async function getFileBlob(url: string): Promise { + assertRelativeUrl(url) + if (typeof navigator !== 'undefined' && navigator.onLine === false) { + const cached = await getCachedBlob(url) + if (cached) return cached + throw new Error('File not available offline') + } + let resp: Response + try { + resp = await fetch(url, { credentials: 'include' }) + } catch (err) { + // Genuine network failure — the fetch itself rejected (offline, or a flaky + // link even though navigator.onLine is true). Serve the pre-downloaded copy. + const cached = await getCachedBlob(url) + if (cached) return cached + throw err + } + // The server answered: a non-ok status (401/403/404/…) is a real error and must + // surface, not be masked by a stale cached copy. + if (!resp.ok) throw new Error(resp.status === 401 ? 'Unauthorized' : `HTTP ${resp.status}`) + return await resp.blob() +} + /** * Fetches a protected file using cookie auth (credentials: include) and * triggers a browser download. Works inside PWA standalone mode because the * fetch stays in the PWA's WebView rather than handing off to the system - * browser (which would lose the session cookie). + * browser (which would lose the session cookie). Falls back to the offline + * cache when the network is unavailable. */ export async function downloadFile(url: string, filename?: string): Promise { - assertRelativeUrl(url) - const resp = await fetch(url, { credentials: 'include' }) - if (!resp.ok) throw new Error(resp.status === 401 ? 'Unauthorized' : `HTTP ${resp.status}`) - const blob = await resp.blob() + const blob = await getFileBlob(url) const blobUrl = URL.createObjectURL(blob) triggerAnchorDownload(blobUrl, filename) } @@ -72,10 +103,7 @@ export async function downloadFile(url: string, filename?: string): Promise { - assertRelativeUrl(url) - const resp = await fetch(url, { credentials: 'include' }) - if (!resp.ok) throw new Error(resp.status === 401 ? 'Unauthorized' : `HTTP ${resp.status}`) - const blob = await resp.blob() + const blob = await getFileBlob(url) const blobUrl = URL.createObjectURL(blob) // Force download for MIME types that can execute script when rendered inline diff --git a/client/tests/unit/utils/fileDownload.test.ts b/client/tests/unit/utils/fileDownload.test.ts index b5a8833c..2045d217 100644 --- a/client/tests/unit/utils/fileDownload.test.ts +++ b/client/tests/unit/utils/fileDownload.test.ts @@ -1,5 +1,9 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { downloadFile, openFile } from '../../../src/utils/fileDownload' +import { getCachedBlob } from '../../../src/db/offlineDb' + +// Mock the offline DB so these tests never touch Dexie/IndexedDB. +vi.mock('../../../src/db/offlineDb', () => ({ getCachedBlob: vi.fn() })) function makeFetchMock(status: number, blob: Blob = new Blob(['data'], { type: 'application/pdf' })) { return vi.fn().mockResolvedValue({ @@ -170,3 +174,52 @@ describe('openFile', () => { } }) }) + +describe('offline fallback (#1046)', () => { + function setOnline(value: boolean) { + Object.defineProperty(navigator, 'onLine', { value, configurable: true }) + } + beforeEach(() => vi.mocked(getCachedBlob).mockReset()) + afterEach(() => setOnline(true)) + + it('serves the cached blob without a network call when offline', async () => { + setOnline(false) + const blob = new Blob(['x'], { type: 'application/pdf' }) + vi.mocked(getCachedBlob).mockResolvedValue(blob) + const fetchSpy = vi.fn() + vi.stubGlobal('fetch', fetchSpy) + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) + + await downloadFile('/uploads/files/cached.pdf') + + expect(fetchSpy).not.toHaveBeenCalled() + expect(getCachedBlob).toHaveBeenCalledWith('/uploads/files/cached.pdf') + expect(URL.createObjectURL).toHaveBeenCalledWith(blob) + }) + + it('falls back to the cache when a live fetch rejects (network error) while online', async () => { + setOnline(true) + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network down'))) + const blob = new Blob(['x'], { type: 'application/pdf' }) + vi.mocked(getCachedBlob).mockResolvedValue(blob) + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) + + await downloadFile('/uploads/files/cached.pdf') + + expect(getCachedBlob).toHaveBeenCalledWith('/uploads/files/cached.pdf') + expect(URL.createObjectURL).toHaveBeenCalledWith(blob) + }) + + it('throws when offline and the file was never cached', async () => { + setOnline(false) + vi.mocked(getCachedBlob).mockResolvedValue(null) + await expect(downloadFile('/uploads/files/missing.pdf')).rejects.toThrow(/offline/i) + }) + + it('does not consult the cache on an HTTP error — a 401 still surfaces', async () => { + setOnline(true) + vi.stubGlobal('fetch', makeFetchMock(401)) + await expect(downloadFile('/uploads/files/secret.pdf')).rejects.toThrow('Unauthorized') + expect(getCachedBlob).not.toHaveBeenCalled() + }) +}) diff --git a/docs/trek-icon.png b/docs/trek-icon.png new file mode 100644 index 00000000..b6f059ce Binary files /dev/null and b/docs/trek-icon.png differ diff --git a/server/src/services/authService.ts b/server/src/services/authService.ts index 43e3a3f9..c26733e0 100644 --- a/server/src/services/authService.ts +++ b/server/src/services/authService.ts @@ -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' }; } diff --git a/server/src/services/mapsService.ts b/server/src/services/mapsService.ts index d6503228..c4795887 100644 --- a/server/src/services/mapsService.ts +++ b/server/src/services/mapsService.ts @@ -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 = { + 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 = { 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 }> { - 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 }, diff --git a/server/tests/unit/services/authServiceDb.test.ts b/server/tests/unit/services/authServiceDb.test.ts index 66b2b018..962071fe 100644 --- a/server/tests/unit/services/authServiceDb.test.ts +++ b/server/tests/unit/services/authServiceDb.test.ts @@ -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 // --------------------------------------------------------------------------- diff --git a/server/tests/unit/services/mapsService.test.ts b/server/tests/unit/services/mapsService.test.ts index bf6f00a4..d166ff76 100644 --- a/server/tests/unit/services/mapsService.test.ts +++ b/server/tests/unit/services/mapsService.test.ts @@ -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(); + }); }); diff --git a/unraid-template.xml b/unraid-template.xml index b9c48442..5eae6de1 100644 --- a/unraid-template.xml +++ b/unraid-template.xml @@ -15,7 +15,7 @@ Productivity: Tools: http://[IP]:[PORT:3000] https://raw.githubusercontent.com/mauriceboe/TREK/main/unraid-template.xml - https://raw.githubusercontent.com/mauriceboe/TREK/main/client/public/icons/icon-dark.svg + https://raw.githubusercontent.com/mauriceboe/TREK/main/docs/trek-icon.png Support TREK development