From fd611b91f2a3e17c2d83171baddb9e8b5613a109 Mon Sep 17 00:00:00 2001 From: jubnl Date: Wed, 13 May 2026 11:03:16 +0200 Subject: [PATCH] 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. --- client/src/i18n/translations/ar.ts | 2 ++ client/src/i18n/translations/br.ts | 2 ++ client/src/i18n/translations/cs.ts | 2 ++ client/src/i18n/translations/de.ts | 2 ++ client/src/i18n/translations/en.ts | 2 ++ client/src/i18n/translations/es.ts | 2 ++ client/src/i18n/translations/fr.ts | 2 ++ client/src/i18n/translations/hu.ts | 2 ++ client/src/i18n/translations/id.ts | 2 ++ client/src/i18n/translations/it.ts | 2 ++ client/src/i18n/translations/nl.ts | 2 ++ client/src/i18n/translations/pl.ts | 2 ++ client/src/i18n/translations/ru.ts | 2 ++ client/src/i18n/translations/zh.ts | 2 ++ client/src/i18n/translations/zhTw.ts | 2 ++ client/src/pages/JourneyDetailPage.tsx | 12 +++++++++--- server/src/app.ts | 5 +++++ server/src/routes/journey.ts | 4 ++-- 18 files changed, 46 insertions(+), 5 deletions(-) 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' });