From e65acb3de765f3c958dd4e139064b11fbbde79d1 Mon Sep 17 00:00:00 2001 From: Maurice <61554723+mauriceboe@users.noreply.github.com> Date: Thu, 11 Jun 2026 13:31:43 +0200 Subject: [PATCH] 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) --- Dockerfile | 5 + client/src/components/Map/MapView.tsx | 17 +- client/src/components/Map/MapViewGL.tsx | 4 + client/src/components/PDF/TripPDF.test.ts | 17 ++ client/src/components/PDF/TripPDF.tsx | 9 +- .../Planner/PlaceFormModal.test.tsx | 12 ++ .../src/components/Planner/PlaceFormModal.tsx | 5 +- client/src/db/offlineDb.ts | 18 ++ client/src/styles/dashboard.css | 17 ++ client/src/utils/fileDownload.ts | 46 ++++- client/tests/unit/utils/fileDownload.test.ts | 53 +++++ docs/trek-icon.png | Bin 0 -> 14965 bytes server/src/services/authService.ts | 8 +- server/src/services/mapsService.ts | 195 ++++++++++-------- .../tests/unit/services/authServiceDb.test.ts | 30 +++ .../tests/unit/services/mapsService.test.ts | 52 +++++ unraid-template.xml | 2 +- 17 files changed, 385 insertions(+), 105 deletions(-) create mode 100644 docs/trek-icon.png 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 0000000000000000000000000000000000000000..b6f059ce428c2cbec437bb4fa9610868abeea61a GIT binary patch literal 14965 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

