diff --git a/client/src/App.tsx b/client/src/App.tsx index 0a53fa46..941492c2 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -100,7 +100,7 @@ function RootRedirect() { } export default function App() { - const { loadUser, isAuthenticated, demoMode, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled } = useAuthStore() + const { loadUser, isAuthenticated, demoMode, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled } = useAuthStore() const { loadSettings } = useSettingsStore() const { loadAddons } = useAddonStore() @@ -116,7 +116,7 @@ export default function App() { loadUser() } } - authApi.getAppConfig().then(async (config: { demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; permissions?: Record }) => { + authApi.getAppConfig().then(async (config: { demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_enabled?: boolean; permissions?: Record }) => { if (config?.demo_mode) setDemoMode(true) if (config?.dev_mode) setDevMode(true) if (config?.is_prerelease !== undefined) setIsPrerelease(config.is_prerelease) @@ -125,6 +125,9 @@ export default function App() { if (config?.timezone) setServerTimezone(config.timezone) if (config?.require_mfa !== undefined) setAppRequireMfa(!!config.require_mfa) if (config?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(config.trip_reminders_enabled) + if (config?.places_photos_enabled !== undefined) setPlacesPhotosEnabled(config.places_photos_enabled) + if (config?.places_autocomplete_enabled !== undefined) setPlacesAutocompleteEnabled(config.places_autocomplete_enabled) + if (config?.places_details_enabled !== undefined) setPlacesDetailsEnabled(config.places_details_enabled) if (config?.permissions) usePermissionsStore.getState().setPermissions(config.permissions) if (config?.version) { diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 8ac67d13..179e02ff 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -272,6 +272,12 @@ export const adminApi = { checkVersion: () => apiClient.get('/admin/version-check').then(r => r.data), getBagTracking: () => apiClient.get('/admin/bag-tracking').then(r => r.data), updateBagTracking: (enabled: boolean) => apiClient.put('/admin/bag-tracking', { enabled }).then(r => r.data), + getPlacesPhotos: () => apiClient.get('/admin/places-photos').then(r => r.data), + updatePlacesPhotos: (enabled: boolean) => apiClient.put('/admin/places-photos', { enabled }).then(r => r.data), + getPlacesAutocomplete: () => apiClient.get('/admin/places-autocomplete').then(r => r.data), + updatePlacesAutocomplete: (enabled: boolean) => apiClient.put('/admin/places-autocomplete', { enabled }).then(r => r.data), + getPlacesDetails: () => apiClient.get('/admin/places-details').then(r => r.data), + updatePlacesDetails: (enabled: boolean) => apiClient.put('/admin/places-details', { enabled }).then(r => r.data), getCollabFeatures: () => apiClient.get('/admin/collab-features').then(r => r.data), updateCollabFeatures: (features: Record) => apiClient.put('/admin/collab-features', features).then(r => r.data), packingTemplates: () => apiClient.get('/admin/packing-templates').then(r => r.data), diff --git a/client/src/components/Map/MapView.tsx b/client/src/components/Map/MapView.tsx index 32d1ec44..1bcc499f 100644 --- a/client/src/components/Map/MapView.tsx +++ b/client/src/components/Map/MapView.tsx @@ -66,9 +66,9 @@ function createPlaceIcon(place, orderNumbers, isSelected) { ">${label}` } - // Base64 data URL thumbnails — no external image fetch during zoom - // Only use base64 data URLs for markers — external URLs cause zoom lag - if (place.image_url && place.image_url.startsWith('data:')) { + // Prefer base64 data URLs (no zoom lag); also accept same-origin proxy URLs as a fallback + // while the thumb is still being generated in the background + if (place.image_url && (place.image_url.startsWith('data:') || place.image_url.startsWith('/api/maps/place-photo/'))) { const imgIcon = L.divIcon({ className: '', html: `
= { 'admin.fileTypesSaved': 'Fájltípus-beállítások mentve', // Csomagolási sablonok és poggyászkövetés + 'admin.placesPhotos.title': 'Helyfotók', + 'admin.placesPhotos.subtitle': 'Fotók lekérése a Google Places API-ból. Tiltsa le az API-kvóta megtakarításához. A Wikimedia-fotók nem érintettek.', + 'admin.placesAutocomplete.title': 'Hely automatikus kiegészítése', + 'admin.placesAutocomplete.subtitle': 'A Google Places API használata keresési javaslatokhoz. Tiltsa le az API-kvóta megtakarításához.', + 'admin.placesDetails.title': 'Hely részletei', + 'admin.placesDetails.subtitle': 'Részletes helyinformációk lekérése (nyitvatartás, értékelés, weboldal) a Google Places API-ból. Tiltsa le az API-kvóta megtakarításához.', 'admin.bagTracking.title': 'Poggyászkövetés', 'admin.bagTracking.subtitle': 'Súly- és táskahozzárendelés engedélyezése csomagolási tételeknél', 'admin.collab.chat.title': 'Chat', diff --git a/client/src/i18n/translations/id.ts b/client/src/i18n/translations/id.ts index 5e7a0f1e..bdba0cf8 100644 --- a/client/src/i18n/translations/id.ts +++ b/client/src/i18n/translations/id.ts @@ -610,6 +610,12 @@ const id: Record = { 'admin.fileTypesSaved': 'Pengaturan jenis file disimpan', // Packing Templates & Bag Tracking + 'admin.placesPhotos.title': 'Foto Tempat', + 'admin.placesPhotos.subtitle': 'Mengambil foto dari Google Places API. Nonaktifkan untuk menghemat kuota API. Foto Wikimedia tidak terpengaruh.', + 'admin.placesAutocomplete.title': 'Pelengkapan Otomatis Tempat', + 'admin.placesAutocomplete.subtitle': 'Menggunakan Google Places API untuk saran pencarian. Nonaktifkan untuk menghemat kuota API.', + 'admin.placesDetails.title': 'Detail Tempat', + 'admin.placesDetails.subtitle': 'Mengambil informasi detail tempat (jam, penilaian, situs web) dari Google Places API. Nonaktifkan untuk menghemat kuota API.', 'admin.bagTracking.title': 'Pelacak Tas', 'admin.bagTracking.subtitle': 'Aktifkan berat dan penugasan tas untuk item packing', 'admin.collab.chat.title': 'Chat', diff --git a/client/src/i18n/translations/it.ts b/client/src/i18n/translations/it.ts index 58ecfea6..178305e5 100644 --- a/client/src/i18n/translations/it.ts +++ b/client/src/i18n/translations/it.ts @@ -545,6 +545,12 @@ const it: Record = { 'admin.fileTypesFormat': 'Estensioni separate da virgola (es. jpg,png,pdf,doc). Usa * per consentire tutti i tipi.', 'admin.fileTypesSaved': 'Impostazioni dei tipi di file salvate', // Packing Templates & Bag Tracking + 'admin.placesPhotos.title': 'Foto dei luoghi', + 'admin.placesPhotos.subtitle': "Recupera le foto dall'API Google Places. Disabilita per risparmiare la quota API. Le foto di Wikimedia non sono interessate.", + 'admin.placesAutocomplete.title': 'Completamento automatico dei luoghi', + 'admin.placesAutocomplete.subtitle': "Utilizza l'API Google Places per i suggerimenti di ricerca. Disabilita per risparmiare la quota API.", + 'admin.placesDetails.title': 'Dettagli del luogo', + 'admin.placesDetails.subtitle': "Recupera informazioni dettagliate sul luogo (orari, valutazione, sito web) dall'API Google Places. Disabilita per risparmiare la quota API.", 'admin.bagTracking.title': 'Tracciamento valigia', 'admin.bagTracking.subtitle': 'Abilita il peso e l\'assegnazione della valigia per gli elementi della lista valigia', 'admin.collab.chat.title': 'Chat', diff --git a/client/src/i18n/translations/nl.ts b/client/src/i18n/translations/nl.ts index cc9d97cd..17a7165c 100644 --- a/client/src/i18n/translations/nl.ts +++ b/client/src/i18n/translations/nl.ts @@ -546,6 +546,12 @@ const nl: Record = { 'admin.fileTypesFormat': 'Kommagescheiden extensies (bijv. jpg,png,pdf,doc). Gebruik * om alle typen toe te staan.', 'admin.fileTypesSaved': 'Bestandstype-instellingen opgeslagen', + 'admin.placesPhotos.title': "Plaatsfoto's", + 'admin.placesPhotos.subtitle': "Haalt foto's op via de Google Places API. Schakel uit om API-quota te besparen. Wikimedia-foto's worden niet beïnvloed.", + 'admin.placesAutocomplete.title': 'Plaatsautocomplete', + 'admin.placesAutocomplete.subtitle': 'Gebruikt de Google Places API voor zoeksuggesties. Schakel uit om API-quota te besparen.', + 'admin.placesDetails.title': 'Plaatsdetails', + 'admin.placesDetails.subtitle': 'Haalt gedetailleerde plaatsinformatie (openingstijden, beoordeling, website) op via de Google Places API. Schakel uit om API-quota te besparen.', 'admin.bagTracking.title': 'Bagagetracking', 'admin.bagTracking.subtitle': 'Gewicht en bagagetoewijzing inschakelen voor paklijstitems', 'admin.collab.chat.title': 'Chat', diff --git a/client/src/i18n/translations/pl.ts b/client/src/i18n/translations/pl.ts index 427a2134..080d5cb3 100644 --- a/client/src/i18n/translations/pl.ts +++ b/client/src/i18n/translations/pl.ts @@ -518,6 +518,12 @@ const pl: Record = { 'admin.fileTypesSaved': 'Ustawienia typów plików zostały zapisane', // Packing Templates & Bag Tracking + 'admin.placesPhotos.title': 'Zdjęcia miejsc', + 'admin.placesPhotos.subtitle': 'Pobiera zdjęcia z Google Places API. Wyłącz, aby zaoszczędzić limit API. Zdjęcia z Wikimedia nie są objęte.', + 'admin.placesAutocomplete.title': 'Autouzupełnianie miejsc', + 'admin.placesAutocomplete.subtitle': 'Używa Google Places API do sugestii wyszukiwania. Wyłącz, aby zaoszczędzić limit API.', + 'admin.placesDetails.title': 'Szczegóły miejsca', + 'admin.placesDetails.subtitle': 'Pobiera szczegółowe informacje o miejscu (godziny, ocena, strona) z Google Places API. Wyłącz, aby zaoszczędzić limit API.', 'admin.bagTracking.title': 'Kontrola bagażu', 'admin.bagTracking.subtitle': 'Włącz wagę i przypisywanie do toreb dla przedmiotów do pakowania', 'admin.collab.chat.title': 'Czat', diff --git a/client/src/i18n/translations/ru.ts b/client/src/i18n/translations/ru.ts index 0bf70d93..9961de3d 100644 --- a/client/src/i18n/translations/ru.ts +++ b/client/src/i18n/translations/ru.ts @@ -546,6 +546,12 @@ const ru: Record = { 'admin.fileTypesFormat': 'Расширения через запятую (напр. jpg,png,pdf,doc). Используйте * для разрешения всех типов.', 'admin.fileTypesSaved': 'Настройки типов файлов сохранены', + 'admin.placesPhotos.title': 'Фотографии мест', + 'admin.placesPhotos.subtitle': 'Загрузка фотографий из Google Places API. Отключите для экономии квоты API. Фотографии Wikimedia не затронуты.', + 'admin.placesAutocomplete.title': 'Автодополнение мест', + 'admin.placesAutocomplete.subtitle': 'Использование Google Places API для поисковых подсказок. Отключите для экономии квоты API.', + 'admin.placesDetails.title': 'Сведения о месте', + 'admin.placesDetails.subtitle': 'Загрузка подробной информации о месте (часы работы, рейтинг, веб-сайт) из Google Places API. Отключите для экономии квоты API.', 'admin.bagTracking.title': 'Отслеживание багажа', 'admin.bagTracking.subtitle': 'Включить вес и привязку к багажу для вещей', 'admin.collab.chat.title': 'Чат', diff --git a/client/src/i18n/translations/zh.ts b/client/src/i18n/translations/zh.ts index 60aacb85..98d5b4db 100644 --- a/client/src/i18n/translations/zh.ts +++ b/client/src/i18n/translations/zh.ts @@ -546,6 +546,12 @@ const zh: Record = { 'admin.fileTypesFormat': '以逗号分隔的扩展名(如 jpg,png,pdf,doc)。使用 * 允许所有类型。', 'admin.fileTypesSaved': '文件类型设置已保存', + 'admin.placesPhotos.title': '地点照片', + 'admin.placesPhotos.subtitle': '从 Google Places API 获取照片。禁用可节省 API 配额。Wikimedia 照片不受影响。', + 'admin.placesAutocomplete.title': '地点自动补全', + 'admin.placesAutocomplete.subtitle': '使用 Google Places API 提供搜索建议。禁用可节省 API 配额。', + 'admin.placesDetails.title': '地点详情', + 'admin.placesDetails.subtitle': '从 Google Places API 获取地点详细信息(营业时间、评分、网站)。禁用可节省 API 配额。', 'admin.bagTracking.title': '行李追踪', 'admin.bagTracking.subtitle': '为打包物品启用重量和行李分配', 'admin.collab.chat.title': '聊天', diff --git a/client/src/i18n/translations/zhTw.ts b/client/src/i18n/translations/zhTw.ts index 50a0f6b1..9d83ae40 100644 --- a/client/src/i18n/translations/zhTw.ts +++ b/client/src/i18n/translations/zhTw.ts @@ -606,6 +606,12 @@ const zhTw: Record = { 'admin.fileTypesFormat': '以逗號分隔的副檔名(如 jpg,png,pdf,doc)。使用 * 允許所有型別。', 'admin.fileTypesSaved': '檔案型別設定已儲存', + 'admin.placesPhotos.title': '地點照片', + 'admin.placesPhotos.subtitle': '從 Google Places API 獲取照片。停用可節省 API 配額。Wikimedia 照片不受影響。', + 'admin.placesAutocomplete.title': '地點自動補全', + 'admin.placesAutocomplete.subtitle': '使用 Google Places API 提供搜尋建議。停用可節省 API 配額。', + 'admin.placesDetails.title': '地點詳情', + 'admin.placesDetails.subtitle': '從 Google Places API 獲取地點詳細資訊(營業時間、評分、網站)。停用可節省 API 配額。', 'admin.bagTracking.title': '行李追蹤', 'admin.bagTracking.subtitle': '為打包物品啟用重量和行李分配', 'admin.collab.chat.title': '聊天', diff --git a/client/src/pages/AdminPage.tsx b/client/src/pages/AdminPage.tsx index d758082a..9b976734 100644 --- a/client/src/pages/AdminPage.tsx +++ b/client/src/pages/AdminPage.tsx @@ -194,6 +194,18 @@ export default function AdminPage(): React.ReactElement { const [bagTrackingEnabled, setBagTrackingEnabled] = useState(false) useEffect(() => { adminApi.getBagTracking().then(d => setBagTrackingEnabled(d.enabled)).catch(() => {}) }, []) + // Places photos + const [placesPhotosEnabled, setPlacesPhotosEnabledState] = useState(true) + useEffect(() => { adminApi.getPlacesPhotos().then(d => setPlacesPhotosEnabledState(d.enabled)).catch(() => {}) }, []) + + // Places autocomplete + const [placesAutocompleteEnabled, setPlacesAutocompleteEnabledState] = useState(true) + useEffect(() => { adminApi.getPlacesAutocomplete().then(d => setPlacesAutocompleteEnabledState(d.enabled)).catch(() => {}) }, []) + + // Places details + const [placesDetailsEnabled, setPlacesDetailsEnabledState] = useState(true) + useEffect(() => { adminApi.getPlacesDetails().then(d => setPlacesDetailsEnabledState(d.enabled)).catch(() => {}) }, []) + // Collab features const [collabFeatures, setCollabFeatures] = useState<{ chat: boolean; notes: boolean; polls: boolean; whatsnext: boolean }>({ chat: true, notes: true, polls: true, whatsnext: true }) useEffect(() => { adminApi.getCollabFeatures().then(d => setCollabFeatures(d)).catch(() => {}) }, []) @@ -242,7 +254,7 @@ export default function AdminPage(): React.ReactElement { const [updateInfo, setUpdateInfo] = useState(null) const [showUpdateModal, setShowUpdateModal] = useState(false) - const { user: currentUser, updateApiKeys, setAppRequireMfa, setTripRemindersEnabled, logout } = useAuthStore() + const { user: currentUser, updateApiKeys, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, logout } = useAuthStore() const navigate = useNavigate() const toast = useToast() @@ -1023,6 +1035,66 @@ export default function AdminPage(): React.ReactElement { )}
+ {/* Place Photos Toggle */} +
+
+

{t('admin.placesPhotos.title')}

+

{t('admin.placesPhotos.subtitle')}

+
+ +
+ + {/* Place Autocomplete Toggle */} +
+
+

{t('admin.placesAutocomplete.title')}

+

{t('admin.placesAutocomplete.subtitle')}

+
+ +
+ + {/* Place Details Toggle */} +
+
+

{t('admin.placesDetails.title')}

+

{t('admin.placesDetails.subtitle')}

+
+ +
+ {/* Open-Meteo Weather Info */}
diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index 5c24201d..020be261 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -28,6 +28,7 @@ import { useTranslation } from '../i18n' import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi, mapsApi } from '../api/client' import { accommodationRepo } from '../repo/accommodationRepo' import { offlineDb } from '../db/offlineDb' +import { useAuthStore } from '../store/authStore' import ConfirmDialog from '../components/shared/ConfirmDialog' import { useResizablePanels } from '../hooks/useResizablePanels' import { useTripWebSocket } from '../hooks/useTripWebSocket' @@ -75,6 +76,7 @@ export default function TripPlannerPage(): React.ReactElement | null { const toast = useToast() const { t, language } = useTranslation() const { settings } = useSettingsStore() + const placesPhotosEnabled = useAuthStore(s => s.placesPhotosEnabled) const trip = useTripStore(s => s.trip) const days = useTripStore(s => s.days) const places = useTripStore(s => s.places) @@ -178,7 +180,7 @@ export default function TripPlannerPage(): React.ReactElement | null { // Start photo fetches during splash screen so images are ready when map mounts useEffect(() => { - if (isLoading || !places || places.length === 0) return + if (isLoading || !places || places.length === 0 || !placesPhotosEnabled) return for (const p of places) { if (p.image_url) continue const cacheKey = p.google_place_id || p.osm_id || `${p.lat},${p.lng}` @@ -900,7 +902,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
{mobileSidebarOpen === 'left' - ? { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId); setMobileSidebarOpen(null) }} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); setSelectedAssignmentId(null); setMobileSidebarOpen(null) }} accommodations={tripAccommodations} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} /> + ? { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId); setMobileSidebarOpen(null) }} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null); setMobileSidebarOpen(null) }} accommodations={tripAccommodations} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} /> : { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} onPlacesFilterChange={setMapPlacesFilter} pushUndo={pushUndo} /> }
diff --git a/client/src/services/photoService.ts b/client/src/services/photoService.ts index d5fc3682..c80c7fa4 100644 --- a/client/src/services/photoService.ts +++ b/client/src/services/photoService.ts @@ -85,6 +85,19 @@ export function fetchPhoto( return } + // If photoId is already our stable proxy URL, use it directly — no API round-trip needed + if (photoId && photoId.startsWith('/api/maps/place-photo/')) { + const entry: PhotoEntry = { photoUrl: photoId, thumbDataUrl: null } + cache.set(cacheKey, entry) + callback?.(entry) + notify(cacheKey, entry) + // Generate base64 thumb in background + urlToBase64(photoId).then(thumb => { + if (thumb) { entry.thumbDataUrl = thumb; notifyThumb(cacheKey, thumb) } + }) + return + } + inFlight.add(cacheKey) mapsApi.placePhoto(photoId, lat, lng, name) .then(async (data: { photoUrl?: string }) => { diff --git a/client/src/store/authStore.ts b/client/src/store/authStore.ts index 74fe01e1..8d8c342d 100644 --- a/client/src/store/authStore.ts +++ b/client/src/store/authStore.ts @@ -33,6 +33,9 @@ interface AuthState { /** Server policy: all users must enable MFA */ appRequireMfa: boolean tripRemindersEnabled: boolean + placesPhotosEnabled: boolean + placesAutocompleteEnabled: boolean + placesDetailsEnabled: boolean login: (email: string, password: string) => Promise completeMfaLogin: (mfaToken: string, code: string) => Promise @@ -53,6 +56,9 @@ interface AuthState { setServerTimezone: (tz: string) => void setAppRequireMfa: (val: boolean) => void setTripRemindersEnabled: (val: boolean) => void + setPlacesPhotosEnabled: (val: boolean) => void + setPlacesAutocompleteEnabled: (val: boolean) => void + setPlacesDetailsEnabled: (val: boolean) => void demoLogin: () => Promise } @@ -74,6 +80,9 @@ export const useAuthStore = create()( serverTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone, appRequireMfa: false, tripRemindersEnabled: false, + placesPhotosEnabled: true, + placesAutocompleteEnabled: true, + placesDetailsEnabled: true, login: async (email: string, password: string) => { authSequence++ @@ -257,6 +266,9 @@ export const useAuthStore = create()( setServerTimezone: (tz: string) => set({ serverTimezone: tz }), setAppRequireMfa: (val: boolean) => set({ appRequireMfa: val }), setTripRemindersEnabled: (val: boolean) => set({ tripRemindersEnabled: val }), + setPlacesPhotosEnabled: (val: boolean) => set({ placesPhotosEnabled: val }), + setPlacesAutocompleteEnabled: (val: boolean) => set({ placesAutocompleteEnabled: val }), + setPlacesDetailsEnabled: (val: boolean) => set({ placesDetailsEnabled: val }), demoLogin: async () => { authSequence++ diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index e79decef..d4786fa4 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -1634,6 +1634,45 @@ function runMigrations(db: Database.Database): void { try { db.exec('ALTER TABLE trip_album_links ADD COLUMN passphrase TEXT DEFAULT NULL'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } try { db.exec('ALTER TABLE trek_photos ADD COLUMN passphrase TEXT DEFAULT NULL'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } }, + // Migration 105: Persistent Google place photo disk cache registry + () => { + db.exec(` + CREATE TABLE IF NOT EXISTS google_place_photo_meta ( + place_id TEXT PRIMARY KEY, + attribution TEXT, + fetched_at INTEGER NOT NULL, + error_at INTEGER + ) + `); + }, + // Migration 106: Persistent Place Details row cache + () => { + db.exec(` + CREATE TABLE IF NOT EXISTS place_details_cache ( + place_id TEXT NOT NULL, + lang TEXT NOT NULL DEFAULT '', + expanded INTEGER NOT NULL DEFAULT 0, + payload_json TEXT NOT NULL, + fetched_at INTEGER NOT NULL, + PRIMARY KEY (place_id, lang, expanded) + ) + `); + }, + // Migration 107: Backfill expired signed Google photo URLs to stable proxy URLs + { raw: () => { + db.exec(` + UPDATE places + SET image_url = '/api/maps/place-photo/' || google_place_id || '/bytes', + updated_at = CURRENT_TIMESTAMP + WHERE google_place_id IS NOT NULL + AND image_url IS NOT NULL + AND image_url != '' + AND ( + (image_url LIKE '%googleusercontent.com%' AND image_url LIKE '%/places/%/photos/%') + OR (image_url LIKE '%places.googleapis.com%' AND image_url LIKE '%/places/%/photos/%') + ) + `); + }}, ]; if (currentVersion < migrations.length) { diff --git a/server/src/routes/admin.ts b/server/src/routes/admin.ts index ab873e52..1c9c2aca 100644 --- a/server/src/routes/admin.ts +++ b/server/src/routes/admin.ts @@ -201,6 +201,63 @@ router.put('/bag-tracking', (req: Request, res: Response) => { res.json(result); }); +// ── Places Photos ─────────────────────────────────────────────────────── + +router.get('/places-photos', (_req: Request, res: Response) => { + res.json(svc.getPlacesPhotos()); +}); + +router.put('/places-photos', (req: Request, res: Response) => { + if (typeof req.body.enabled !== 'boolean') return res.status(400).json({ error: 'enabled must be a boolean' }); + const result = svc.updatePlacesPhotos(req.body.enabled); + const authReq = req as AuthRequest; + writeAudit({ + userId: authReq.user.id, + action: 'admin.places_photos', + ip: getClientIp(req), + details: { enabled: result.enabled }, + }); + res.json(result); +}); + +// ── Places Autocomplete ────────────────────────────────────────────────── + +router.get('/places-autocomplete', (_req: Request, res: Response) => { + res.json(svc.getPlacesAutocomplete()); +}); + +router.put('/places-autocomplete', (req: Request, res: Response) => { + if (typeof req.body.enabled !== 'boolean') return res.status(400).json({ error: 'enabled must be a boolean' }); + const result = svc.updatePlacesAutocomplete(req.body.enabled); + const authReq = req as AuthRequest; + writeAudit({ + userId: authReq.user.id, + action: 'admin.places_autocomplete', + ip: getClientIp(req), + details: { enabled: result.enabled }, + }); + res.json(result); +}); + +// ── Places Details ─────────────────────────────────────────────────────── + +router.get('/places-details', (_req: Request, res: Response) => { + res.json(svc.getPlacesDetails()); +}); + +router.put('/places-details', (req: Request, res: Response) => { + if (typeof req.body.enabled !== 'boolean') return res.status(400).json({ error: 'enabled must be a boolean' }); + const result = svc.updatePlacesDetails(req.body.enabled); + const authReq = req as AuthRequest; + writeAudit({ + userId: authReq.user.id, + action: 'admin.places_details', + ip: getClientIp(req), + details: { enabled: result.enabled }, + }); + res.json(result); +}); + // ── Collab Features ─────────────────────────────────────────────────────── router.get('/collab-features', (_req: Request, res: Response) => { diff --git a/server/src/routes/maps.ts b/server/src/routes/maps.ts index 57867095..427d4500 100644 --- a/server/src/routes/maps.ts +++ b/server/src/routes/maps.ts @@ -4,11 +4,14 @@ import { AuthRequest } from '../types'; import { searchPlaces, getPlaceDetails, + getPlaceDetailsExpanded, getPlacePhoto, reverseGeocode, resolveGoogleMapsUrl, autocompletePlaces, } from '../services/mapsService'; +import { db } from '../db/database'; +import { serveFilePath } from '../services/placePhotoCache'; const router = express.Router(); @@ -32,6 +35,9 @@ router.post('/search', authenticate, async (req: Request, res: Response) => { // POST /autocomplete router.post('/autocomplete', authenticate, async (req: Request, res: Response) => { + const autocompleteEnabledRow = db.prepare("SELECT value FROM app_settings WHERE key = 'places_autocomplete_enabled'").get() as { value: string } | undefined; + if (autocompleteEnabledRow?.value === 'false') return res.status(200).json({ suggestions: [], source: 'disabled' }); + const authReq = req as AuthRequest; const { input, lang, locationBias } = req.body; @@ -70,11 +76,18 @@ router.post('/autocomplete', authenticate, async (req: Request, res: Response) = // GET /details/:placeId router.get('/details/:placeId', authenticate, async (req: Request, res: Response) => { + const detailsEnabledRow = db.prepare("SELECT value FROM app_settings WHERE key = 'places_details_enabled'").get() as { value: string } | undefined; + if (detailsEnabledRow?.value === 'false') return res.status(200).json({ place: null, disabled: true }); + const authReq = req as AuthRequest; const { placeId } = req.params; + const expand = req.query.expand as string | undefined; try { - const result = await getPlaceDetails(authReq.user.id, placeId, req.query.lang as string); + const refresh = req.query.refresh === '1'; + const result = expand + ? await getPlaceDetailsExpanded(authReq.user.id, placeId, req.query.lang as string, refresh) + : await getPlaceDetails(authReq.user.id, placeId, req.query.lang as string); res.json(result); } catch (err: unknown) { const status = (err as { status?: number }).status || 500; @@ -88,6 +101,12 @@ router.get('/details/:placeId', authenticate, async (req: Request, res: Response router.get('/place-photo/:placeId', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; const { placeId } = req.params; + + // Kill-switch only applies to Google Places API fetches — Wikimedia (coords: prefix) is always allowed + if (!placeId.startsWith('coords:')) { + const photosEnabledRow = db.prepare("SELECT value FROM app_settings WHERE key = 'places_photos_enabled'").get() as { value: string } | undefined; + if (photosEnabledRow?.value === 'false') return res.status(200).json({ photoUrl: null }); + } const lat = parseFloat(req.query.lat as string); const lng = parseFloat(req.query.lng as string); @@ -102,6 +121,15 @@ router.get('/place-photo/:placeId', authenticate, async (req: Request, res: Resp } }); +// GET /place-photo/:placeId/bytes — serve cached photo bytes from disk +router.get('/place-photo/:placeId/bytes', authenticate, (req: Request, res: Response) => { + const { placeId } = req.params; + const fp = serveFilePath(placeId); + if (!fp) return res.status(404).json({ error: 'Photo not cached' }); + res.set('Cache-Control', 'public, max-age=2592000, immutable'); + res.sendFile(fp); +}); + // GET /reverse router.get('/reverse', authenticate, async (req: Request, res: Response) => { const { lat, lng, lang } = req.query as { lat: string; lng: string; lang?: string }; diff --git a/server/src/services/adminService.ts b/server/src/services/adminService.ts index 81674b54..66a2d349 100644 --- a/server/src/services/adminService.ts +++ b/server/src/services/adminService.ts @@ -459,6 +459,42 @@ export function updateBagTracking(enabled: boolean) { return { enabled: !!enabled }; } +// ── Places Photos ───────────────────────────────────────────────────────── + +export function getPlacesPhotos() { + const row = db.prepare("SELECT value FROM app_settings WHERE key = 'places_photos_enabled'").get() as { value: string } | undefined; + return { enabled: row?.value !== 'false' }; +} + +export function updatePlacesPhotos(enabled: boolean) { + db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('places_photos_enabled', ?)").run(enabled ? 'true' : 'false'); + return { enabled: !!enabled }; +} + +// ── Places Autocomplete ──────────────────────────────────────────────────── + +export function getPlacesAutocomplete() { + const row = db.prepare("SELECT value FROM app_settings WHERE key = 'places_autocomplete_enabled'").get() as { value: string } | undefined; + return { enabled: row?.value !== 'false' }; +} + +export function updatePlacesAutocomplete(enabled: boolean) { + db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('places_autocomplete_enabled', ?)").run(enabled ? 'true' : 'false'); + return { enabled: !!enabled }; +} + +// ── Places Details ───────────────────────────────────────────────────────── + +export function getPlacesDetails() { + const row = db.prepare("SELECT value FROM app_settings WHERE key = 'places_details_enabled'").get() as { value: string } | undefined; + return { enabled: row?.value !== 'false' }; +} + +export function updatePlacesDetails(enabled: boolean) { + db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('places_details_enabled', ?)").run(enabled ? 'true' : 'false'); + return { enabled: !!enabled }; +} + // ── Collab Features ─────────────────────────────────────────────────────── const COLLAB_FEATURE_KEYS = ['collab_chat_enabled', 'collab_notes_enabled', 'collab_polls_enabled', 'collab_whatsnext_enabled'] as const; diff --git a/server/src/services/authService.ts b/server/src/services/authService.ts index 33660c58..b091bf58 100644 --- a/server/src/services/authService.ts +++ b/server/src/services/authService.ts @@ -229,6 +229,12 @@ export function getAppConfig(authenticatedUser: { id: number } | null) { const activeChannels = notifChannelsRaw === 'none' ? [] : notifChannelsRaw.split(',').map((c: string) => c.trim()).filter(Boolean); const hasWebhookEnabled = activeChannels.includes('webhook'); const tripRemindersEnabled = tripReminderSetting !== 'false'; + const placesPhotosSetting = (db.prepare("SELECT value FROM app_settings WHERE key = 'places_photos_enabled'").get() as { value: string } | undefined)?.value; + const placesPhotosEnabled = placesPhotosSetting !== 'false'; + const placesAutocompleteSetting = (db.prepare("SELECT value FROM app_settings WHERE key = 'places_autocomplete_enabled'").get() as { value: string } | undefined)?.value; + const placesAutocompleteEnabled = placesAutocompleteSetting !== 'false'; + const placesDetailsSetting = (db.prepare("SELECT value FROM app_settings WHERE key = 'places_details_enabled'").get() as { value: string } | undefined)?.value; + const placesDetailsEnabled = placesDetailsSetting !== 'false'; const setupComplete = userCount > 0 && !(db.prepare("SELECT id FROM users WHERE role = 'admin' AND must_change_password = 1 LIMIT 1").get()); return { @@ -258,6 +264,9 @@ export function getAppConfig(authenticatedUser: { id: number } | null) { notification_channels: activeChannels, available_channels: { email: hasSmtpHost, webhook: hasWebhookEnabled, inapp: true }, trip_reminders_enabled: tripRemindersEnabled, + places_photos_enabled: placesPhotosEnabled, + places_autocomplete_enabled: placesAutocompleteEnabled, + places_details_enabled: placesDetailsEnabled, permissions: authenticatedUser ? getAllPermissions() : undefined, dev_mode: process.env.NODE_ENV === 'development', }; diff --git a/server/src/services/mapsService.ts b/server/src/services/mapsService.ts index ca886cea..30827857 100644 --- a/server/src/services/mapsService.ts +++ b/server/src/services/mapsService.ts @@ -2,6 +2,19 @@ import { db } from '../db/database'; import { decrypt_api_key } from './apiKeyCrypto'; import { checkSsrf } from '../utils/ssrfGuard'; +// ── Google API call counter ─────────────────────────────────────────────────── + +let googleApiCallCount = 0; + +export function getGoogleApiCallCount(): number { return googleApiCallCount; } +export function resetGoogleApiCallCount(): void { googleApiCallCount = 0; } + +function googleFetch(endpoint: string, label: string, init?: RequestInit): Promise { + googleApiCallCount++; + console.debug(`[Google API] #${googleApiCallCount} ${label} → ${endpoint}`); + return fetch(endpoint, init); +} + // ── Interfaces ─────────────────────────────────────────────────────────────── interface NominatimResult { @@ -55,26 +68,8 @@ interface GooglePlaceDetails extends GooglePlaceResult { const UA = 'TREK Travel Planner (https://github.com/mauriceboe/TREK)'; -// ── Photo cache ────────────────────────────────────────────────────────────── - -const photoCache = new Map(); -const PHOTO_TTL = 12 * 60 * 60 * 1000; // 12 hours -const ERROR_TTL = 5 * 60 * 1000; // 5 min for errors -const CACHE_MAX_ENTRIES = 1000; -const CACHE_PRUNE_TARGET = 500; -const CACHE_CLEANUP_INTERVAL = 5 * 60 * 1000; // 5 minutes - -setInterval(() => { - const now = Date.now(); - for (const [key, entry] of photoCache) { - if (now - entry.fetchedAt > PHOTO_TTL) photoCache.delete(key); - } - if (photoCache.size > CACHE_MAX_ENTRIES) { - const entries = [...photoCache.entries()].sort((a, b) => a[1].fetchedAt - b[1].fetchedAt); - const toDelete = entries.slice(0, entries.length - CACHE_PRUNE_TARGET); - toDelete.forEach(([key]) => photoCache.delete(key)); - } -}, CACHE_CLEANUP_INTERVAL); +// ── Photo cache (disk-backed) ──────────────────────────────────────────────── +import * as placePhotoCache from './placePhotoCache'; // ── API key retrieval ──────────────────────────────────────────────────────── @@ -311,7 +306,7 @@ export async function searchPlaces(userId: number, query: string, lang?: string) return { places, source: 'openstreetmap' }; } - const response = await fetch('https://places.googleapis.com/v1/places:searchText', { + const response = await googleFetch('https://places.googleapis.com/v1/places:searchText', 'searchText', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -371,7 +366,7 @@ export async function autocompletePlaces( }; } - const response = await fetch('https://places.googleapis.com/v1/places:autocomplete', { + const response = await googleFetch('https://places.googleapis.com/v1/places:autocomplete', 'autocomplete', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -451,12 +446,79 @@ export async function getPlaceDetails(userId: number, placeId: string, lang?: st } // Google details + const langKey = lang || 'de'; const apiKey = getMapsKey(userId); if (!apiKey) { throw Object.assign(new Error('Google Maps API key not configured'), { status: 400 }); } - const response = await fetch(`https://places.googleapis.com/v1/places/${placeId}?languageCode=${lang || 'de'}`, { + // Check DB cache first (lean mask, expanded=0) — 7-day TTL + const DETAILS_TTL = 7 * 24 * 60 * 60 * 1000; + const cached = db.prepare( + 'SELECT payload_json, fetched_at FROM place_details_cache WHERE place_id = ? AND lang = ? AND expanded = 0' + ).get(placeId, langKey) as { payload_json: string; fetched_at: number } | undefined; + if (cached && Date.now() - cached.fetched_at < DETAILS_TTL) return { place: JSON.parse(cached.payload_json) }; + + const response = await googleFetch(`https://places.googleapis.com/v1/places/${placeId}?languageCode=${langKey}`, `getPlaceDetails(${placeId})`, { + method: 'GET', + headers: { + 'X-Goog-Api-Key': apiKey, + 'X-Goog-FieldMask': 'id,displayName,formattedAddress,location,rating,userRatingCount,websiteUri,nationalPhoneNumber,regularOpeningHours,googleMapsUri', + }, + }); + + const data = await response.json() as GooglePlaceDetails & { error?: { message?: string } }; + + if (!response.ok) { + const err = new Error(data.error?.message || 'Google Places API error') as Error & { status: number }; + err.status = response.status; + throw err; + } + + const place = { + google_place_id: data.id, + name: data.displayName?.text || '', + address: data.formattedAddress || '', + lat: data.location?.latitude || null, + lng: data.location?.longitude || null, + rating: data.rating || null, + rating_count: data.userRatingCount || null, + website: data.websiteUri || null, + phone: data.nationalPhoneNumber || null, + opening_hours: data.regularOpeningHours?.weekdayDescriptions || null, + open_now: data.regularOpeningHours?.openNow ?? null, + google_maps_url: data.googleMapsUri || null, + summary: null, + reviews: [], + source: 'google' as const, + cached_at: Date.now(), + }; + + try { + db.prepare( + 'INSERT OR REPLACE INTO place_details_cache (place_id, lang, expanded, payload_json, fetched_at) VALUES (?, ?, 0, ?, ?)' + ).run(placeId, langKey, JSON.stringify(place), Date.now()); + } catch (dbErr) { + console.error('Failed to cache place details:', dbErr); + } + + return { place }; +} + +export async function getPlaceDetailsExpanded(userId: number, placeId: string, lang?: string, refresh = false): Promise<{ place: Record }> { + const langKey = lang || 'de'; + const apiKey = getMapsKey(userId); + if (!apiKey) throw Object.assign(new Error('Google Maps API key not configured'), { status: 400 }); + + // Check DB cache for expanded result + if (!refresh) { + const cached = db.prepare( + 'SELECT payload_json FROM place_details_cache WHERE place_id = ? AND lang = ? AND expanded = 1' + ).get(placeId, langKey) as { payload_json: string } | undefined; + if (cached) return { place: JSON.parse(cached.payload_json) }; + } + + const response = await googleFetch(`https://places.googleapis.com/v1/places/${placeId}?languageCode=${langKey}`, `getPlaceDetailsExpanded(${placeId})`, { method: 'GET', headers: { 'X-Goog-Api-Key': apiKey, @@ -494,12 +556,21 @@ export async function getPlaceDetails(userId: number, placeId: string, lang?: st photo: r.authorAttribution?.photoUri || null, })), source: 'google' as const, + cached_at: Date.now(), }; + try { + db.prepare( + 'INSERT OR REPLACE INTO place_details_cache (place_id, lang, expanded, payload_json, fetched_at) VALUES (?, ?, 1, ?, ?)' + ).run(placeId, langKey, JSON.stringify(place), Date.now()); + } catch (dbErr) { + console.error('Failed to cache expanded place details:', dbErr); + } + return { place }; } -// ── Place photo (Google or Wikimedia, with caching + DB persistence) ───────── +// ── Place photo (Google or Wikimedia, disk-cached) ──────────────────────────── export async function getPlacePhoto( userId: number, @@ -508,84 +579,110 @@ export async function getPlacePhoto( lng: number, name?: string, ): Promise<{ photoUrl: string; attribution: string | null }> { - // Check cache first - const cached = photoCache.get(placeId); - if (cached) { - const ttl = cached.error ? ERROR_TTL : PHOTO_TTL; - if (Date.now() - cached.fetchedAt < ttl) { - if (cached.error) throw Object.assign(new Error('(Cache) No photo available'), { status: 404 }); - return { photoUrl: cached.photoUrl, attribution: cached.attribution }; + // Disk cache hit — serve immediately, no Google call + const diskHit = placePhotoCache.get(placeId); + if (diskHit) return { photoUrl: diskHit.photoUrl, attribution: diskHit.attribution }; + + // Recent error — don't hammer the API + if (placePhotoCache.getErrored(placeId)) { + throw Object.assign(new Error('(Cache) No photo available'), { status: 404 }); + } + + // Deduplicate concurrent requests for the same placeId + const existing = placePhotoCache.getInFlight(placeId); + if (existing) { + const result = await existing; + if (!result) throw Object.assign(new Error('(Cache) No photo available'), { status: 404 }); + return { photoUrl: `/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`, attribution: result.attribution }; + } + + const fetchPromise = (async (): Promise<{ filePath: string; attribution: string | null } | null> => { + 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 + const ssrf = await checkSsrf(wiki.photoUrl, true); + if (!ssrf.allowed) throw Object.assign(new Error('Photo URL blocked'), { status: 403 }); + const imgRes = await fetch(wiki.photoUrl); + 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 */ } + } + placePhotoCache.markError(placeId); + return null; } - photoCache.delete(placeId); - } - const apiKey = getMapsKey(userId); - const isCoordLookup = placeId.startsWith('coords:'); + // 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 details = await detailsRes.json() as GooglePlaceDetails & { error?: { message?: string } }; - // No Google key or coordinate-only lookup -> try Wikimedia - if (!apiKey || isCoordLookup) { - if (!isNaN(lat) && !isNaN(lng)) { - try { - const wiki = await fetchWikimediaPhoto(lat, lng, name); - if (wiki) { - photoCache.set(placeId, { ...wiki, fetchedAt: Date.now() }); - return wiki; - } else { - photoCache.set(placeId, { photoUrl: '', attribution: null, fetchedAt: Date.now(), error: true }); - } - } catch { /* fall through */ } + if (!detailsRes.ok) { + console.error('Google Places photo details error:', details.error?.message || detailsRes.status); + placePhotoCache.markError(placeId); + return null; } - throw Object.assign(new Error('(Wikimedia) No photo available'), { status: 404 }); - } - // Google Photos - const detailsRes = await fetch(`https://places.googleapis.com/v1/places/${placeId}`, { - headers: { - 'X-Goog-Api-Key': apiKey, - 'X-Goog-FieldMask': 'photos', - }, - }); - const details = await detailsRes.json() as GooglePlaceDetails & { error?: { message?: string } }; + if (!details.photos?.length) { + placePhotoCache.markError(placeId); + return null; + } - if (!detailsRes.ok) { - console.error('Google Places photo details error:', details.error?.message || detailsRes.status); - photoCache.set(placeId, { photoUrl: '', attribution: null, fetchedAt: Date.now(), error: true }); - throw Object.assign(new Error('(Google Places) Photo could not be retrieved'), { status: 404 }); - } + const photo = details.photos[0]; + const photoName = photo.name; + const attribution = photo.authorAttributions?.[0]?.displayName || null; - if (!details.photos?.length) { - photoCache.set(placeId, { photoUrl: '', attribution: null, fetchedAt: Date.now(), error: true }); - throw Object.assign(new Error('(Google Places) No photo available'), { status: 404 }); - } + // 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 } } + ); - const photo = details.photos[0]; - const photoName = photo.name; - const attribution = photo.authorAttributions?.[0]?.displayName || null; + if (!mediaRes.ok) { + placePhotoCache.markError(placeId); + return null; + } - const mediaRes = await fetch( - `https://places.googleapis.com/v1/${photoName}/media?maxHeightPx=400&skipHttpRedirect=true`, - { headers: { 'X-Goog-Api-Key': apiKey } } - ); - const mediaData = await mediaRes.json() as { photoUri?: string }; - const photoUrl = mediaData.photoUri; + const bytes = Buffer.from(await mediaRes.arrayBuffer()); + if (!bytes.length) { + placePhotoCache.markError(placeId); + return null; + } - if (!photoUrl) { - photoCache.set(placeId, { photoUrl: '', attribution, fetchedAt: Date.now(), error: true }); - throw Object.assign(new Error('(Google Places) Photo URL not available'), { status: 404 }); - } + const cached = await placePhotoCache.put(placeId, bytes, attribution); - photoCache.set(placeId, { photoUrl, attribution, fetchedAt: Date.now() }); + // 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); + } - // Persist photo 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(photoUrl, placeId, ''); - } catch (dbErr) { - console.error('Failed to persist photo URL to database:', dbErr); - } + return { filePath: cached.filePath, attribution }; + })(); - return { photoUrl, attribution }; + placePhotoCache.setInFlight(placeId, fetchPromise); + + const result = await fetchPromise; + if (!result) throw Object.assign(new Error('No photo available'), { status: 404 }); + return { photoUrl: `/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`, attribution: result.attribution }; } // ── Reverse geocoding ──────────────────────────────────────────────────────── diff --git a/server/src/services/placePhotoCache.ts b/server/src/services/placePhotoCache.ts new file mode 100644 index 00000000..06995e57 --- /dev/null +++ b/server/src/services/placePhotoCache.ts @@ -0,0 +1,95 @@ +import path from 'node:path'; +import fs from 'node:fs'; +import fsPromises from 'node:fs/promises'; +import crypto from 'node:crypto'; +import { db } from '../db/database'; + +const GOOGLE_PHOTO_DIR = path.join(__dirname, '../../uploads/photos/google'); +const ERROR_TTL = 5 * 60 * 1000; + +// In-flight dedup — prevents stampedes when multiple requests hit the same uncached placeId simultaneously +const inFlight = new Map>(); + +function ensureDir(): void { + if (!fs.existsSync(GOOGLE_PHOTO_DIR)) { + fs.mkdirSync(GOOGLE_PHOTO_DIR, { recursive: true }); + } +} + +function filePath(placeId: string): string { + // Hash to avoid filename collisions — coords:lat:lng pseudo-IDs contain characters that + // collapse identically under sanitization (e.g. ':' and '.' both → '_') + const hash = crypto.createHash('sha1').update(placeId).digest('hex'); + return path.join(GOOGLE_PHOTO_DIR, `${hash}.jpg`); +} + +function proxyUrl(placeId: string): string { + return `/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`; +} + +interface CachedPhoto { + photoUrl: string; + filePath: string; + attribution: string | null; +} + +export function get(placeId: string): CachedPhoto | null { + const row = db.prepare( + 'SELECT attribution FROM google_place_photo_meta WHERE place_id = ? AND error_at IS NULL' + ).get(placeId) as { attribution: string | null } | undefined; + + if (!row) return null; + + const fp = filePath(placeId); + if (!fs.existsSync(fp)) { + // File missing (e.g. volume wiped) — clear row so it refetches + db.prepare('DELETE FROM google_place_photo_meta WHERE place_id = ?').run(placeId); + return null; + } + + return { photoUrl: proxyUrl(placeId), filePath: fp, attribution: row.attribution }; +} + +export function getErrored(placeId: string): boolean { + const row = db.prepare( + 'SELECT error_at FROM google_place_photo_meta WHERE place_id = ? AND error_at IS NOT NULL' + ).get(placeId) as { error_at: number } | undefined; + + if (!row) return false; + return Date.now() - row.error_at < ERROR_TTL; +} + +export function markError(placeId: string): void { + db.prepare( + 'INSERT OR REPLACE INTO google_place_photo_meta (place_id, attribution, fetched_at, error_at) VALUES (?, NULL, ?, ?)' + ).run(placeId, Date.now(), Date.now()); +} + +export async function put(placeId: string, bytes: Buffer, attribution: string | null): Promise { + ensureDir(); + const fp = filePath(placeId); + const tmp = fp + '.tmp'; + + await fsPromises.writeFile(tmp, bytes); + await fsPromises.rename(tmp, fp); + + db.prepare( + 'INSERT OR REPLACE INTO google_place_photo_meta (place_id, attribution, fetched_at, error_at) VALUES (?, ?, ?, NULL)' + ).run(placeId, attribution, Date.now()); + + return { photoUrl: proxyUrl(placeId), filePath: fp, attribution }; +} + +export function getInFlight(placeId: string): Promise<{ filePath: string; attribution: string | null } | null> | undefined { + return inFlight.get(placeId); +} + +export function setInFlight(placeId: string, promise: Promise<{ filePath: string; attribution: string | null } | null>): void { + inFlight.set(placeId, promise); + promise.finally(() => inFlight.delete(placeId)); +} + +export function serveFilePath(placeId: string): string | null { + const fp = filePath(placeId); + return fs.existsSync(fp) ? fp : null; +} diff --git a/server/tests/unit/services/mapsService.test.ts b/server/tests/unit/services/mapsService.test.ts index 4399651a..29f8e225 100644 --- a/server/tests/unit/services/mapsService.test.ts +++ b/server/tests/unit/services/mapsService.test.ts @@ -8,10 +8,19 @@ */ import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; -const { mockDbGet, mockDbRun, mockCheckSsrf } = vi.hoisted(() => ({ +const { mockDbGet, mockDbRun, mockCheckSsrf, mockCacheGet, mockCacheGetErrored, mockCachePut, mockCacheGetInFlight, mockCacheSetInFlight } = vi.hoisted(() => ({ mockDbGet: vi.fn(() => undefined as any), mockDbRun: vi.fn(), mockCheckSsrf: vi.fn(async () => ({ allowed: true })), + mockCacheGet: vi.fn(() => null as any), + mockCacheGetErrored: vi.fn(() => false), + mockCachePut: vi.fn(async (placeId: string, _bytes: Buffer, attribution: string | null) => ({ + photoUrl: `/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`, + filePath: `/tmp/${placeId}.jpg`, + attribution, + })), + mockCacheGetInFlight: vi.fn(() => undefined), + mockCacheSetInFlight: vi.fn(), })); vi.mock('../../../src/db/database', () => ({ @@ -33,6 +42,16 @@ vi.mock('../../../src/config', () => ({ ENCRYPTION_KEY: '0'.repeat(64), })); +vi.mock('../../../src/services/placePhotoCache', () => ({ + get: (placeId: string) => mockCacheGet(placeId), + getErrored: (placeId: string) => mockCacheGetErrored(placeId), + put: (placeId: string, bytes: Buffer, attribution: string | null) => mockCachePut(placeId, bytes, attribution), + markError: vi.fn(), + getInFlight: (placeId: string) => mockCacheGetInFlight(placeId), + setInFlight: (placeId: string, p: Promise) => mockCacheSetInFlight(placeId, p), + serveFilePath: vi.fn(() => null), +})); + import { parseOpeningHours, buildOsmDetails, @@ -46,6 +65,19 @@ afterEach(() => { mockDbRun.mockReset(); mockCheckSsrf.mockReset(); mockCheckSsrf.mockResolvedValue({ allowed: true }); + mockCacheGet.mockReset(); + mockCacheGet.mockReturnValue(null); + mockCacheGetErrored.mockReset(); + mockCacheGetErrored.mockReturnValue(false); + mockCachePut.mockReset(); + mockCachePut.mockImplementation(async (placeId: string, _bytes: Buffer, attribution: string | null) => ({ + photoUrl: `/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`, + filePath: `/tmp/${placeId}.jpg`, + attribution, + })); + mockCacheGetInFlight.mockReset(); + mockCacheGetInFlight.mockReturnValue(undefined); + mockCacheSetInFlight.mockReset(); }); // ── parseOpeningHours ───────────────────────────────────────────────────────── @@ -995,11 +1027,9 @@ describe('getPlaceDetails (fetch stubbed)', () => { expect(place.rating_count).toBe(200000); expect(place.open_now).toBe(true); expect(place.source).toBe('google'); - expect(place.reviews).toHaveLength(1); - expect(place.reviews[0].author).toBe('John'); - expect(place.reviews[0].rating).toBe(5); - expect(place.reviews[0].text).toBe('Amazing!'); - expect(place.reviews[0].photo).toBe('https://photo.url'); + // Lean mask — reviews/summary not fetched in getPlaceDetails; use getPlaceDetailsExpanded for those + expect(place.reviews).toHaveLength(0); + expect(place.summary).toBeNull(); }); it('MAPS-041c: throws with status when Google API returns non-ok response', async () => { @@ -1016,8 +1046,10 @@ describe('getPlaceDetails (fetch stubbed)', () => { }); }); - it('MAPS-041d: maps reviews with optional fields absent to null', async () => { + it('MAPS-041d: getPlaceDetailsExpanded maps reviews with optional fields absent to null', async () => { mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' }); + // expanded=1 cache miss → return undefined + mockDbGet.mockReturnValueOnce(undefined); vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({ @@ -1028,8 +1060,8 @@ describe('getPlaceDetails (fetch stubbed)', () => { ], }), })); - const { getPlaceDetails } = await import('../../../src/services/mapsService'); - const result = await getPlaceDetails(1, 'ChIJ456'); + const { getPlaceDetailsExpanded } = await import('../../../src/services/mapsService'); + const result = await getPlaceDetailsExpanded(1, 'ChIJ456'); const review = (result.place as any).reviews[0]; expect(review.author).toBeNull(); expect(review.rating).toBeNull(); @@ -1104,8 +1136,10 @@ describe('getPlaceDetails (fetch stubbed)', () => { expect((result.place as any).open_now).toBe(false); }); - it('MAPS-041g: truncates reviews to first 5 entries', async () => { + it('MAPS-041g: getPlaceDetailsExpanded truncates reviews to first 5 entries', async () => { mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' }); + // expanded=1 cache miss + mockDbGet.mockReturnValueOnce(undefined); const manyReviews = Array.from({ length: 8 }, (_, i) => ({ authorAttribution: { displayName: `User${i}` }, rating: 4, @@ -1116,8 +1150,8 @@ describe('getPlaceDetails (fetch stubbed)', () => { ok: true, json: async () => ({ id: 'ChIJMany', reviews: manyReviews }), })); - const { getPlaceDetails } = await import('../../../src/services/mapsService'); - const result = await getPlaceDetails(1, 'ChIJMany'); + const { getPlaceDetailsExpanded } = await import('../../../src/services/mapsService'); + const result = await getPlaceDetailsExpanded(1, 'ChIJMany'); expect((result.place as any).reviews).toHaveLength(5); }); }); @@ -1125,16 +1159,26 @@ describe('getPlaceDetails (fetch stubbed)', () => { // ── getPlacePhoto (fetch stubbed) ──────────────────────────────────────────── describe('getPlacePhoto (fetch stubbed)', () => { - it('MAPS-042: returns Wikimedia photo for coordinate-based lookup (no API key)', async () => { - vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - query: { pages: { '1': { thumbnail: { source: 'https://wiki.org/photo.jpg' } } } }, - }), - })); + it('MAPS-042: returns proxy URL for coordinate-based lookup via Wikimedia (no API key)', async () => { + vi.stubGlobal('fetch', vi.fn() + // First call: Wikimedia Commons API + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + query: { pages: { '1': { thumbnail: { source: 'https://wiki.org/photo.jpg' } } } }, + }), + }) + // Second call: fetch Wikimedia image bytes + .mockResolvedValueOnce({ + ok: true, + arrayBuffer: async () => new ArrayBuffer(100), + }) + ); const { getPlacePhoto } = await import('../../../src/services/mapsService'); - const result = await getPlacePhoto(999, 'coords:48.8,2.3', 48.8, 2.3, 'Eiffel Tower'); - expect(result.photoUrl).toBe('https://wiki.org/photo.jpg'); + const placeId = 'coords:48.8,2.3'; + const result = await getPlacePhoto(999, placeId, 48.8, 2.3, 'Eiffel Tower'); + expect(result.photoUrl).toBe(`/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`); + expect(mockCachePut).toHaveBeenCalledOnce(); }); it('MAPS-043: throws 404 when Wikimedia returns nothing and no API key', async () => { @@ -1146,37 +1190,28 @@ describe('getPlacePhoto (fetch stubbed)', () => { await expect(getPlacePhoto(999, 'coords:0.0,0.0', 0, 0)).rejects.toMatchObject({ status: 404 }); }); - it('MAPS-043b: returns cached photo when cache entry is fresh and valid', async () => { - // First call populates cache; second call should use cache without fetching - vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - query: { pages: { '1': { thumbnail: { source: 'https://wiki.org/cached.jpg' } } } }, - }), - })); - const { getPlacePhoto } = await import('../../../src/services/mapsService'); - const uniqueId = `coords:cache-test-${Date.now()}`; - const first = await getPlacePhoto(999, uniqueId, 48.8, 2.3, 'Cache Test'); + it('MAPS-043b: returns cached photo when disk cache returns a hit', async () => { + const placeId = `coords:cache-test-${Date.now()}`; + const cachedUrl = `/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`; + mockCacheGet.mockReturnValue({ + photoUrl: cachedUrl, + filePath: `/tmp/${placeId}.jpg`, + attribution: null, + }); const fetchMock = vi.fn(); vi.stubGlobal('fetch', fetchMock); - const second = await getPlacePhoto(999, uniqueId, 48.8, 2.3, 'Cache Test'); - expect(second.photoUrl).toBe(first.photoUrl); + const { getPlacePhoto } = await import('../../../src/services/mapsService'); + const result = await getPlacePhoto(999, placeId, 48.8, 2.3, 'Cache Test'); + expect(result.photoUrl).toBe(cachedUrl); expect(fetchMock).not.toHaveBeenCalled(); }); - it('MAPS-043c: throws 404 from cache when cached entry is an error', async () => { - // Seed the cache with an error entry by triggering a no-result Wikimedia call - vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ query: { pages: {} } }), - })); - const { getPlacePhoto } = await import('../../../src/services/mapsService'); - const errorId = `coords:error-cache-${Date.now()}`; - // First call causes error to be cached - await expect(getPlacePhoto(999, errorId, 0, 0)).rejects.toMatchObject({ status: 404 }); - // Second call should throw directly from cache (no fetch) + it('MAPS-043c: throws 404 from error cache without making a network request', async () => { + mockCacheGetErrored.mockReturnValue(true); const fetchMock = vi.fn(); vi.stubGlobal('fetch', fetchMock); + const { getPlacePhoto } = await import('../../../src/services/mapsService'); + const errorId = `coords:error-cache-${Date.now()}`; await expect(getPlacePhoto(999, errorId, 0, 0)).rejects.toMatchObject({ status: 404 }); expect(fetchMock).not.toHaveBeenCalled(); }); @@ -1194,7 +1229,7 @@ describe('getPlacePhoto (fetch stubbed)', () => { await expect(getPlacePhoto(999, throwId, 48.8, 2.3, 'Place')).rejects.toMatchObject({ status: 404 }); }); - it('MAPS-044: returns photo via Google path when API key present and photos exist', async () => { + it('MAPS-044: returns proxy URL via Google path when API key present and photos exist', async () => { mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' }); const fetchMock = vi.fn() // First call: get place details (with photos) @@ -1204,17 +1239,18 @@ describe('getPlacePhoto (fetch stubbed)', () => { photos: [{ name: 'places/ChIJABC/photos/photo1', authorAttributions: [{ displayName: 'Photographer' }] }], }), }) - // Second call: get media URL + // Second call: fetch image bytes .mockResolvedValueOnce({ ok: true, - json: async () => ({ photoUri: 'https://lh3.googleusercontent.com/photo.jpg' }), + arrayBuffer: async () => new ArrayBuffer(200), }); vi.stubGlobal('fetch', fetchMock); const { getPlacePhoto } = await import('../../../src/services/mapsService'); const uniqueId = `ChIJABC-${Date.now()}`; const result = await getPlacePhoto(1, uniqueId, 48.8, 2.3, 'Place'); - expect(result.photoUrl).toBe('https://lh3.googleusercontent.com/photo.jpg'); + expect(result.photoUrl).toBe(`/api/maps/place-photo/${encodeURIComponent(uniqueId)}/bytes`); expect(result.attribution).toBe('Photographer'); + expect(mockCachePut).toHaveBeenCalledOnce(); }); it('MAPS-044b: throws 404 when Google details fetch returns non-ok', async () => { @@ -1240,7 +1276,7 @@ describe('getPlacePhoto (fetch stubbed)', () => { await expect(getPlacePhoto(1, noPhotoId, 48.8, 2.3)).rejects.toMatchObject({ status: 404 }); }); - it('MAPS-044d: throws 404 when media endpoint returns no photoUri', async () => { + it('MAPS-044d: throws 404 when media endpoint returns non-ok status', async () => { mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' }); const fetchMock = vi.fn() .mockResolvedValueOnce({ @@ -1250,8 +1286,9 @@ describe('getPlacePhoto (fetch stubbed)', () => { }), }) .mockResolvedValueOnce({ - ok: true, - json: async () => ({}), // no photoUri + ok: false, + status: 403, + arrayBuffer: async () => new ArrayBuffer(0), }); vi.stubGlobal('fetch', fetchMock); const { getPlacePhoto } = await import('../../../src/services/mapsService'); @@ -1259,7 +1296,7 @@ describe('getPlacePhoto (fetch stubbed)', () => { await expect(getPlacePhoto(1, noUriId, 48.8, 2.3)).rejects.toMatchObject({ status: 404 }); }); - it('MAPS-044e: returns photo with null attribution when authorAttributions is empty', async () => { + it('MAPS-044e: returns proxy URL with null attribution when authorAttributions is empty', async () => { mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' }); const fetchMock = vi.fn() .mockResolvedValueOnce({ @@ -1270,28 +1307,34 @@ describe('getPlacePhoto (fetch stubbed)', () => { }) .mockResolvedValueOnce({ ok: true, - json: async () => ({ photoUri: 'https://lh3.googleusercontent.com/noattr.jpg' }), + arrayBuffer: async () => new ArrayBuffer(150), }); vi.stubGlobal('fetch', fetchMock); const { getPlacePhoto } = await import('../../../src/services/mapsService'); const noAttrId = `ChIJNoAttr-${Date.now()}`; const result = await getPlacePhoto(1, noAttrId, 48.8, 2.3); - expect(result.photoUrl).toBe('https://lh3.googleusercontent.com/noattr.jpg'); + expect(result.photoUrl).toBe(`/api/maps/place-photo/${encodeURIComponent(noAttrId)}/bytes`); expect(result.attribution).toBeNull(); }); - it('MAPS-044f: uses Wikimedia when API key present but placeId is coords: prefix', async () => { + it('MAPS-044f: uses Wikimedia and returns proxy URL when API key present but placeId is coords: prefix', async () => { mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' }); - vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - query: { pages: { '1': { thumbnail: { source: 'https://wiki.org/coords-photo.jpg' } } } }, - }), - })); + vi.stubGlobal('fetch', vi.fn() + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + query: { pages: { '1': { thumbnail: { source: 'https://wiki.org/coords-photo.jpg' } } } }, + }), + }) + .mockResolvedValueOnce({ + ok: true, + arrayBuffer: async () => new ArrayBuffer(120), + }) + ); const { getPlacePhoto } = await import('../../../src/services/mapsService'); - // Use a unique placeId to avoid hitting the in-memory cache from other tests const uniqueId = `coords:44f-test-${Date.now()}`; const result = await getPlacePhoto(1, uniqueId, 48.8, 2.3, 'Coords Place'); - expect(result.photoUrl).toBe('https://wiki.org/coords-photo.jpg'); + expect(result.photoUrl).toBe(`/api/maps/place-photo/${encodeURIComponent(uniqueId)}/bytes`); + expect(mockCachePut).toHaveBeenCalledOnce(); }); });