Compare commits

..

4 Commits

Author SHA1 Message Date
jubnl fd611b91f2 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.
2026-05-13 11:03:16 +02:00
jubnl 93b51a0bf5 fix(csp): allow unsafe-eval for HEIC image conversion 2026-05-13 10:34:57 +02:00
github-actions[bot] 5b710a429a chore: bump version to 3.0.19 [skip ci] 2026-05-13 08:13:30 +00:00
Julien G. da3cba2de3 v3.0.19 Bug Fixes (#992)
* fix(mcp): replace relative oauth constent redirect by absolute redirect derived from APP_URL (#987)

* feat(journey): convert HEIC/HEIF uploads to JPEG for cross-platform compatibility

HEIC is an Apple-only format not recognised as an image by many browsers
and platforms. heic-to (lazy-loaded) now converts HEIC/HEIF files to JPEG
before upload in both the gallery and entry editor photo pickers.
Embedded metadata (EXIF, GPS) may be lost during conversion — documented
in the Journey Journal wiki page.

* fix(journey): skip heic-to import for non-HEIC files to avoid test env failures

* fix(notifications): prevent double-escaping HTML in password reset emails

buildPasswordResetHtml passed a pre-built HTML block to buildEmailHtml,
which then escaped it again — rendering raw tags as plain text in the email.
2026-05-13 10:13:17 +02:00
27 changed files with 91 additions and 20 deletions
+2 -2
View File
@@ -1,5 +1,5 @@
apiVersion: v2 apiVersion: v2
name: trek name: trek
version: 3.0.18 version: 3.0.19
description: Minimal Helm chart for TREK app description: Minimal Helm chart for TREK app
appVersion: "3.0.18" appVersion: "3.0.19"
+9 -2
View File
@@ -1,16 +1,17 @@
{ {
"name": "trek-client", "name": "trek-client",
"version": "3.0.18", "version": "3.0.19",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "trek-client", "name": "trek-client",
"version": "3.0.18", "version": "3.0.19",
"dependencies": { "dependencies": {
"@react-pdf/renderer": "^4.3.2", "@react-pdf/renderer": "^4.3.2",
"axios": "^1.6.7", "axios": "^1.6.7",
"dexie": "^4.4.2", "dexie": "^4.4.2",
"heic-to": "^1.4.2",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"lucide-react": "^0.344.0", "lucide-react": "^0.344.0",
"mapbox-gl": "^3.22.0", "mapbox-gl": "^3.22.0",
@@ -5827,6 +5828,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/heic-to": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/heic-to/-/heic-to-1.4.2.tgz",
"integrity": "sha512-y69thwxfNcEm2Vk8lbOD/cMabnvMJyOREfJYiCHcXCDqlfcPyJoBhyRc8+iDe1B95LRfpbTOpzxzY1xbRkdwBA==",
"license": "LGPL-3.0"
},
"node_modules/hsl-to-hex": { "node_modules/hsl-to-hex": {
"version": "1.0.0", "version": "1.0.0",
"license": "MIT", "license": "MIT",
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "trek-client", "name": "trek-client",
"version": "3.0.18", "version": "3.0.19",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -18,6 +18,7 @@
"@react-pdf/renderer": "^4.3.2", "@react-pdf/renderer": "^4.3.2",
"axios": "^1.6.7", "axios": "^1.6.7",
"dexie": "^4.4.2", "dexie": "^4.4.2",
"heic-to": "^1.4.2",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"lucide-react": "^0.344.0", "lucide-react": "^0.344.0",
"mapbox-gl": "^3.22.0", "mapbox-gl": "^3.22.0",
+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': '此旅程不存在或連結已過期。',
+14 -5
View File
@@ -1,5 +1,6 @@
import { useEffect, useState, useRef, useCallback, useMemo } from 'react' import { useEffect, useState, useRef, useCallback, useMemo } from 'react'
import { formatLocationName } from '../utils/formatters' import { formatLocationName } from '../utils/formatters'
import { normalizeImageFiles } from '../utils/convertHeic'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { useParams, useNavigate } from 'react-router-dom' import { useParams, useNavigate } from 'react-router-dom'
import { useJourneyStore } from '../store/journeyStore' import { useJourneyStore } from '../store/journeyStore'
@@ -29,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%)',
@@ -1027,13 +1029,14 @@ function GalleryView({ entries, gallery, journeyId, userId, trips, onPhotoClick,
if (!files?.length) return if (!files?.length) return
setGalleryUploading(true) setGalleryUploading(true)
try { try {
const normalized = await normalizeImageFiles(files)
const formData = new FormData() const formData = new FormData()
for (const f of files) formData.append('photos', f) for (const f of normalized) formData.append('photos', f)
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)
} }
@@ -2173,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 || '')
@@ -2246,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) {
@@ -2265,7 +2273,8 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
if (!files?.length) return if (!files?.length) return
// Queue files locally until Save so cancel/close actually discards. This // Queue files locally until Save so cancel/close actually discards. This
// keeps photo behavior consistent with text fields — no silent persistence. // keeps photo behavior consistent with text fields — no silent persistence.
setPendingFiles(prev => [...prev, ...Array.from(files)]) const normalized = await normalizeImageFiles(files)
setPendingFiles(prev => [...prev, ...normalized])
} }
return ( return (
+17
View File
@@ -0,0 +1,17 @@
function looksLikeHeic(file: File): boolean {
const ext = file.name.split('.').pop()?.toLowerCase() ?? ''
return ext === 'heic' || ext === 'heif' || file.type === 'image/heic' || file.type === 'image/heif'
}
export async function normalizeImageFile(file: File): Promise<File> {
if (!looksLikeHeic(file)) return file
const { isHeic, heicTo } = await import('heic-to')
if (!(await isHeic(file))) return file
const blob = await heicTo({ blob: file, type: 'image/jpeg', quality: 0.92 })
const jpegName = file.name.replace(/\.(heic|heif)$/i, '.jpg')
return new File([blob], jpegName, { type: 'image/jpeg' })
}
export async function normalizeImageFiles(files: FileList | File[]): Promise<File[]> {
return Promise.all(Array.from(files).map(normalizeImageFile))
}
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "trek-server", "name": "trek-server",
"version": "3.0.18", "version": "3.0.19",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "trek-server", "name": "trek-server",
"version": "3.0.18", "version": "3.0.19",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.28.0", "@modelcontextprotocol/sdk": "^1.28.0",
"archiver": "^6.0.1", "archiver": "^6.0.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "trek-server", "name": "trek-server",
"version": "3.0.18", "version": "3.0.19",
"main": "src/index.ts", "main": "src/index.ts",
"scripts": { "scripts": {
"start": "node --import tsx src/index.ts", "start": "node --import tsx src/index.ts",
+6 -1
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';
@@ -122,7 +123,7 @@ export function createApp(): express.Application {
contentSecurityPolicy: { contentSecurityPolicy: {
directives: { directives: {
defaultSrc: ["'self'"], defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'wasm-unsafe-eval'"], scriptSrc: ["'self'", "'wasm-unsafe-eval'", "'unsafe-eval'"],
styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com", "https://unpkg.com"], styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com", "https://unpkg.com"],
imgSrc: ["'self'", "data:", "blob:", "https:"], imgSrc: ["'self'", "data:", "blob:", "https:"],
connectSrc: [ connectSrc: [
@@ -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 -1
View File
@@ -147,7 +147,8 @@ export const trekOAuthProvider: OAuthServerProvider = {
if (params.state) qs.set('state', params.state); if (params.state) qs.set('state', params.state);
if (params.resource) qs.set('resource', params.resource.href); if (params.resource) qs.set('resource', params.resource.href);
res.redirect(302, `/oauth/consent?${qs.toString()}`); const base = getMcpSafeUrl().replace(/\/+$/, '');
res.redirect(302, `${base}/oauth/consent?${qs.toString()}`);
}, },
// Not called because skipLocalPkceValidation = true. // Not called because skipLocalPkceValidation = true.
+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' });
+3 -3
View File
@@ -316,12 +316,12 @@ export function getEventText(lang: string, event: NotifEventType, params: Record
// ── Email HTML builder ───────────────────────────────────────────────────── // ── Email HTML builder ─────────────────────────────────────────────────────
export function buildEmailHtml(subject: string, body: string, lang: string, navigateTarget?: string): string { export function buildEmailHtml(subject: string, body: string, lang: string, navigateTarget?: string, rawBody = false): string {
const s = I18N[lang] || I18N.en; const s = I18N[lang] || I18N.en;
const appUrl = getAppUrl(); const appUrl = getAppUrl();
const ctaHref = escapeHtml(navigateTarget ? `${appUrl}${navigateTarget}` : (appUrl || '')); const ctaHref = escapeHtml(navigateTarget ? `${appUrl}${navigateTarget}` : (appUrl || ''));
const safeSubject = escapeHtml(subject); const safeSubject = escapeHtml(subject);
const safeBody = escapeHtml(body); const safeBody = rawBody ? body : escapeHtml(body);
return `<!DOCTYPE html> return `<!DOCTYPE html>
<html> <html>
@@ -396,7 +396,7 @@ function buildPasswordResetHtml(subject: string, strings: PasswordResetStrings,
<p style="margin:0 0 10px 0; font-size:13px; color:#6B7280;">${safeExpiry}</p> <p style="margin:0 0 10px 0; font-size:13px; color:#6B7280;">${safeExpiry}</p>
<p style="margin:0; font-size:13px; color:#6B7280;">${safeIgnore}</p> <p style="margin:0; font-size:13px; color:#6B7280;">${safeIgnore}</p>
`; `;
return buildEmailHtml(subject, block, lang); return buildEmailHtml(subject, block, lang, undefined, true);
} }
/** /**
+1
View File
@@ -37,6 +37,7 @@ Each entry corresponds to a day in your journey. The entry editor provides:
- **Weather** — choose one of six values: Sunny, Partly cloudy, Cloudy, Rainy, Stormy, Cold. - **Weather** — choose one of six values: Sunny, Partly cloudy, Cloudy, Rainy, Stormy, Cold.
- **Photos** — attach photos to the entry. The first photo becomes the card thumbnail in list views. - **Photos** — attach photos to the entry. The first photo becomes the card thumbnail in list views.
> **Note on HEIC files:** HEIC is an Apple-only format that many browsers and platforms do not recognise as an image. To ensure broad compatibility, HEIC/HEIF files are automatically converted to JPEG before upload. This conversion may result in the loss of embedded metadata (EXIF data such as GPS coordinates, camera information, etc.).
- **Pros / Cons** — optional verdict cards. Add items to a **Pros** list (thumbs-up) or a **Cons** list (thumbs-down) to summarise what you loved or what could have been better. These are stored in the `pros_cons.pros` and `pros_cons.cons` arrays on the entry. - **Pros / Cons** — optional verdict cards. Add items to a **Pros** list (thumbs-up) or a **Cons** list (thumbs-down) to summarise what you loved or what could have been better. These are stored in the `pros_cons.pros` and `pros_cons.cons` arrays on the entry.
- **Tags** — free-form labels (e.g. "hidden gem", "best meal"). - **Tags** — free-form labels (e.g. "hidden gem", "best meal").
- **Location** — pin the entry to a map location. - **Location** — pin the entry to a map location.