jPpQ%pC9g94!aDxWI$faV&0>~vM69r2hR<|9T
z&~}}X$l69*=-zFt^7{Da*zI;3au$dgfd0%GM1gy8OH~iV>cN;qllIrfF-oCZZ|JM
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-%siMa0T25R<^~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