diff --git a/client/src/components/Map/mapboxSetup.ts b/client/src/components/Map/mapboxSetup.ts index b3fc9071..a77fa2d0 100644 --- a/client/src/components/Map/mapboxSetup.ts +++ b/client/src/components/Map/mapboxSetup.ts @@ -8,13 +8,15 @@ export function isStandardFamily(style: string): boolean { return style === 'mapbox://styles/mapbox/standard' || style === 'mapbox://styles/mapbox/standard-satellite' } -// Terrain is only genuinely useful for the satellite imagery styles — on -// clean flat styles like streets/light/dark it nudges route lines onto -// the DEM while our HTML markers stay at Z=0, which causes the visible -// offset when the map is pitched. Restrict terrain to satellite. +// Terrain is only genuinely useful for styles that benefit from elevation +// data. On flat vector styles (streets/light/dark) it nudges route lines +// onto the DEM while HTML markers stay at Z=0, causing a visible drift +// when the map is pitched. Satellite and Outdoors are the intended styles +// for terrain; markers are re-pinned by syncMarkerAltitudes(). export function wantsTerrain(style: string): boolean { return style === 'mapbox://styles/mapbox/satellite-v9' || style === 'mapbox://styles/mapbox/satellite-streets-v12' + || style === 'mapbox://styles/mapbox/outdoors-v12' } // 3D can be added to every style now — the standard family has it built-in diff --git a/client/src/components/Planner/PlaceInspector.tsx b/client/src/components/Planner/PlaceInspector.tsx index 9e09f1ff..881ff253 100644 --- a/client/src/components/Planner/PlaceInspector.tsx +++ b/client/src/components/Planner/PlaceInspector.tsx @@ -169,7 +169,10 @@ export default function PlaceInspector({ const category = categories?.find(c => c.id === place.category_id) const dayAssignments = selectedDayId ? (assignments[String(selectedDayId)] || []) : [] - const assignmentInDay = selectedDayId ? dayAssignments.find(a => a.place?.id === place.id) : null + const assignmentInDay = selectedDayId + ? ((selectedAssignmentId ? dayAssignments.find(a => a.id === selectedAssignmentId) : null) + ?? dayAssignments.find(a => a.place?.id === place.id)) + : null const openingHours = googleDetails?.opening_hours || null const openNow = googleDetails?.open_now ?? null diff --git a/client/src/i18n/translations/ar.ts b/client/src/i18n/translations/ar.ts index 390b05c0..8a3313f0 100644 --- a/client/src/i18n/translations/ar.ts +++ b/client/src/i18n/translations/ar.ts @@ -1674,6 +1674,7 @@ const ar: Record = { 'journey.settings.failedToDelete': 'فشل في الحذف', 'journey.entries.deleteTitle': 'حذف الإدخال', 'journey.photosUploaded': 'تم رفع {count} صورة', + 'journey.photosUploadFailed': 'فشل رفع بعض الصور', 'journey.photosAdded': 'تمت إضافة {count} صورة', 'journey.picker.tripPeriod': 'فترة الرحلة', 'journey.picker.dateRange': 'نطاق التاريخ', @@ -1705,6 +1706,7 @@ const ar: Record = { // Journey Entry Editor 'journey.editor.discardChangesConfirm': 'لديك تغييرات غير محفوظة. هل تريد تجاهلها؟', + 'journey.editor.uploadFailed': 'فشل رفع الصور', 'journey.editor.uploadPhotos': 'رفع صور', 'journey.editor.uploading': '...جارٍ الرفع', 'journey.editor.fromGallery': 'من المعرض', diff --git a/client/src/i18n/translations/br.ts b/client/src/i18n/translations/br.ts index 0757c3d2..cf73f2fa 100644 --- a/client/src/i18n/translations/br.ts +++ b/client/src/i18n/translations/br.ts @@ -2077,6 +2077,7 @@ const br: Record = { 'journey.synced.places': 'lugares', 'journey.synced.synced': 'sincronizado', 'journey.editor.discardChangesConfirm': 'Você tem alterações não salvas. Descartá-las?', + 'journey.editor.uploadFailed': 'Falha ao enviar fotos', 'journey.editor.uploadPhotos': 'Enviar fotos', 'journey.editor.uploading': 'Enviando...', 'journey.editor.fromGallery': 'Da galeria', @@ -2169,6 +2170,7 @@ const br: Record = { 'journey.settings.failedToDelete': 'Falha ao excluir', 'journey.entries.deleteTitle': 'Excluir entrada', 'journey.photosUploaded': '{count} fotos enviadas', + 'journey.photosUploadFailed': 'Algumas fotos não foram enviadas', 'journey.photosAdded': '{count} fotos adicionadas', 'journey.public.notFound': 'Não encontrado', 'journey.public.notFoundMessage': 'Esta jornada não existe ou o link expirou.', diff --git a/client/src/i18n/translations/cs.ts b/client/src/i18n/translations/cs.ts index a14b633d..cefbf21b 100644 --- a/client/src/i18n/translations/cs.ts +++ b/client/src/i18n/translations/cs.ts @@ -2082,6 +2082,7 @@ const cs: Record = { 'journey.synced.places': 'místa', 'journey.synced.synced': 'synchronizováno', 'journey.editor.discardChangesConfirm': 'Máte neuložené změny. Zahodit?', + 'journey.editor.uploadFailed': 'Nahrávání fotek selhalo', 'journey.editor.uploadPhotos': 'Nahrát fotky', 'journey.editor.uploading': 'Nahrávání...', 'journey.editor.fromGallery': 'Z galerie', @@ -2174,6 +2175,7 @@ const cs: Record = { 'journey.settings.failedToDelete': 'Smazání se nezdařilo', 'journey.entries.deleteTitle': 'Smazat záznam', 'journey.photosUploaded': '{count} fotografií nahráno', + 'journey.photosUploadFailed': 'Některé fotky se nepodařilo nahrát', 'journey.photosAdded': '{count} fotografií přidáno', 'journey.public.notFound': 'Nenalezeno', 'journey.public.notFoundMessage': 'Tento cestovní deník neexistuje nebo odkaz vypršel.', diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index cbb6d153..d287a4aa 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -2085,6 +2085,7 @@ const de: Record = { 'journey.synced.places': 'Orte', 'journey.synced.synced': 'synchronisiert', 'journey.editor.discardChangesConfirm': 'Du hast ungespeicherte Änderungen. Verwerfen?', + 'journey.editor.uploadFailed': 'Foto-Upload fehlgeschlagen', 'journey.editor.uploadPhotos': 'Fotos hochladen', 'journey.editor.uploading': 'Hochladen...', 'journey.editor.fromGallery': 'Aus Galerie', @@ -2181,6 +2182,7 @@ const de: Record = { 'journey.settings.failedToDelete': 'Löschen fehlgeschlagen', 'journey.entries.deleteTitle': 'Eintrag löschen', 'journey.photosUploaded': '{count} Fotos hochgeladen', + 'journey.photosUploadFailed': 'Einige Fotos konnten nicht hochgeladen werden', 'journey.photosAdded': '{count} Fotos hinzugefügt', 'journey.public.notFound': 'Nicht gefunden', 'journey.public.notFoundMessage': 'Diese Journey existiert nicht oder der Link ist abgelaufen.', diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index ce8321a6..2f0bfc71 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -2111,6 +2111,7 @@ const en: Record = { // Journey Entry Editor 'journey.editor.discardChangesConfirm': 'You have unsaved changes. Discard them?', + 'journey.editor.uploadFailed': 'Photo upload failed', 'journey.editor.uploadPhotos': 'Upload photos', 'journey.editor.uploading': 'Uploading...', 'journey.editor.fromGallery': 'From Gallery', @@ -2219,6 +2220,7 @@ const en: Record = { 'journey.settings.failedToDelete': 'Failed to delete', 'journey.entries.deleteTitle': 'Delete Entry', 'journey.photosUploaded': '{count} photos uploaded', + 'journey.photosUploadFailed': 'Some photos failed to upload', 'journey.photosAdded': '{count} photos added', // Journey — Public Page diff --git a/client/src/i18n/translations/es.ts b/client/src/i18n/translations/es.ts index a66bdfb6..b9b93dc7 100644 --- a/client/src/i18n/translations/es.ts +++ b/client/src/i18n/translations/es.ts @@ -2084,6 +2084,7 @@ const es: Record = { 'journey.synced.places': 'lugares', 'journey.synced.synced': 'sincronizado', 'journey.editor.discardChangesConfirm': 'Tienes cambios sin guardar. ¿Descartarlos?', + 'journey.editor.uploadFailed': 'Error al subir fotos', 'journey.editor.uploadPhotos': 'Subir fotos', 'journey.editor.uploading': 'Subiendo...', 'journey.editor.fromGallery': 'Desde galería', @@ -2176,6 +2177,7 @@ const es: Record = { 'journey.settings.failedToDelete': 'Error al eliminar', 'journey.entries.deleteTitle': 'Eliminar entrada', 'journey.photosUploaded': '{count} fotos subidas', + 'journey.photosUploadFailed': 'Algunas fotos no se pudieron subir', 'journey.photosAdded': '{count} fotos añadidas', 'journey.public.notFound': 'No encontrado', 'journey.public.notFoundMessage': 'Esta travesía no existe o el enlace ha expirado.', diff --git a/client/src/i18n/translations/fr.ts b/client/src/i18n/translations/fr.ts index c7cd1605..bb84a30b 100644 --- a/client/src/i18n/translations/fr.ts +++ b/client/src/i18n/translations/fr.ts @@ -2078,6 +2078,7 @@ const fr: Record = { 'journey.synced.places': 'lieux', 'journey.synced.synced': 'synchronisé', 'journey.editor.discardChangesConfirm': 'Vous avez des modifications non enregistrées. Les ignorer ?', + 'journey.editor.uploadFailed': 'Échec du téléversement des photos', 'journey.editor.uploadPhotos': 'Téléverser des photos', 'journey.editor.uploading': 'Envoi...', 'journey.editor.fromGallery': 'Depuis la galerie', @@ -2170,6 +2171,7 @@ const fr: Record = { 'journey.settings.failedToDelete': 'Échec de la suppression', 'journey.entries.deleteTitle': "Supprimer l'entrée", 'journey.photosUploaded': '{count} photos téléversées', + 'journey.photosUploadFailed': "Certaines photos n'ont pas pu être téléversées", 'journey.photosAdded': '{count} photos ajoutées', 'journey.public.notFound': 'Introuvable', 'journey.public.notFoundMessage': 'Ce journal n\'existe pas ou le lien a expiré.', diff --git a/client/src/i18n/translations/hu.ts b/client/src/i18n/translations/hu.ts index f8046fab..bfae8e1a 100644 --- a/client/src/i18n/translations/hu.ts +++ b/client/src/i18n/translations/hu.ts @@ -2079,6 +2079,7 @@ const hu: Record = { 'journey.synced.places': 'helyszín', 'journey.synced.synced': 'szinkronizálva', 'journey.editor.discardChangesConfirm': 'Mentetlen módosításaid vannak. Elveted?', + 'journey.editor.uploadFailed': 'A fotók feltöltése sikertelen', 'journey.editor.uploadPhotos': 'Fotók feltöltése', 'journey.editor.uploading': 'Feltöltés...', 'journey.editor.fromGallery': 'Galériából', @@ -2171,6 +2172,7 @@ const hu: Record = { 'journey.settings.failedToDelete': 'Törlés sikertelen', 'journey.entries.deleteTitle': 'Bejegyzés törlése', 'journey.photosUploaded': '{count} fotó feltöltve', + 'journey.photosUploadFailed': 'Néhány fotót nem sikerült feltölteni', 'journey.photosAdded': '{count} fotó hozzáadva', 'journey.public.notFound': 'Nem található', 'journey.public.notFoundMessage': 'Ez az útinapló nem létezik vagy a link lejárt.', diff --git a/client/src/i18n/translations/id.ts b/client/src/i18n/translations/id.ts index 112d17fc..a8b80d04 100644 --- a/client/src/i18n/translations/id.ts +++ b/client/src/i18n/translations/id.ts @@ -2094,6 +2094,7 @@ const id: Record = { // Journey Entry Editor 'journey.editor.discardChangesConfirm': 'Anda memiliki perubahan yang belum disimpan. Buang?', + 'journey.editor.uploadFailed': 'Gagal mengunggah foto', 'journey.editor.uploadPhotos': 'Unggah foto', 'journey.editor.uploading': 'Mengunggah...', 'journey.editor.fromGallery': 'Dari Galeri', @@ -2198,6 +2199,7 @@ const id: Record = { 'journey.settings.failedToDelete': 'Gagal menghapus', 'journey.entries.deleteTitle': 'Hapus Entri', 'journey.photosUploaded': '{count} foto diunggah', + 'journey.photosUploadFailed': 'Beberapa foto gagal diunggah', 'journey.photosAdded': '{count} foto ditambahkan', // Journey — Public Page diff --git a/client/src/i18n/translations/it.ts b/client/src/i18n/translations/it.ts index 2ac5424f..21569ed9 100644 --- a/client/src/i18n/translations/it.ts +++ b/client/src/i18n/translations/it.ts @@ -2079,6 +2079,7 @@ const it: Record = { 'journey.synced.places': 'luoghi', 'journey.synced.synced': 'sincronizzato', 'journey.editor.discardChangesConfirm': 'Hai modifiche non salvate. Vuoi scartarle?', + 'journey.editor.uploadFailed': 'Caricamento foto non riuscito', 'journey.editor.uploadPhotos': 'Carica foto', 'journey.editor.uploading': 'Caricamento...', 'journey.editor.fromGallery': 'Dalla galleria', @@ -2171,6 +2172,7 @@ const it: Record = { 'journey.settings.failedToDelete': 'Eliminazione non riuscita', 'journey.entries.deleteTitle': 'Elimina voce', 'journey.photosUploaded': '{count} foto caricate', + 'journey.photosUploadFailed': 'Alcune foto non sono state caricate', 'journey.photosAdded': '{count} foto aggiunte', 'journey.public.notFound': 'Non trovato', 'journey.public.notFoundMessage': 'Questo diario non esiste o il link è scaduto.', diff --git a/client/src/i18n/translations/nl.ts b/client/src/i18n/translations/nl.ts index 0cb55bc1..289f2ae9 100644 --- a/client/src/i18n/translations/nl.ts +++ b/client/src/i18n/translations/nl.ts @@ -2078,6 +2078,7 @@ const nl: Record = { 'journey.synced.places': 'plaatsen', 'journey.synced.synced': 'gesynchroniseerd', 'journey.editor.discardChangesConfirm': 'Je hebt niet-opgeslagen wijzigingen. Verwerpen?', + 'journey.editor.uploadFailed': 'Foto uploaden mislukt', 'journey.editor.uploadPhotos': 'Foto\'s uploaden', 'journey.editor.uploading': 'Uploaden...', 'journey.editor.fromGallery': 'Uit galerij', @@ -2170,6 +2171,7 @@ const nl: Record = { 'journey.settings.failedToDelete': 'Verwijderen mislukt', 'journey.entries.deleteTitle': 'Vermelding verwijderen', 'journey.photosUploaded': "{count} foto's geüpload", + 'journey.photosUploadFailed': "Sommige foto's konden niet worden geüpload", 'journey.photosAdded': "{count} foto's toegevoegd", 'journey.public.notFound': 'Niet gevonden', 'journey.public.notFoundMessage': 'Dit reisverslag bestaat niet of de link is verlopen.', diff --git a/client/src/i18n/translations/pl.ts b/client/src/i18n/translations/pl.ts index 87f768a9..b0c53c00 100644 --- a/client/src/i18n/translations/pl.ts +++ b/client/src/i18n/translations/pl.ts @@ -2071,6 +2071,7 @@ const pl: Record = { 'journey.synced.places': 'miejsca', 'journey.synced.synced': 'zsynchronizowane', 'journey.editor.discardChangesConfirm': 'Masz niezapisane zmiany. Odrzucić?', + 'journey.editor.uploadFailed': 'Przesyłanie zdjęć nie powiodło się', 'journey.editor.uploadPhotos': 'Prześlij zdjęcia', 'journey.editor.uploading': 'Przesyłanie...', 'journey.editor.fromGallery': 'Z galerii', @@ -2163,6 +2164,7 @@ const pl: Record = { 'journey.settings.failedToDelete': 'Nie udało się usunąć', 'journey.entries.deleteTitle': 'Usuń wpis', 'journey.photosUploaded': '{count} zdjęć przesłanych', + 'journey.photosUploadFailed': 'Nie udało się przesłać niektórych zdjęć', 'journey.photosAdded': '{count} zdjęć dodanych', 'journey.public.notFound': 'Nie znaleziono', 'journey.public.notFoundMessage': 'Ten dziennik podróży nie istnieje lub link wygasł.', diff --git a/client/src/i18n/translations/ru.ts b/client/src/i18n/translations/ru.ts index f4f23fb8..3384f353 100644 --- a/client/src/i18n/translations/ru.ts +++ b/client/src/i18n/translations/ru.ts @@ -2078,6 +2078,7 @@ const ru: Record = { 'journey.synced.places': 'мест', 'journey.synced.synced': 'синхронизировано', 'journey.editor.discardChangesConfirm': 'У вас есть несохранённые изменения. Отменить?', + 'journey.editor.uploadFailed': 'Не удалось загрузить фото', 'journey.editor.uploadPhotos': 'Загрузить фото', 'journey.editor.uploading': 'Загрузка...', 'journey.editor.fromGallery': 'Из галереи', @@ -2170,6 +2171,7 @@ const ru: Record = { 'journey.settings.failedToDelete': 'Не удалось удалить', 'journey.entries.deleteTitle': 'Удалить запись', 'journey.photosUploaded': '{count} фото загружено', + 'journey.photosUploadFailed': 'Некоторые фото не удалось загрузить', 'journey.photosAdded': '{count} фото добавлено', 'journey.public.notFound': 'Не найдено', 'journey.public.notFoundMessage': 'Это путешествие не существует или ссылка устарела.', diff --git a/client/src/i18n/translations/zh.ts b/client/src/i18n/translations/zh.ts index ffa564b6..6549509a 100644 --- a/client/src/i18n/translations/zh.ts +++ b/client/src/i18n/translations/zh.ts @@ -2078,6 +2078,7 @@ const zh: Record = { 'journey.synced.places': '个地点', 'journey.synced.synced': '已同步', 'journey.editor.discardChangesConfirm': '您有未保存的更改。要放弃吗?', + 'journey.editor.uploadFailed': '照片上传失败', 'journey.editor.uploadPhotos': '上传照片', 'journey.editor.uploading': '上传中...', 'journey.editor.fromGallery': '从相册', @@ -2170,6 +2171,7 @@ const zh: Record = { 'journey.settings.failedToDelete': '删除失败', 'journey.entries.deleteTitle': '删除条目', 'journey.photosUploaded': '{count} 张照片已上传', + 'journey.photosUploadFailed': '部分照片上传失败', 'journey.photosAdded': '{count} 张照片已添加', 'journey.public.notFound': '未找到', 'journey.public.notFoundMessage': '此旅程不存在或链接已过期。', diff --git a/client/src/i18n/translations/zhTw.ts b/client/src/i18n/translations/zhTw.ts index 331596c5..00385ad2 100644 --- a/client/src/i18n/translations/zhTw.ts +++ b/client/src/i18n/translations/zhTw.ts @@ -2036,6 +2036,7 @@ const zhTw: Record = { 'journey.synced.places': '個地點', 'journey.synced.synced': '已同步', 'journey.editor.discardChangesConfirm': '您有未儲存的變更。要放棄嗎?', + 'journey.editor.uploadFailed': '照片上傳失敗', 'journey.editor.uploadPhotos': '上傳照片', 'journey.editor.uploading': '上傳中...', 'journey.editor.fromGallery': '從相簿', @@ -2128,6 +2129,7 @@ const zhTw: Record = { 'journey.settings.failedToDelete': '刪除失敗', 'journey.entries.deleteTitle': '刪除條目', 'journey.photosUploaded': '{count} 張照片已上傳', + 'journey.photosUploadFailed': '部分照片上傳失敗', 'journey.photosAdded': '{count} 張照片已新增', 'journey.public.notFound': '未找到', 'journey.public.notFoundMessage': '此旅程不存在或連結已過期。', diff --git a/client/src/pages/JourneyDetailPage.tsx b/client/src/pages/JourneyDetailPage.tsx index 360247e7..fcd54545 100644 --- a/client/src/pages/JourneyDetailPage.tsx +++ b/client/src/pages/JourneyDetailPage.tsx @@ -30,6 +30,7 @@ import MobileEntryView from '../components/Journey/MobileEntryView' import { useIsMobile } from '../hooks/useIsMobile' import type { JourneyEntry, JourneyPhoto, GalleryPhoto, JourneyTrip, JourneyDetail } from '../store/journeyStore' import { computeJourneyLifecycle } from '../utils/journeyLifecycle' +import { getApiErrorMessage } from '../types' const GRADIENTS = [ 'linear-gradient(135deg, #0F172A 0%, #6366F1 45%, #EC4899 100%)', @@ -1034,8 +1035,8 @@ function GalleryView({ entries, gallery, journeyId, userId, trips, onPhotoClick, await journeyApi.uploadGalleryPhotos(journeyId, formData) toast.success(t('journey.photosUploaded', { count: files.length })) onRefresh() - } catch { - toast.error(t('journey.settings.coverFailed')) + } catch (err) { + toast.error(getApiErrorMessage(err, t('journey.photosUploadFailed'))) } finally { setGalleryUploading(false) } @@ -2175,6 +2176,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa onDone: () => void }) { const { t } = useTranslation() + const toast = useToast() const isMobile = useIsMobile() const [title, setTitle] = useState(entry.title || '') const [story, setStory] = useState(entry.story || '') @@ -2248,7 +2250,11 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa if (pendingFiles.length > 0 && entryId) { const formData = new FormData() for (const f of pendingFiles) formData.append('photos', f) - await onUploadPhotos(entryId, formData) + try { + await onUploadPhotos(entryId, formData) + } catch (err) { + toast.error(getApiErrorMessage(err, t('journey.editor.uploadFailed'))) + } } // link gallery photos that were picked before save if (pendingLinkIds.length > 0 && entryId) { diff --git a/server/src/app.ts b/server/src/app.ts index 0be79570..45d17b7d 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -5,6 +5,7 @@ import cookieParser from 'cookie-parser'; import path from 'node:path'; import fs from 'node:fs'; +import multer from 'multer'; import { logDebug, logWarn, logError } from './services/auditLog'; import { enforceGlobalMfaPolicy } from './middleware/mfaPolicy'; import { authenticate, verifyJwtAndLoadUser } from './middleware/auth'; @@ -507,6 +508,10 @@ export function createApp(): express.Application { } else { console.error('Unhandled error:', err); } + if (err instanceof multer.MulterError) { + const status = err.code === 'LIMIT_FILE_SIZE' ? 413 : 400; + return res.status(status).json({ error: err.message }); + } const status = err.statusCode || err.status || 500; // Expose the message for client errors (4xx); keep 'Internal server error' for 5xx. const message = status < 500 ? err.message : 'Internal server error'; diff --git a/server/src/routes/journey.ts b/server/src/routes/journey.ts index 1336bd50..b655d7ce 100644 --- a/server/src/routes/journey.ts +++ b/server/src/routes/journey.ts @@ -98,7 +98,7 @@ router.delete('/entries/:entryId', authenticate, (req: Request, res: Response) = // ── Photos (prefix /photos and /entries — before /:id) ─────────────────── -router.post('/entries/:entryId/photos', authenticate, upload.array('photos', 10), async (req: Request, res: Response) => { +router.post('/entries/:entryId/photos', authenticate, upload.array('photos'), async (req: Request, res: Response) => { const authReq = req as AuthRequest; const files = req.files as Express.Multer.File[]; if (!files?.length) return res.status(400).json({ error: 'No files uploaded' }); @@ -201,7 +201,7 @@ router.delete('/photos/:photoId', authenticate, async (req: Request, res: Respon // ── Gallery (prefix /:id/gallery — before /:id) ────────────────────────── // Upload photos directly to the journey gallery (no entry association) -router.post('/:id/gallery/photos', authenticate, upload.array('photos', 20), async (req: Request, res: Response) => { +router.post('/:id/gallery/photos', authenticate, upload.array('photos'), async (req: Request, res: Response) => { const authReq = req as AuthRequest; const files = req.files as Express.Multer.File[]; if (!files?.length) return res.status(400).json({ error: 'No files uploaded' }); diff --git a/server/src/services/mapsService.ts b/server/src/services/mapsService.ts index 00151fe2..c336135c 100644 --- a/server/src/services/mapsService.ts +++ b/server/src/services/mapsService.ts @@ -1,6 +1,7 @@ import { db } from '../db/database'; import { decrypt_api_key } from './apiKeyCrypto'; import { checkSsrf } from '../utils/ssrfGuard'; +import { getAppUrl } from './notifications'; // ── Google API call counter ─────────────────────────────────────────────────── @@ -12,7 +13,11 @@ 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); + const referer = process.env.APP_URL ? getAppUrl() : undefined; + return fetch(endpoint, { + ...init, + headers: { ...(referer ? { Referer: referer } : {}), ...(init?.headers as Record ?? {}) }, + }); } // ── Interfaces ─────────────────────────────────────────────────────────────── diff --git a/wiki/Places-and-Search.md b/wiki/Places-and-Search.md index efc49453..8a336294 100644 --- a/wiki/Places-and-Search.md +++ b/wiki/Places-and-Search.md @@ -21,6 +21,8 @@ Type in the search box at the top of the form. After 2 or more characters, with When a key is present, the autocomplete uses the Google Places API, which can return ratings, opening hours, photos, and phone numbers from Google's database. +> **API key restrictions:** TREK calls the Google Places API from the server, not the browser. If you apply **HTTP referrers** restrictions to your key in Google Cloud Console, you must also set `APP_URL` in your environment — TREK sends it as the `Referer` header on every outbound Google API request. Without it, Google will reject all server-side calls with `REQUEST_DENIED`. For server-side deployments, **IP address** restrictions are simpler and require no extra configuration. See [Troubleshooting](Troubleshooting) if photos are missing after adding a key. + ### Without a Google Maps API key TREK falls back to OpenStreetMap (Nominatim) automatically — no API key needed. A notice appears above the search box explaining that OpenStreetMap is in use and that photos, ratings, and opening hours are unavailable. Results include name, address, and coordinates. diff --git a/wiki/Troubleshooting.md b/wiki/Troubleshooting.md index 7b7a6d03..dae924c8 100644 --- a/wiki/Troubleshooting.md +++ b/wiki/Troubleshooting.md @@ -223,6 +223,45 @@ If `ALLOWED_ORIGINS` is not set, TREK allows all origins (development default). --- +## Place photos not loading / place thumbnail shows default map pin (Google Maps API key configured) + +**Cause:** When a Google Maps API key is set, TREK fetches photo references and image bytes from the Google Places API on the server side. If the server-side call is rejected or returns no photos, the `/place-photo/:id` endpoint returns 404 and the place falls back to the default map-pin thumbnail. The most common causes are: + +1. **HTTP referrer restriction on the API key.** Google Cloud Console lets you restrict a key to specific HTTP referrers. Because TREK calls Google from the server (not the browser), it sends a `Referer` header derived from `APP_URL`. If `APP_URL` is not set, the fallback is `http://localhost:`, which will not match any domain whitelist in GCP. + +2. **Wrong key restriction type.** API keys restricted by **HTTP referrers** are designed for browser-side JavaScript. For a self-hosted server application, use **IP address** restrictions instead — add the public IP of your TREK server and no `APP_URL` configuration is needed. + +3. **Places API (New) not enabled.** The key must have **Places API (New)** enabled in Google Cloud Console under APIs & Services → Enabled APIs. Enabling only the legacy Places API is not sufficient. + +4. **Billing not set up.** Google requires a billing account to be linked to the project even within the free tier. Without it, photo and details requests return `REQUEST_DENIED`. + +**Fix for HTTP referrer restriction:** + +Set `APP_URL` to the public URL of your instance and add that URL (or its domain with a wildcard, e.g. `https://trek.example.com/*`) to the allowed referrers in GCP: + +```yaml +environment: + - APP_URL=https://trek.example.com +``` + +**Fix for wrong restriction type:** + +Switch the key's "Application restrictions" from **HTTP referrers** to **IP addresses** in Google Cloud Console, and add your server's public IP. No `APP_URL` change needed. + +**Verifying the issue:** + +Run the following curl command using your key to check whether Google returns photo references: + +```bash +curl "https://places.googleapis.com/v1/places/" \ + -H "X-Goog-Api-Key: YOUR_API_KEY" \ + -H "X-Goog-FieldMask: photos" +``` + +If the response is `{}` or `{"error": {...}}`, the key or its restrictions are blocking the request. If it returns a `photos` array, the key is valid and the issue is elsewhere. + +--- + ## MCP OAuth flow does not initiate / "Connect" redirects but authentication never starts **Cause:** TREK builds the OAuth 2.1 redirect URI from `APP_URL`. If `APP_URL` is not set, the authorization URL is constructed from a localhost fallback that external clients (Claude.ai, Claude Desktop) cannot reach, so the OAuth handshake never completes.