M zQR)eXEakb!v4K$?IxnDtZ!{ zJ6-mA`f)N@oH83o8buULU2~;%BUt_CXS23jkoCkrQki!Sf9>}cVx!4%;FgZ1oC61J z(Iy*qJfAy{CKe~#x{1s83+pd;fq15iy9e|Zo^(GJ5k=$}juj^a=h}tuTDPF+gDSoO zqLPII#tqcV+h+i=`;k&FFyY@S&TsphJCBy9hhhk_Zb44Sv<_@x>j2sx z`L28TC&S(U;nvq1Rh-?3af?dDq%{z%OBBQe^ENM!&=4;?1a3PZdHO{~`rW1I#QD^;3pCW*TZ?e<@memL3D^^WwfS1|6C z;FJEQYQ}xm!YM+F^#xOa@dA#u48C+05MqZ1CS-1|&qN8;yp0{x)|Sp93W^4mIv1I( zJo~Et80QPna0#U5)obNds7RC`STky_4)kCIL5uS;w*B>n`VtoliEOwUb_cjPn}U`cT3Y z^_QFGYeuAgc76P9T;^Ax$eb&`@2b`ZRIrP0 z&7A@Ny_4ygXN*|CH}Wk4G%-MZiWXsoKAX@iGrtO<7trA z#uQo5#ij>6azrVxB{C!mT8eqKpo@-o5A})}L&o~;*+X^nab;!8T$)8@`_}X|?X3*WHHq6jM-IafQ0rmb4aKo#a0eJt z)kjB7O%AC#W)fsLgnKn^;|tz_B)67xmONaJ4x6$%aW81&erHI>BC{&|gkuTsA^6SH zQ=HzCt?iFABl6#1Uoh9CGE!V6Je}P_-cn;DT`){Ij~phVA_6y?3-y=;Eim zjzw{{vB7m7({ynU2>y(TVjqjOp4_6K*hF}>E08z7lqu>qYq4JIUB5lKl(!d-nsl7w zcYj`XfGNb!?jCkDA8v7Ofr1agppEFmkj}6hXf!gSHQNSmqJFa1Cx<9rj+7Fv?{1pE zDr$ZEiFm%?60oFR&J=yVcOH0hK5fA8{@Q`bJ+OUrq^Cfjn0P9TV+x?-pij1S>_G>; z+?l9LJF-g6Cg)JqZXvrI9RRBP)G`Ti{5hd#HJ|QMtb#mmKz}r$j=~irIj&jUGNf%n z^6yR7G0R!B?|KT6IiK#;%ic&ohAji>7s`<{4p*lpTCw~s)z-bFzqS1FjYCW`S{J`? z;O9)Se8ZEIKuDn+#?@waJp)R%gY;S1xK=?y;k^>1qUL}5`qDmUYfL%l)o=+|olKNMO2jrM3bI9pz+u^~ zy(w1Fd~#J}*9w8j)PpzK9Chj~05P^1Q4p1o^$FZdLt8ufyV<@eawXkG@&Q-1jH^W@ zmO@n(Dq$$pC07+Ezr0oB3L;JQ<^8;IFM;H2$~_5i2MLw}p&~@LJx&HVmK#SC`69bw zHTZpj{<^Mjn&3kh?E>t?HYGd~w3}7QWIqse&r~=YzdLZ#{0g$lxBN}O56@3C%~rln zC_K)Q#w93AlO+mlLYkj~lI+PS+u+k0;I{_-h5$(4n|&l@8-%Kcpo1%7%sx(TcpEh}IDvL=Qotuj4yCEI zfCuS*_+!hn;C+tVlRbbrdXmj!!7d(z+|m~7m5}KE)!3F@c(_{y*@YF0yAce`Dyc2o z=7LnF?~$(ob)z&(cr@zDnWsyBl{HNb67>dv#kv*mzM+l>awL9J<#(^h6^PYw!eiyu*3bDkXSr`RODj;`XH~bgo|+Be?F|?oXFbAapqu7yJPv zVfHSt6BO0ZnRtw2giG|nv^U~`vB~o(>=d4D|0RiU?q!n|d-`zeCQp6DJl`-NF zMPND*We_lYpX!dQTB)71~zsU8%Z|d+_<4JUr&y&Tt zP+Nf{sBL}j{@_Y+_iRAW9=W|GyqSb5N(LZ>k(Q6$4=Idw@TbRcQp)pxYbcz)Y~(Ob ztXDx@%X3?q`3F|V{rm5;S}>Y{_w{gG5M)&Vjx*dJ8RAPrv1YJmn@VQhko!`EUXQ;9 zeSN5aN6oyNxiZ!&JQN!(RP6=@T8g{PJ?yxnlMcO47AZ)xid^C9%__GS<_yITQKqbAoRilY7 z0n5|&H2FbHU&e78i!Tkso&jBna07i`rixy3&U*Xi87-B2R~cYccb-6oFnYqc32Lrw z&o1mvkst+`Z+?8|O10n*9S(Sx;U<)n|)1qk}hjS6`yn zVnE%h;El<__b$V0r$Wk2onBTims^zLCz-!CYW;#iS)r;Cu^dlgiTD*PgDoFoA#u`b#yYl=Jq!UY{IVcOi z28Jm9w+i{i?naik7tJ}78~yabG_#gK5P!Si5O8=%*u*Ue)pv7rx}G1R$h+`Ig?L^ zERPg|+ul8l5;Sw}hw;8LBwY4qUNx&m`-4*kJU~kI8;*-Saf1#!_3*V zYUiwa0I$!wF{KP8u{=~Z8Mrt_Bw)N7N|N81V z;iWDv+zLp_aNwqxLKCg{;KBPhpJVt>iaPl_2BBSi@n(Idzec1{@wiSr% zEhiT%E_2zyWWBA+T)Z$Yw$p6ok_paY#?FGT>$)Vn=oyeCt8it1?tZJAY-?P~c!lVQ zCBwSNT*Km6xhwMzpVa&3?<6jx$LYvgr&P}Yr?iJ%d20qkXp%x74MKnZA?Jr(FU}`O zxTrE=h?8#@#Ju}iY$6Vx+aj_^_0E_#Ur@a^(Duingh4lnIVttDBI9%CrSVuEcGYa% zwbNZT`yLGPZya7h>UtDx3#NHENPm7@U%Wod;c-fO!>+nQiAN~*3eY5|&+3DIl~q#v z@<%CelSFBA+!yB5Y7v)?__F5ru^X?a4JeGQZM&`I?|h|4U^iTa{@pf%sA>s(L1AO~ z530*3m4SlB@L#u(bh^Dn1@aas>ka?8M`FT1mXtH{L7q^$82;PXPDj`3|8McDl>BQ0 z&Qzi3kJgEgP{cwt#NW?Tgic_S|Nq=6bP68%d1{~2;gO$x`kek~yrujq1pnTzvkyBk z^8L{`NflFn5o{qhT%!GbVAK;{>qzqT`beh=RK@tUA4A-73Oh4W0DCJK5hDeV+fGI4 z{{&#crAKI%c*%Zg}Mzb#u8( z>W$ey4MRJo;P*RBQ&zgBi<+<-kLIbe_Ba&OuHWl2Hu%Hp;Hq^QH{WcUp6A;!XcEx- ziqEnpc0qhlb&a}^cI!hhIVljL6~wR9zdoNobLQ%4V*ipNOTSYBe9v+j@~C}ADirxM zf`?R%B_g$L8&CkS8x-HaxejjBt;yAh#5p$v%xh1VuU;?e<#x`C+|v7@C`C?5FZsKr zf;Le!bb76o--k9~|2NXD8{duTgb0w58 zLI#qL4jPBAUYKw`r7qoPrL#QdC`6$h%P-j1H^xs%d&f&x)1`wJeIKM_7?gt{rgP9CeDs=nB?rj zB{#jgq95@E`_VmW^3ggB6`u@dJbN!26$AaMwhM0hChF&3`_vud)bzmx>CCr(IFLuy z^=w(38_a3>JeC-c9Q}8x@%3}1qMPf4YzrbwhqhjbHHu1ExAjvZkFRSuR5Dgw zTheKWbE8NOK*nU-7AE6+c(c0Xr0s_K0W7m~k;;V#$KEAzCA@O6!%^B_L)(&BoR+H0 zEh~yQ=TZod@2H+DWLp#J2DDegdbhjRPen$y?zlU7 zP)9OF*u-l-^t0Lpwmn5us)!5q$OvUxB=gJA{&2(hW3=qCrPm^}zX$i8NU&Zt(fnyJ zr&k7Jx6{u`O;E?UZUI{RO`E@c>uA_B9hrMU zn&ta3)!zit&$JG)@w?&D#w*`7$uQ2|nkFvPC-17zs>BKD-$&&gxBv2LJC)a;f|7Ti z{xtPCCv7unOi`%XLOT=9@#Bp z0V+muIyT;&93-Nniv(UDzwhd@6+;uRKDNIii#7v%BQ;DMujF(MB-y-IivL(tWeJ&F z&GzKT2VaR@(B(Of{RRF`zFcnR2|G=L>K5eKx4L4G6t?pcRfu>#JqJ7EGwz&XRHGaE-1GEm-dKr$v$ZkDI@13iFhx z`ftiO2>F{ALq_Wt?e#v)SsfBP&(;@EEd&=+H9Cm++BB(^RIj9xy|{9+-}tv zc8n*d-q+j!sc|O|=$zW9hPFgmahDGG8McqjF9>;iDt(PH;# z63HZ~oe4b;KiQ~PUw(uv7uI`B-c(-M-|REdwBJsmftyt_c7b>U@IZMbgL<&A@1i*$ zMZwIl4S1l<;7J*ySFdt6u!MQ~aA2W6$!)J5R<`g3aG_VGJrZ|kZN47~`q7_% z;LT{FfL`wo^ib_P*;6i37Ox5Zy7e)h3 zL#jG8NB}K&&BQ^Y$qAqp#^(XloJ6P3tPK05gxne`DJ`B=9uyY26)(b0Gq4O zYcLK@1B(H-pLZPlv~bv6B|9Li(;}o0XytnZDSrfSy=79whwFZY8M6au`rd1!kK3Na zXkbv}+j7T^0e~W7Q|h29$$l4}449277KZQ&2Zd$wtx~bTDgVIFF+e*spRd6sJyzeg z4g~cwc7Z%57|x+SZh|5ta#s76UT{`av0qW<{Vu$}gg0EIVB9*b%ZLGCfA0Lz`HV?b z?2mHfjhAiG%{!!t+eTm}aN36M)ASI83(%CF36KpfY-@mQk$~rPq;0x5NR?`wq%>|m zVUpIIKi^Nzakf{P%p|doP@?mZa@3H1198we(hH$v!+^&J*le0+h86T^y5{Y!L^#?a zdgCY*O(95-qrj;%ct8_4`}reJu`BKC4o{+~e(}_0o2r*JNn^2X)i+144tWas=wpZy(ov63;Y35MBO=NM9n?MC``_~TA zqIgp|h&nPV%Jpr3oHVA|4&zJwZIFywr1WwN>ua~HSRRuMy#&eqPQ*VXK8m>QX-=VB zXcSb%Kji9x3%rcoGjA3xy=hk|@ULSx7%UXcCwg^g=9Ns*(^v)y+OAoij)NC3j;9Ht z6RVL;ZoUuV)<@lNMF6LewmBfAm&^v=E)mH?kTWB}g6ZPqHGK?WSP6~s5)IsOV|oq9 z0E)#sWys62rb2kj9Pmtq7$uDTO1lfNW+(0757q{C`yZ>Sx}effSjXBA2DhNNQ5%=# z(;rD~Q6~!aG_p{{8Va%Vh08U_`ldZYXySCXnr}fPpDEF5ph1=rT@W~=Vijh5ai(|t zJ*g6uL*USa5o#ZYVW&`Us7~!{1mA`_@vG9&gl78OHn9EZq?rcz2|kH!HK1v=We*YZ zbg+I6$;#CgDUJ*1G`FZUs(>Gv|0W$8`X~7GVyP|DVY-37uo{qZW7b z#VG1TgLFHrd4m2;t zm%=SSnWSZT7S;h#x9cynfT-SaB7O+qv1z}->&i;5AY^yj?k7oFk;q=nS1=_}-6`h&BP|dTn^Ed^x`@7|B zRTL|h@t)I!5U~ea?nc;7cYSya5U|@S_yj^_pOR@p?Fdf46n`-u$nba&4M1^u=m#6> zZfhPj)7X+x_C)(&ZO*oAx zaJhmZ3xHXyn$ZMwC;E^h4qiNk*e`0DfO6^ndE64_ zG&G|X3kvCbY>)iE5z9F=d48&0HcIX3AWXuxp!M0>CC8#xOYB= zpZq~|pK~M*_;>p`P?*HG7>|+ip=!`T$^3aKvGg1izMex_cy9MYWSL5aeTHJi3LZyl z!f1^9s9!t4^6xXyVn|KN(Pl$P{a0kiM@hq-52JC}_T&MY+DVRI(-mnr?wJpB2GrWj zAtb%J&gjB<&+SW?GYK-`+7=V(MH7gEM~bc+?-g5Bs>dREZKw@vk}3ksyZZSjRd0Q04v!D)W4FLp?%21??&3=AI3H z%@q(gp_y5I4Z=b8AzP2T6JbZP59R0&H%c0hT3%Ls&F1giE@s4K&ZqJ>Xh%W;0D&+0 zclX>cS3_WcKyT@z_B0IU9ISw=12I$dXQ(H#5Y>V9{xgU(J9m8ait-ltyD+!r}QUM+g#)@9WX<)wXTd zP1ZghBMu|mA-t8s^lrgarzCsWY$#au#VG9eiYJ^^2ZGVem4Edr7{YRZR3MBq8Knq@ zVKATPx$`}mWc-M#8F`8r_eWwb8eFnr_YQ_M=*hzD9MmwOKoXAf_J+NNeXIU~sm#?N z=-Uto!#KeJH89ZzV&35O}F|N016O1Zp zN=^(>G^CcQ#0Th> za#zsNC}d7tH+mbY+jP9>1bN;qDm{6?aFO&xlTJ)x0HcOy!0z}0TfAQ@^A4Jyg?{V% zpG-J(2qv^(4bIFi%b8RZI7baDClhvFt-)@Tflk{i1Zs}}JB z`d)d%ENf11r61ImqNpcZ@xY7*zmuSw<KoC@K)TzT>m7MN3#+FC*?FrnI_sUDH~wa@k&nm9~CRtJUT zgzR@TG@Xuh z=i>5fEORaz%f#0H`t5nlGh>m7JS2pM& z|A_fD_uBz;DR+Y`(2(uEzH@_4x1WH77PlwEuUP;^Xe3Ia!Qhc)L)8$J;2=(+Ow6o= znJ39$fy+^Ku~MGGub(tH3bBcf>*n%A7Dh`Zqv7C=fue7KJ^~q%T+QUuDM^B@*C&w% z$Os4h>7erM)Fn}sh;L=4Ek~1kv!P%G$5o0+YINW1Ddffx3P``px$!{b={{6ngnCc} zr#C*+1^~ADVYk&$Y#<80w5i}dLkz^q5N`ePF0*FM0%>*B+}Z=uis6%-FZ$FN_+R|& z432D!+O99}0uTbK;|7#<{{=G?uSK?>|M}||Hk(in!zfETTgDVYV+C#48RmdCY^A@U z4x2+5n>Hel8=LYRaMJ1|RzjKmD`+3;%p3!1dHf>Z7Pgv2)j?DVKZpU)aRuw_s7op| zDH1>pOlWn2!=q$mCiZ#mww_xYEDvJ3<0(>xmFsW`Wxal-%siMa0&#T25R<^~W7?8G zAtKmgiGmHc>nXIZu|osX#$bxh$`kdfb24KHXOVrTpLWXnOf(?_jR;S7B6m^;6e%geIvu549M^&@Y5)4Lf zV2KXKy)0{jkeik33#|%O1p-52#q!$8t3LJQ$uY}T$tfAY=qp_VKTo-w88{Zi3(bfK zm>Eo6V!Q|T375gB>1<9A_@ncUd(h2XB;WS)4h;2!WqVOwZ6F?cPjxVgWDwEG!(A5? z7=nX66FPZQltARt|5=k!G<0}^TAyFR4?`t5b3MKR@(KV@XfgGVOS_|@zwp$!vH*@q z0IfZ=n(AExA9PZ_7V3tV<^;|YC``EWtZ!^egJMhzbQ;2Csn)eT76cb)d&eecC@^gd z8ZdB%vf(0)ZL7^aAK!`3es}fVfv-v5*=j)U=3gV_J!OnXJ8IoGb{x5V{f1rAdc}f= zn$I7WwAX$qh-WwAb%{^6zW&*3WcW>*IQwLH+#GK6*O{-!>jI2FI zKDD18lPW==`XXw!9sUFTF}_m25Bc3M3B#5NW8Wjg_|-Fl`5^j5gd@KjLh9uV94T~o zIUS#Lni?biub)yliWE}X`?UjGnP`QPo8QaP?9Eb`iu}F2lou!tr%dN}hsU%}l{ty9wP|Is^H;&Ja1?Hv0f8h9R7bEOPSb#Z00wI?kC_zFk-vLRsj^&em@O^K@s^s z$lLVm=N7+qgfV8w-+xO7AkUC28M%GXD9Ff192X>qk$*fNLygJ&M^2Cm`R0%JHOLEp zeAXf{V=B@3C2<(@X4s9CUq3aw@}E3mtQs3hpmrF^dDS5rsS@oI*s_r{IlvjVnx95W z = { + 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