v3.0.21 Bug Fixes (#998)

* fix(journey): remove photo upload count limit and surface upload errors (#997)

Removes the arbitrary 10-file cap on journey entry photo uploads and 20-file
cap on gallery uploads. MulterErrors now return proper 4xx responses instead
of 500, and the client surfaces the server error message via toast rather than
silently trapping the user in the post editor overlay.

* fix(planner): remove correct assignment when place assigned to same day multiple times

When a place was assigned to the same day more than once, the "Remove from day"
button in PlaceInspector always deleted the first assignment (Array.find on
place.id) instead of the currently selected one. Now prefers selectedAssignmentId
when available.

Fixes #1005

* fix(map): enable 3D terrain for Mapbox outdoors style in trip planner

wantsTerrain() only matched satellite styles, so the outdoors-v12 style
was flat in the planner despite showing correct 3D terrain in the settings
preview. Added outdoors-v12 to the allowlist; marker drift is already
handled by syncMarkerAltitudes().

Fixes #1002

* fix(maps): send Referer header on Google API calls when APP_URL is set

Supports HTTP referrer restrictions on GCP API keys. Documents the
restriction types and photo troubleshooting steps in the wiki.
This commit is contained in:
Julien G.
2026-05-16 00:53:02 +02:00
committed by GitHub
parent e7211325df
commit 117942f45e
23 changed files with 103 additions and 11 deletions
+6 -4
View File
@@ -8,13 +8,15 @@ export function isStandardFamily(style: string): boolean {
return style === 'mapbox://styles/mapbox/standard' || style === 'mapbox://styles/mapbox/standard-satellite' return style === 'mapbox://styles/mapbox/standard' || style === 'mapbox://styles/mapbox/standard-satellite'
} }
// Terrain is only genuinely useful for the satellite imagery styles — on // Terrain is only genuinely useful for styles that benefit from elevation
// clean flat styles like streets/light/dark it nudges route lines onto // data. On flat vector styles (streets/light/dark) it nudges route lines
// the DEM while our HTML markers stay at Z=0, which causes the visible // onto the DEM while HTML markers stay at Z=0, causing a visible drift
// offset when the map is pitched. Restrict terrain to satellite. // 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 { export function wantsTerrain(style: string): boolean {
return style === 'mapbox://styles/mapbox/satellite-v9' return style === 'mapbox://styles/mapbox/satellite-v9'
|| style === 'mapbox://styles/mapbox/satellite-streets-v12' || 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 // 3D can be added to every style now — the standard family has it built-in
@@ -169,7 +169,10 @@ export default function PlaceInspector({
const category = categories?.find(c => c.id === place.category_id) const category = categories?.find(c => c.id === place.category_id)
const dayAssignments = selectedDayId ? (assignments[String(selectedDayId)] || []) : [] 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 openingHours = googleDetails?.opening_hours || null
const openNow = googleDetails?.open_now ?? null const openNow = googleDetails?.open_now ?? null
+2
View File
@@ -1674,6 +1674,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'journey.settings.failedToDelete': 'فشل في الحذف', 'journey.settings.failedToDelete': 'فشل في الحذف',
'journey.entries.deleteTitle': 'حذف الإدخال', 'journey.entries.deleteTitle': 'حذف الإدخال',
'journey.photosUploaded': 'تم رفع {count} صورة', 'journey.photosUploaded': 'تم رفع {count} صورة',
'journey.photosUploadFailed': 'فشل رفع بعض الصور',
'journey.photosAdded': 'تمت إضافة {count} صورة', 'journey.photosAdded': 'تمت إضافة {count} صورة',
'journey.picker.tripPeriod': 'فترة الرحلة', 'journey.picker.tripPeriod': 'فترة الرحلة',
'journey.picker.dateRange': 'نطاق التاريخ', 'journey.picker.dateRange': 'نطاق التاريخ',
@@ -1705,6 +1706,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
// Journey Entry Editor // Journey Entry Editor
'journey.editor.discardChangesConfirm': 'لديك تغييرات غير محفوظة. هل تريد تجاهلها؟', 'journey.editor.discardChangesConfirm': 'لديك تغييرات غير محفوظة. هل تريد تجاهلها؟',
'journey.editor.uploadFailed': 'فشل رفع الصور',
'journey.editor.uploadPhotos': 'رفع صور', 'journey.editor.uploadPhotos': 'رفع صور',
'journey.editor.uploading': '...جارٍ الرفع', 'journey.editor.uploading': '...جارٍ الرفع',
'journey.editor.fromGallery': 'من المعرض', 'journey.editor.fromGallery': 'من المعرض',
+2
View File
@@ -2077,6 +2077,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'journey.synced.places': 'lugares', 'journey.synced.places': 'lugares',
'journey.synced.synced': 'sincronizado', 'journey.synced.synced': 'sincronizado',
'journey.editor.discardChangesConfirm': 'Você tem alterações não salvas. Descartá-las?', '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.uploadPhotos': 'Enviar fotos',
'journey.editor.uploading': 'Enviando...', 'journey.editor.uploading': 'Enviando...',
'journey.editor.fromGallery': 'Da galeria', 'journey.editor.fromGallery': 'Da galeria',
@@ -2169,6 +2170,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'journey.settings.failedToDelete': 'Falha ao excluir', 'journey.settings.failedToDelete': 'Falha ao excluir',
'journey.entries.deleteTitle': 'Excluir entrada', 'journey.entries.deleteTitle': 'Excluir entrada',
'journey.photosUploaded': '{count} fotos enviadas', 'journey.photosUploaded': '{count} fotos enviadas',
'journey.photosUploadFailed': 'Algumas fotos não foram enviadas',
'journey.photosAdded': '{count} fotos adicionadas', 'journey.photosAdded': '{count} fotos adicionadas',
'journey.public.notFound': 'Não encontrado', 'journey.public.notFound': 'Não encontrado',
'journey.public.notFoundMessage': 'Esta jornada não existe ou o link expirou.', 'journey.public.notFoundMessage': 'Esta jornada não existe ou o link expirou.',
+2
View File
@@ -2082,6 +2082,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'journey.synced.places': 'místa', 'journey.synced.places': 'místa',
'journey.synced.synced': 'synchronizováno', 'journey.synced.synced': 'synchronizováno',
'journey.editor.discardChangesConfirm': 'Máte neuložené změny. Zahodit?', '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.uploadPhotos': 'Nahrát fotky',
'journey.editor.uploading': 'Nahrávání...', 'journey.editor.uploading': 'Nahrávání...',
'journey.editor.fromGallery': 'Z galerie', 'journey.editor.fromGallery': 'Z galerie',
@@ -2174,6 +2175,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'journey.settings.failedToDelete': 'Smazání se nezdařilo', 'journey.settings.failedToDelete': 'Smazání se nezdařilo',
'journey.entries.deleteTitle': 'Smazat záznam', 'journey.entries.deleteTitle': 'Smazat záznam',
'journey.photosUploaded': '{count} fotografií nahráno', 'journey.photosUploaded': '{count} fotografií nahráno',
'journey.photosUploadFailed': 'Některé fotky se nepodařilo nahrát',
'journey.photosAdded': '{count} fotografií přidáno', 'journey.photosAdded': '{count} fotografií přidáno',
'journey.public.notFound': 'Nenalezeno', 'journey.public.notFound': 'Nenalezeno',
'journey.public.notFoundMessage': 'Tento cestovní deník neexistuje nebo odkaz vypršel.', 'journey.public.notFoundMessage': 'Tento cestovní deník neexistuje nebo odkaz vypršel.',
+2
View File
@@ -2085,6 +2085,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'journey.synced.places': 'Orte', 'journey.synced.places': 'Orte',
'journey.synced.synced': 'synchronisiert', 'journey.synced.synced': 'synchronisiert',
'journey.editor.discardChangesConfirm': 'Du hast ungespeicherte Änderungen. Verwerfen?', 'journey.editor.discardChangesConfirm': 'Du hast ungespeicherte Änderungen. Verwerfen?',
'journey.editor.uploadFailed': 'Foto-Upload fehlgeschlagen',
'journey.editor.uploadPhotos': 'Fotos hochladen', 'journey.editor.uploadPhotos': 'Fotos hochladen',
'journey.editor.uploading': 'Hochladen...', 'journey.editor.uploading': 'Hochladen...',
'journey.editor.fromGallery': 'Aus Galerie', 'journey.editor.fromGallery': 'Aus Galerie',
@@ -2181,6 +2182,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'journey.settings.failedToDelete': 'Löschen fehlgeschlagen', 'journey.settings.failedToDelete': 'Löschen fehlgeschlagen',
'journey.entries.deleteTitle': 'Eintrag löschen', 'journey.entries.deleteTitle': 'Eintrag löschen',
'journey.photosUploaded': '{count} Fotos hochgeladen', 'journey.photosUploaded': '{count} Fotos hochgeladen',
'journey.photosUploadFailed': 'Einige Fotos konnten nicht hochgeladen werden',
'journey.photosAdded': '{count} Fotos hinzugefügt', 'journey.photosAdded': '{count} Fotos hinzugefügt',
'journey.public.notFound': 'Nicht gefunden', 'journey.public.notFound': 'Nicht gefunden',
'journey.public.notFoundMessage': 'Diese Journey existiert nicht oder der Link ist abgelaufen.', 'journey.public.notFoundMessage': 'Diese Journey existiert nicht oder der Link ist abgelaufen.',
+2
View File
@@ -2111,6 +2111,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
// Journey Entry Editor // Journey Entry Editor
'journey.editor.discardChangesConfirm': 'You have unsaved changes. Discard them?', 'journey.editor.discardChangesConfirm': 'You have unsaved changes. Discard them?',
'journey.editor.uploadFailed': 'Photo upload failed',
'journey.editor.uploadPhotos': 'Upload photos', 'journey.editor.uploadPhotos': 'Upload photos',
'journey.editor.uploading': 'Uploading...', 'journey.editor.uploading': 'Uploading...',
'journey.editor.fromGallery': 'From Gallery', 'journey.editor.fromGallery': 'From Gallery',
@@ -2219,6 +2220,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'journey.settings.failedToDelete': 'Failed to delete', 'journey.settings.failedToDelete': 'Failed to delete',
'journey.entries.deleteTitle': 'Delete Entry', 'journey.entries.deleteTitle': 'Delete Entry',
'journey.photosUploaded': '{count} photos uploaded', 'journey.photosUploaded': '{count} photos uploaded',
'journey.photosUploadFailed': 'Some photos failed to upload',
'journey.photosAdded': '{count} photos added', 'journey.photosAdded': '{count} photos added',
// Journey — Public Page // Journey — Public Page
+2
View File
@@ -2084,6 +2084,7 @@ const es: Record<string, string> = {
'journey.synced.places': 'lugares', 'journey.synced.places': 'lugares',
'journey.synced.synced': 'sincronizado', 'journey.synced.synced': 'sincronizado',
'journey.editor.discardChangesConfirm': 'Tienes cambios sin guardar. ¿Descartarlos?', 'journey.editor.discardChangesConfirm': 'Tienes cambios sin guardar. ¿Descartarlos?',
'journey.editor.uploadFailed': 'Error al subir fotos',
'journey.editor.uploadPhotos': 'Subir fotos', 'journey.editor.uploadPhotos': 'Subir fotos',
'journey.editor.uploading': 'Subiendo...', 'journey.editor.uploading': 'Subiendo...',
'journey.editor.fromGallery': 'Desde galería', 'journey.editor.fromGallery': 'Desde galería',
@@ -2176,6 +2177,7 @@ const es: Record<string, string> = {
'journey.settings.failedToDelete': 'Error al eliminar', 'journey.settings.failedToDelete': 'Error al eliminar',
'journey.entries.deleteTitle': 'Eliminar entrada', 'journey.entries.deleteTitle': 'Eliminar entrada',
'journey.photosUploaded': '{count} fotos subidas', 'journey.photosUploaded': '{count} fotos subidas',
'journey.photosUploadFailed': 'Algunas fotos no se pudieron subir',
'journey.photosAdded': '{count} fotos añadidas', 'journey.photosAdded': '{count} fotos añadidas',
'journey.public.notFound': 'No encontrado', 'journey.public.notFound': 'No encontrado',
'journey.public.notFoundMessage': 'Esta travesía no existe o el enlace ha expirado.', 'journey.public.notFoundMessage': 'Esta travesía no existe o el enlace ha expirado.',
+2
View File
@@ -2078,6 +2078,7 @@ const fr: Record<string, string> = {
'journey.synced.places': 'lieux', 'journey.synced.places': 'lieux',
'journey.synced.synced': 'synchronisé', 'journey.synced.synced': 'synchronisé',
'journey.editor.discardChangesConfirm': 'Vous avez des modifications non enregistrées. Les ignorer ?', '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.uploadPhotos': 'Téléverser des photos',
'journey.editor.uploading': 'Envoi...', 'journey.editor.uploading': 'Envoi...',
'journey.editor.fromGallery': 'Depuis la galerie', 'journey.editor.fromGallery': 'Depuis la galerie',
@@ -2170,6 +2171,7 @@ const fr: Record<string, string> = {
'journey.settings.failedToDelete': 'Échec de la suppression', 'journey.settings.failedToDelete': 'Échec de la suppression',
'journey.entries.deleteTitle': "Supprimer l'entrée", 'journey.entries.deleteTitle': "Supprimer l'entrée",
'journey.photosUploaded': '{count} photos téléversées', '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.photosAdded': '{count} photos ajoutées',
'journey.public.notFound': 'Introuvable', 'journey.public.notFound': 'Introuvable',
'journey.public.notFoundMessage': 'Ce journal n\'existe pas ou le lien a expiré.', 'journey.public.notFoundMessage': 'Ce journal n\'existe pas ou le lien a expiré.',
+2
View File
@@ -2079,6 +2079,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'journey.synced.places': 'helyszín', 'journey.synced.places': 'helyszín',
'journey.synced.synced': 'szinkronizálva', 'journey.synced.synced': 'szinkronizálva',
'journey.editor.discardChangesConfirm': 'Mentetlen módosításaid vannak. Elveted?', '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.uploadPhotos': 'Fotók feltöltése',
'journey.editor.uploading': 'Feltöltés...', 'journey.editor.uploading': 'Feltöltés...',
'journey.editor.fromGallery': 'Galériából', 'journey.editor.fromGallery': 'Galériából',
@@ -2171,6 +2172,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'journey.settings.failedToDelete': 'Törlés sikertelen', 'journey.settings.failedToDelete': 'Törlés sikertelen',
'journey.entries.deleteTitle': 'Bejegyzés törlése', 'journey.entries.deleteTitle': 'Bejegyzés törlése',
'journey.photosUploaded': '{count} fotó feltöltve', '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.photosAdded': '{count} fotó hozzáadva',
'journey.public.notFound': 'Nem található', 'journey.public.notFound': 'Nem található',
'journey.public.notFoundMessage': 'Ez az útinapló nem létezik vagy a link lejárt.', 'journey.public.notFoundMessage': 'Ez az útinapló nem létezik vagy a link lejárt.',
+2
View File
@@ -2094,6 +2094,7 @@ const id: Record<string, string | { name: string; category: string }[]> = {
// Journey Entry Editor // Journey Entry Editor
'journey.editor.discardChangesConfirm': 'Anda memiliki perubahan yang belum disimpan. Buang?', 'journey.editor.discardChangesConfirm': 'Anda memiliki perubahan yang belum disimpan. Buang?',
'journey.editor.uploadFailed': 'Gagal mengunggah foto',
'journey.editor.uploadPhotos': 'Unggah foto', 'journey.editor.uploadPhotos': 'Unggah foto',
'journey.editor.uploading': 'Mengunggah...', 'journey.editor.uploading': 'Mengunggah...',
'journey.editor.fromGallery': 'Dari Galeri', 'journey.editor.fromGallery': 'Dari Galeri',
@@ -2198,6 +2199,7 @@ const id: Record<string, string | { name: string; category: string }[]> = {
'journey.settings.failedToDelete': 'Gagal menghapus', 'journey.settings.failedToDelete': 'Gagal menghapus',
'journey.entries.deleteTitle': 'Hapus Entri', 'journey.entries.deleteTitle': 'Hapus Entri',
'journey.photosUploaded': '{count} foto diunggah', 'journey.photosUploaded': '{count} foto diunggah',
'journey.photosUploadFailed': 'Beberapa foto gagal diunggah',
'journey.photosAdded': '{count} foto ditambahkan', 'journey.photosAdded': '{count} foto ditambahkan',
// Journey — Public Page // Journey — Public Page
+2
View File
@@ -2079,6 +2079,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'journey.synced.places': 'luoghi', 'journey.synced.places': 'luoghi',
'journey.synced.synced': 'sincronizzato', 'journey.synced.synced': 'sincronizzato',
'journey.editor.discardChangesConfirm': 'Hai modifiche non salvate. Vuoi scartarle?', 'journey.editor.discardChangesConfirm': 'Hai modifiche non salvate. Vuoi scartarle?',
'journey.editor.uploadFailed': 'Caricamento foto non riuscito',
'journey.editor.uploadPhotos': 'Carica foto', 'journey.editor.uploadPhotos': 'Carica foto',
'journey.editor.uploading': 'Caricamento...', 'journey.editor.uploading': 'Caricamento...',
'journey.editor.fromGallery': 'Dalla galleria', 'journey.editor.fromGallery': 'Dalla galleria',
@@ -2171,6 +2172,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'journey.settings.failedToDelete': 'Eliminazione non riuscita', 'journey.settings.failedToDelete': 'Eliminazione non riuscita',
'journey.entries.deleteTitle': 'Elimina voce', 'journey.entries.deleteTitle': 'Elimina voce',
'journey.photosUploaded': '{count} foto caricate', 'journey.photosUploaded': '{count} foto caricate',
'journey.photosUploadFailed': 'Alcune foto non sono state caricate',
'journey.photosAdded': '{count} foto aggiunte', 'journey.photosAdded': '{count} foto aggiunte',
'journey.public.notFound': 'Non trovato', 'journey.public.notFound': 'Non trovato',
'journey.public.notFoundMessage': 'Questo diario non esiste o il link è scaduto.', 'journey.public.notFoundMessage': 'Questo diario non esiste o il link è scaduto.',
+2
View File
@@ -2078,6 +2078,7 @@ const nl: Record<string, string> = {
'journey.synced.places': 'plaatsen', 'journey.synced.places': 'plaatsen',
'journey.synced.synced': 'gesynchroniseerd', 'journey.synced.synced': 'gesynchroniseerd',
'journey.editor.discardChangesConfirm': 'Je hebt niet-opgeslagen wijzigingen. Verwerpen?', 'journey.editor.discardChangesConfirm': 'Je hebt niet-opgeslagen wijzigingen. Verwerpen?',
'journey.editor.uploadFailed': 'Foto uploaden mislukt',
'journey.editor.uploadPhotos': 'Foto\'s uploaden', 'journey.editor.uploadPhotos': 'Foto\'s uploaden',
'journey.editor.uploading': 'Uploaden...', 'journey.editor.uploading': 'Uploaden...',
'journey.editor.fromGallery': 'Uit galerij', 'journey.editor.fromGallery': 'Uit galerij',
@@ -2170,6 +2171,7 @@ const nl: Record<string, string> = {
'journey.settings.failedToDelete': 'Verwijderen mislukt', 'journey.settings.failedToDelete': 'Verwijderen mislukt',
'journey.entries.deleteTitle': 'Vermelding verwijderen', 'journey.entries.deleteTitle': 'Vermelding verwijderen',
'journey.photosUploaded': "{count} foto's geüpload", 'journey.photosUploaded': "{count} foto's geüpload",
'journey.photosUploadFailed': "Sommige foto's konden niet worden geüpload",
'journey.photosAdded': "{count} foto's toegevoegd", 'journey.photosAdded': "{count} foto's toegevoegd",
'journey.public.notFound': 'Niet gevonden', 'journey.public.notFound': 'Niet gevonden',
'journey.public.notFoundMessage': 'Dit reisverslag bestaat niet of de link is verlopen.', 'journey.public.notFoundMessage': 'Dit reisverslag bestaat niet of de link is verlopen.',
+2
View File
@@ -2071,6 +2071,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'journey.synced.places': 'miejsca', 'journey.synced.places': 'miejsca',
'journey.synced.synced': 'zsynchronizowane', 'journey.synced.synced': 'zsynchronizowane',
'journey.editor.discardChangesConfirm': 'Masz niezapisane zmiany. Odrzucić?', '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.uploadPhotos': 'Prześlij zdjęcia',
'journey.editor.uploading': 'Przesyłanie...', 'journey.editor.uploading': 'Przesyłanie...',
'journey.editor.fromGallery': 'Z galerii', 'journey.editor.fromGallery': 'Z galerii',
@@ -2163,6 +2164,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'journey.settings.failedToDelete': 'Nie udało się usunąć', 'journey.settings.failedToDelete': 'Nie udało się usunąć',
'journey.entries.deleteTitle': 'Usuń wpis', 'journey.entries.deleteTitle': 'Usuń wpis',
'journey.photosUploaded': '{count} zdjęć przesłanych', 'journey.photosUploaded': '{count} zdjęć przesłanych',
'journey.photosUploadFailed': 'Nie udało się przesłać niektórych zdjęć',
'journey.photosAdded': '{count} zdjęć dodanych', 'journey.photosAdded': '{count} zdjęć dodanych',
'journey.public.notFound': 'Nie znaleziono', 'journey.public.notFound': 'Nie znaleziono',
'journey.public.notFoundMessage': 'Ten dziennik podróży nie istnieje lub link wygasł.', 'journey.public.notFoundMessage': 'Ten dziennik podróży nie istnieje lub link wygasł.',
+2
View File
@@ -2078,6 +2078,7 @@ const ru: Record<string, string> = {
'journey.synced.places': 'мест', 'journey.synced.places': 'мест',
'journey.synced.synced': 'синхронизировано', 'journey.synced.synced': 'синхронизировано',
'journey.editor.discardChangesConfirm': 'У вас есть несохранённые изменения. Отменить?', 'journey.editor.discardChangesConfirm': 'У вас есть несохранённые изменения. Отменить?',
'journey.editor.uploadFailed': 'Не удалось загрузить фото',
'journey.editor.uploadPhotos': 'Загрузить фото', 'journey.editor.uploadPhotos': 'Загрузить фото',
'journey.editor.uploading': 'Загрузка...', 'journey.editor.uploading': 'Загрузка...',
'journey.editor.fromGallery': 'Из галереи', 'journey.editor.fromGallery': 'Из галереи',
@@ -2170,6 +2171,7 @@ const ru: Record<string, string> = {
'journey.settings.failedToDelete': 'Не удалось удалить', 'journey.settings.failedToDelete': 'Не удалось удалить',
'journey.entries.deleteTitle': 'Удалить запись', 'journey.entries.deleteTitle': 'Удалить запись',
'journey.photosUploaded': '{count} фото загружено', 'journey.photosUploaded': '{count} фото загружено',
'journey.photosUploadFailed': 'Некоторые фото не удалось загрузить',
'journey.photosAdded': '{count} фото добавлено', 'journey.photosAdded': '{count} фото добавлено',
'journey.public.notFound': 'Не найдено', 'journey.public.notFound': 'Не найдено',
'journey.public.notFoundMessage': 'Это путешествие не существует или ссылка устарела.', 'journey.public.notFoundMessage': 'Это путешествие не существует или ссылка устарела.',
+2
View File
@@ -2078,6 +2078,7 @@ const zh: Record<string, string> = {
'journey.synced.places': '个地点', 'journey.synced.places': '个地点',
'journey.synced.synced': '已同步', 'journey.synced.synced': '已同步',
'journey.editor.discardChangesConfirm': '您有未保存的更改。要放弃吗?', 'journey.editor.discardChangesConfirm': '您有未保存的更改。要放弃吗?',
'journey.editor.uploadFailed': '照片上传失败',
'journey.editor.uploadPhotos': '上传照片', 'journey.editor.uploadPhotos': '上传照片',
'journey.editor.uploading': '上传中...', 'journey.editor.uploading': '上传中...',
'journey.editor.fromGallery': '从相册', 'journey.editor.fromGallery': '从相册',
@@ -2170,6 +2171,7 @@ const zh: Record<string, string> = {
'journey.settings.failedToDelete': '删除失败', 'journey.settings.failedToDelete': '删除失败',
'journey.entries.deleteTitle': '删除条目', 'journey.entries.deleteTitle': '删除条目',
'journey.photosUploaded': '{count} 张照片已上传', 'journey.photosUploaded': '{count} 张照片已上传',
'journey.photosUploadFailed': '部分照片上传失败',
'journey.photosAdded': '{count} 张照片已添加', 'journey.photosAdded': '{count} 张照片已添加',
'journey.public.notFound': '未找到', 'journey.public.notFound': '未找到',
'journey.public.notFoundMessage': '此旅程不存在或链接已过期。', 'journey.public.notFoundMessage': '此旅程不存在或链接已过期。',
+2
View File
@@ -2036,6 +2036,7 @@ const zhTw: Record<string, string> = {
'journey.synced.places': '個地點', 'journey.synced.places': '個地點',
'journey.synced.synced': '已同步', 'journey.synced.synced': '已同步',
'journey.editor.discardChangesConfirm': '您有未儲存的變更。要放棄嗎?', 'journey.editor.discardChangesConfirm': '您有未儲存的變更。要放棄嗎?',
'journey.editor.uploadFailed': '照片上傳失敗',
'journey.editor.uploadPhotos': '上傳照片', 'journey.editor.uploadPhotos': '上傳照片',
'journey.editor.uploading': '上傳中...', 'journey.editor.uploading': '上傳中...',
'journey.editor.fromGallery': '從相簿', 'journey.editor.fromGallery': '從相簿',
@@ -2128,6 +2129,7 @@ const zhTw: Record<string, string> = {
'journey.settings.failedToDelete': '刪除失敗', 'journey.settings.failedToDelete': '刪除失敗',
'journey.entries.deleteTitle': '刪除條目', 'journey.entries.deleteTitle': '刪除條目',
'journey.photosUploaded': '{count} 張照片已上傳', 'journey.photosUploaded': '{count} 張照片已上傳',
'journey.photosUploadFailed': '部分照片上傳失敗',
'journey.photosAdded': '{count} 張照片已新增', 'journey.photosAdded': '{count} 張照片已新增',
'journey.public.notFound': '未找到', 'journey.public.notFound': '未找到',
'journey.public.notFoundMessage': '此旅程不存在或連結已過期。', 'journey.public.notFoundMessage': '此旅程不存在或連結已過期。',
+9 -3
View File
@@ -30,6 +30,7 @@ import MobileEntryView from '../components/Journey/MobileEntryView'
import { useIsMobile } from '../hooks/useIsMobile' import { useIsMobile } from '../hooks/useIsMobile'
import type { JourneyEntry, JourneyPhoto, GalleryPhoto, JourneyTrip, JourneyDetail } from '../store/journeyStore' import type { JourneyEntry, JourneyPhoto, GalleryPhoto, JourneyTrip, JourneyDetail } from '../store/journeyStore'
import { computeJourneyLifecycle } from '../utils/journeyLifecycle' import { computeJourneyLifecycle } from '../utils/journeyLifecycle'
import { getApiErrorMessage } from '../types'
const GRADIENTS = [ const GRADIENTS = [
'linear-gradient(135deg, #0F172A 0%, #6366F1 45%, #EC4899 100%)', '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) await journeyApi.uploadGalleryPhotos(journeyId, formData)
toast.success(t('journey.photosUploaded', { count: files.length })) toast.success(t('journey.photosUploaded', { count: files.length }))
onRefresh() onRefresh()
} catch { } catch (err) {
toast.error(t('journey.settings.coverFailed')) toast.error(getApiErrorMessage(err, t('journey.photosUploadFailed')))
} finally { } finally {
setGalleryUploading(false) setGalleryUploading(false)
} }
@@ -2175,6 +2176,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
onDone: () => void onDone: () => void
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const toast = useToast()
const isMobile = useIsMobile() const isMobile = useIsMobile()
const [title, setTitle] = useState(entry.title || '') const [title, setTitle] = useState(entry.title || '')
const [story, setStory] = useState(entry.story || '') const [story, setStory] = useState(entry.story || '')
@@ -2248,7 +2250,11 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
if (pendingFiles.length > 0 && entryId) { if (pendingFiles.length > 0 && entryId) {
const formData = new FormData() const formData = new FormData()
for (const f of pendingFiles) formData.append('photos', f) 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 // link gallery photos that were picked before save
if (pendingLinkIds.length > 0 && entryId) { if (pendingLinkIds.length > 0 && entryId) {
+5
View File
@@ -5,6 +5,7 @@ import cookieParser from 'cookie-parser';
import path from 'node:path'; import path from 'node:path';
import fs from 'node:fs'; import fs from 'node:fs';
import multer from 'multer';
import { logDebug, logWarn, logError } from './services/auditLog'; import { logDebug, logWarn, logError } from './services/auditLog';
import { enforceGlobalMfaPolicy } from './middleware/mfaPolicy'; import { enforceGlobalMfaPolicy } from './middleware/mfaPolicy';
import { authenticate, verifyJwtAndLoadUser } from './middleware/auth'; import { authenticate, verifyJwtAndLoadUser } from './middleware/auth';
@@ -507,6 +508,10 @@ export function createApp(): express.Application {
} else { } else {
console.error('Unhandled error:', err); 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; const status = err.statusCode || err.status || 500;
// Expose the message for client errors (4xx); keep 'Internal server error' for 5xx. // Expose the message for client errors (4xx); keep 'Internal server error' for 5xx.
const message = status < 500 ? err.message : 'Internal server error'; const message = status < 500 ? err.message : 'Internal server error';
+2 -2
View File
@@ -98,7 +98,7 @@ router.delete('/entries/:entryId', authenticate, (req: Request, res: Response) =
// ── Photos (prefix /photos and /entries — before /:id) ─────────────────── // ── 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 authReq = req as AuthRequest;
const files = req.files as Express.Multer.File[]; const files = req.files as Express.Multer.File[];
if (!files?.length) return res.status(400).json({ error: 'No files uploaded' }); 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) ────────────────────────── // ── Gallery (prefix /:id/gallery — before /:id) ──────────────────────────
// Upload photos directly to the journey gallery (no entry association) // 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 authReq = req as AuthRequest;
const files = req.files as Express.Multer.File[]; const files = req.files as Express.Multer.File[];
if (!files?.length) return res.status(400).json({ error: 'No files uploaded' }); if (!files?.length) return res.status(400).json({ error: 'No files uploaded' });
+6 -1
View File
@@ -1,6 +1,7 @@
import { db } from '../db/database'; import { db } from '../db/database';
import { decrypt_api_key } from './apiKeyCrypto'; import { decrypt_api_key } from './apiKeyCrypto';
import { checkSsrf } from '../utils/ssrfGuard'; import { checkSsrf } from '../utils/ssrfGuard';
import { getAppUrl } from './notifications';
// ── Google API call counter ─────────────────────────────────────────────────── // ── Google API call counter ───────────────────────────────────────────────────
@@ -12,7 +13,11 @@ export function resetGoogleApiCallCount(): void { googleApiCallCount = 0; }
function googleFetch(endpoint: string, label: string, init?: RequestInit): Promise<Response> { function googleFetch(endpoint: string, label: string, init?: RequestInit): Promise<Response> {
googleApiCallCount++; googleApiCallCount++;
console.debug(`[Google API] #${googleApiCallCount} ${label}${endpoint}`); 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<string, string> ?? {}) },
});
} }
// ── Interfaces ─────────────────────────────────────────────────────────────── // ── Interfaces ───────────────────────────────────────────────────────────────
+2
View File
@@ -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. 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 ### 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. 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.
+39
View File
@@ -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:<PORT>`, 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/<PLACE_ID>" \
-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 ## 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. **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.