From 60cbd203279e1dbbf174550aa01a6bfa9660baa9 Mon Sep 17 00:00:00 2001 From: jubnl <66769052+jubnl@users.noreply.github.com> Date: Wed, 1 Jul 2026 18:57:16 +0200 Subject: [PATCH] fix: back-merge v3.1.4 hotfixes into dev (#1371) Port the three main-only fixes onto dev's (post-rewrite) architecture: - fix(backups): prevent recursion when the backup path sits inside the backed-up dir - fix(share): convert budget items to the viewer's base currency instead of a flat EUR - fix(files): surface the descriptive server error for unsupported upload types (#1363) Cherry-picked from 819aa793 on main; the SharedTripPage and useTripPlanner conflicts were resolved to keep dev's font-scaling and full import set while taking the fixes' currency conversion and translateApiError wiring. --- client/src/components/Files/useFileManager.ts | 6 +- .../src/components/Planner/PlaceInspector.tsx | 4 +- client/src/i18n/index.ts | 1 + client/src/i18n/translateApiError.test.ts | 30 +++++++++ client/src/i18n/translateApiError.ts | 17 +++++ client/src/pages/SharedTripPage.test.tsx | 63 +++++++++++++++++++ client/src/pages/SharedTripPage.tsx | 14 +++-- client/src/pages/sharedTrip/useSharedTrip.ts | 10 ++- .../src/pages/tripPlanner/useTripPlanner.ts | 6 +- server/src/nest/files/files.controller.ts | 3 +- server/src/services/backupService.ts | 14 ++++- server/src/services/shareService.ts | 18 +++++- server/tests/integration/files.test.ts | 2 + server/tests/integration/share.test.ts | 50 +++++++++++++++ .../tests/unit/services/backupService.test.ts | 34 +++++++++- shared/src/i18n/ar/files.ts | 1 + shared/src/i18n/br/files.ts | 1 + shared/src/i18n/cs/files.ts | 1 + shared/src/i18n/de/files.ts | 1 + shared/src/i18n/en/files.ts | 1 + shared/src/i18n/es/files.ts | 1 + shared/src/i18n/fr/files.ts | 1 + shared/src/i18n/gr/files.ts | 1 + shared/src/i18n/hu/files.ts | 1 + shared/src/i18n/id/files.ts | 1 + shared/src/i18n/it/files.ts | 1 + shared/src/i18n/ja/files.ts | 1 + shared/src/i18n/ko/files.ts | 1 + shared/src/i18n/nl/files.ts | 1 + shared/src/i18n/pl/files.ts | 1 + shared/src/i18n/ru/files.ts | 1 + shared/src/i18n/sv/files.ts | 1 + shared/src/i18n/tr/files.ts | 1 + shared/src/i18n/uk/files.ts | 1 + shared/src/i18n/vi/files.ts | 1 + shared/src/i18n/zh-TW/files.ts | 1 + shared/src/i18n/zh/files.ts | 1 + 37 files changed, 276 insertions(+), 18 deletions(-) create mode 100644 client/src/i18n/translateApiError.test.ts create mode 100644 client/src/i18n/translateApiError.ts diff --git a/client/src/components/Files/useFileManager.ts b/client/src/components/Files/useFileManager.ts index 400c142c9..93d2e3fe3 100644 --- a/client/src/components/Files/useFileManager.ts +++ b/client/src/components/Files/useFileManager.ts @@ -1,7 +1,7 @@ import { useState, useCallback, useEffect } from 'react' import { useDropzone } from 'react-dropzone' import { useToast } from '../shared/Toast' -import { useTranslation } from '../../i18n' +import { useTranslation, translateApiError } from '../../i18n' import { filesApi } from '../../api/client' import type { Place, Reservation, TripFile, Day, AssignmentsMap } from '../../types' import { useCanDo } from '../../store/permissionsStore' @@ -119,8 +119,8 @@ export function useFileManager({ files = [], onUpload, onDelete, onUpdate, place if (lastId && (places.length > 0 || reservations.length > 0)) { setAssignFileId(lastId) } - } catch { - toast.error(t('files.uploadError')) + } catch (err) { + toast.error(translateApiError(t, err, 'files.uploadError')) } finally { setUploading(false) } diff --git a/client/src/components/Planner/PlaceInspector.tsx b/client/src/components/Planner/PlaceInspector.tsx index b7b818ac4..5c43ad237 100644 --- a/client/src/components/Planner/PlaceInspector.tsx +++ b/client/src/components/Planner/PlaceInspector.tsx @@ -14,7 +14,7 @@ import { useAddonStore } from '../../store/addonStore' import { useSaveToCollectionStore } from '../../store/saveToCollectionStore' import { getCategoryIcon } from '../shared/categoryIcons' import { useToast } from '../shared/Toast' -import { useTranslation } from '../../i18n' +import { useTranslation, translateApiError } from '../../i18n' import type { Place, Category, Day, Assignment, Reservation, TripFile, AssignmentsMap } from '../../types' import type { CollectionStatus } from '@trek/shared' import { splitReservationDateTime, formatTime } from '../../utils/formatters' @@ -255,7 +255,7 @@ export default function PlaceInspector({ setFilesExpanded(true) } catch (err: unknown) { console.error('Upload failed', err) - toast.error(t('files.uploadError')) + toast.error(translateApiError(t, err, 'files.uploadError')) } finally { setIsUploading(false) if (fileInputRef.current) fileInputRef.current.value = '' diff --git a/client/src/i18n/index.ts b/client/src/i18n/index.ts index 3422c9f65..baae42cf6 100644 --- a/client/src/i18n/index.ts +++ b/client/src/i18n/index.ts @@ -8,3 +8,4 @@ export { SUPPORTED_LANGUAGES, } from './TranslationContext' export { TransHtml } from './TransHtml' +export { translateApiError } from './translateApiError' diff --git a/client/src/i18n/translateApiError.test.ts b/client/src/i18n/translateApiError.test.ts new file mode 100644 index 000000000..caa4eafca --- /dev/null +++ b/client/src/i18n/translateApiError.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect } from 'vitest' +import { translateApiError } from './translateApiError' + +// Mimics the real t(): returns a translation for known keys, the key itself otherwise. +const dict: Record = { + 'files.uploadErrorType': "This file type isn't supported", + 'files.uploadError': 'Upload failed', +} +const t = (key: string) => dict[key] ?? key + +describe('translateApiError', () => { + it('resolves a server message that is a known i18n key', () => { + const err = new Error('files.uploadErrorType') + expect(translateApiError(t, err, 'files.uploadError')).toBe("This file type isn't supported") + }) + + it('falls back to the generic key when the message is a plain string', () => { + const err = new Error('Some raw server message') + expect(translateApiError(t, err, 'files.uploadError')).toBe('Upload failed') + }) + + it('falls back when the message is an empty string', () => { + expect(translateApiError(t, new Error(''), 'files.uploadError')).toBe('Upload failed') + }) + + it('falls back when the thrown value is not an Error', () => { + expect(translateApiError(t, 'nope', 'files.uploadError')).toBe('Upload failed') + expect(translateApiError(t, undefined, 'files.uploadError')).toBe('Upload failed') + }) +}) diff --git a/client/src/i18n/translateApiError.ts b/client/src/i18n/translateApiError.ts new file mode 100644 index 000000000..3f99365eb --- /dev/null +++ b/client/src/i18n/translateApiError.ts @@ -0,0 +1,17 @@ +/** + * Resolve a server error whose message may be an i18n key. + * + * The server can return a translation key as its error message (e.g. + * `files.uploadErrorType`). `t()` returns the key unchanged when it isn't a + * known translation, so `translated === key` reliably means "not a key" — in + * that case we fall back to a generic, always-localized message. + */ +export function translateApiError( + t: (key: string) => string, + err: unknown, + fallbackKey: string, +): string { + const key = err instanceof Error ? err.message : '' + const translated = t(key) + return translated && translated !== key ? translated : t(fallbackKey) +} diff --git a/client/src/pages/SharedTripPage.test.tsx b/client/src/pages/SharedTripPage.test.tsx index 84840e50f..f0a2ad05e 100644 --- a/client/src/pages/SharedTripPage.test.tsx +++ b/client/src/pages/SharedTripPage.test.tsx @@ -509,4 +509,67 @@ describe('SharedTripPage', () => { await waitFor(() => expect(screen.getByText('Tag 1')).toBeInTheDocument()); }); }); + + describe('FE-PAGE-SHARED-019: budget renders in the owner\'s baseCurrency, not the EUR trip fallback (#1361)', () => { + it('labels totals with the payload baseCurrency even when the trip currency is EUR', async () => { + server.use( + // No FX needed when the expense is already in the base; stub frankfurter so + // the live-rate fetch never hits the network in tests. + http.get('https://api.frankfurter.dev/v2/rates', () => HttpResponse.json([])), + http.get('/api/shared/:token', ({ params }) => { + if (params.token !== 'cad-token') return; + return HttpResponse.json({ + trip: { id: 1, title: 'Shared Paris Trip', start_date: '2026-07-01', end_date: '2026-07-05', currency: 'EUR' }, + baseCurrency: 'CAD', + days: [], assignments: {}, dayNotes: {}, places: [], reservations: [], accommodations: [], packing: [], + budget: [{ id: 1, name: 'Hotel', total_price: '200', category: 'Accommodation', currency: 'CAD' }], + categories: [], + permissions: { share_bookings: false, share_packing: false, share_budget: true, share_collab: false }, + collab: [], + }); + }), + ); + + renderSharedTrip('cad-token'); + await waitFor(() => expect(screen.getByText('Shared Paris Trip')).toBeInTheDocument()); + fireEvent.click(screen.getByRole('button', { name: /budget/i })); + + await waitFor(() => expect(screen.getByText('Hotel')).toBeInTheDocument()); + // Total + per-row labelled CAD; never the EUR fallback. + expect(screen.getAllByText(/200\.00 CAD/).length).toBeGreaterThan(0); + expect(screen.queryByText(/EUR/)).toBeNull(); + }); + }); + + describe('FE-PAGE-SHARED-020: mixed-currency expenses convert into baseCurrency via live FX (#1361)', () => { + it('converts a EUR expense into the base using fetched rates', async () => { + // Distinct base (NZD) so this test can't read the cached CAD rates seeded by + // FE-PAGE-SHARED-019 (useExchangeRates caches per base in module memory). + server.use( + // rates[X] = units of X per 1 base(NZD); 0.8 EUR per NZD → 100 EUR = 125.00 NZD + // (a clean 2-decimal result, distinct from the unconverted 100). + http.get('https://api.frankfurter.dev/v2/rates', () => HttpResponse.json([{ quote: 'EUR', rate: 0.8 }])), + http.get('/api/shared/:token', ({ params }) => { + if (params.token !== 'mixed-token') return; + return HttpResponse.json({ + trip: { id: 1, title: 'Shared Paris Trip', start_date: '2026-07-01', end_date: '2026-07-05', currency: 'EUR' }, + baseCurrency: 'NZD', + days: [], assignments: {}, dayNotes: {}, places: [], reservations: [], accommodations: [], packing: [], + budget: [{ id: 1, name: 'Dinner', total_price: '100', category: 'Food', currency: 'EUR' }], + categories: [], + permissions: { share_bookings: false, share_packing: false, share_budget: true, share_collab: false }, + collab: [], + }); + }), + ); + + renderSharedTrip('mixed-token'); + await waitFor(() => expect(screen.getByText('Shared Paris Trip')).toBeInTheDocument()); + fireEvent.click(screen.getByRole('button', { name: /budget/i })); + + await waitFor(() => expect(screen.getByText('Dinner')).toBeInTheDocument()); + // 100 EUR / 0.8 = 125.00 NZD once the rate resolves. + await waitFor(() => expect(screen.getAllByText(/125\.00 NZD/).length).toBeGreaterThan(0)); + }); + }); }); diff --git a/client/src/pages/SharedTripPage.tsx b/client/src/pages/SharedTripPage.tsx index 35c1b4872..96bb41e23 100644 --- a/client/src/pages/SharedTripPage.tsx +++ b/client/src/pages/SharedTripPage.tsx @@ -42,7 +42,7 @@ function FitBoundsToPlaces({ places }: { places: any[] }) { export default function SharedTripPage() { const { t, locale } = useTranslation() // Page = wiring container: share fetch + view state live in the hook. - const { data, error, selectedDay, setSelectedDay, activeTab, setActiveTab, showLangPicker, setShowLangPicker } = useSharedTrip() + const { data, error, base, convert, selectedDay, setSelectedDay, activeTab, setActiveTab, showLangPicker, setShowLangPicker } = useSharedTrip() if (error) return (
@@ -328,26 +328,30 @@ export default function SharedTripPage() { {/* Budget */} {activeTab === 'budget' && (budget || []).length > 0 && (() => { + // Pre-rework rows store currency = NULL ("the trip's own currency"); convert + // each expense into the owner's display base via live FX, mirroring CostsPanel. + const curOf = (i: any) => i.currency || trip.currency || base const grouped = (budget || []).reduce((g: any, i: any) => { const c = i.category || t('shared.other'); (g[c] = g[c] || []).push(i); return g }, {}) - const total = (budget || []).reduce((s: number, i: any) => s + (parseFloat(i.total_price) || 0), 0) + const sumIn = (items: any[]) => items.reduce((s: number, i: any) => s + convert(parseFloat(i.total_price) || 0, curOf(i)), 0) + const total = sumIn(budget || []) return (
{/* Total card */}
{t('shared.totalBudget')}
-
{total.toLocaleString(locale, { minimumFractionDigits: 2 })} {trip.currency || 'EUR'}
+
{total.toLocaleString(locale, { minimumFractionDigits: 2 })} {base}
{/* By category */} {Object.entries(grouped).map(([cat, items]: [string, any]) => (
{cat} - {items.reduce((s: number, i: any) => s + (parseFloat(i.total_price) || 0), 0).toLocaleString(locale, { minimumFractionDigits: 2 })} {trip.currency || ''} + {sumIn(items).toLocaleString(locale, { minimumFractionDigits: 2 })} {base}
{items.map((item: any) => (
{item.name} - {item.total_price ? Number(item.total_price).toLocaleString(locale, { minimumFractionDigits: 2 }) : '—'} + {item.total_price ? `${convert(parseFloat(item.total_price) || 0, curOf(item)).toLocaleString(locale, { minimumFractionDigits: 2 })} ${base}` : '—'}
))}
diff --git a/client/src/pages/sharedTrip/useSharedTrip.ts b/client/src/pages/sharedTrip/useSharedTrip.ts index ef6827145..59990e990 100644 --- a/client/src/pages/sharedTrip/useSharedTrip.ts +++ b/client/src/pages/sharedTrip/useSharedTrip.ts @@ -1,6 +1,7 @@ import { useState, useEffect } from 'react' import { useParams } from 'react-router-dom' import { shareApi } from '../../api/client' +import { useExchangeRates } from '../../hooks/useExchangeRates' /** * Shared-trip (public) data hook — owns the token lookup, the read-only share @@ -24,5 +25,12 @@ export function useSharedTrip() { shareApi.getSharedTrip(token).then(setData).catch(() => setError(true)) }, [token]) - return { data, error, selectedDay, setSelectedDay, activeTab, setActiveTab, showLangPicker, setShowLangPicker } + // Budget display currency = what the share owner sees in Costs (embedded in the + // payload as baseCurrency), falling back to the trip's own currency, then EUR. + // Convert every expense into it via live FX, mirroring CostsPanel — a public + // viewer has no settings store, so the base comes from the payload (#1361). + const base = String(data?.baseCurrency || data?.trip?.currency || 'EUR').toUpperCase() + const { convert } = useExchangeRates(base) + + return { data, error, base, convert, selectedDay, setSelectedDay, activeTab, setActiveTab, showLangPicker, setShowLangPicker } } diff --git a/client/src/pages/tripPlanner/useTripPlanner.ts b/client/src/pages/tripPlanner/useTripPlanner.ts index a120a97ec..faddd8446 100644 --- a/client/src/pages/tripPlanner/useTripPlanner.ts +++ b/client/src/pages/tripPlanner/useTripPlanner.ts @@ -6,7 +6,7 @@ import { useSettingsStore } from '../../store/settingsStore' import { getCached, fetchPhoto } from '../../services/photoService' import { useToast } from '../../components/shared/Toast' import { Map, Ticket, PackageCheck, Wallet, FolderOpen, Users, Train } from 'lucide-react' -import { useTranslation } from '../../i18n' +import { useTranslation, translateApiError } from '../../i18n' import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi, healthApi, airtrailApi, mapsApi, placesApi } from '../../api/client' import { parsedItemToDraft, isTransportItem, type BookingReviewDraft } from '../../components/Planner/parsedItemToDraft' import type { BookingImportPreviewItem } from '@trek/shared' @@ -426,7 +426,7 @@ export function useTripPlanner() { const fd = new FormData() fd.append('file', file) fd.append('place_id', String(editingPlace.id)) - try { await tripActions.addFile(tripId, fd) } catch { toast.error(t('files.uploadError')) } + try { await tripActions.addFile(tripId, fd) } catch (err) { toast.error(translateApiError(t, err, 'files.uploadError')) } } } toast.success(t('trip.toast.placeUpdated')) @@ -437,7 +437,7 @@ export function useTripPlanner() { const fd = new FormData() fd.append('file', file) fd.append('place_id', String(place.id)) - try { await tripActions.addFile(tripId, fd) } catch { toast.error(t('files.uploadError')) } + try { await tripActions.addFile(tripId, fd) } catch (err) { toast.error(translateApiError(t, err, 'files.uploadError')) } } } toast.success(t('trip.toast.placeAdded')) diff --git a/server/src/nest/files/files.controller.ts b/server/src/nest/files/files.controller.ts index c8a7b63ac..1df59ffc6 100644 --- a/server/src/nest/files/files.controller.ts +++ b/server/src/nest/files/files.controller.ts @@ -39,7 +39,8 @@ const UPLOAD = { fileFilter: (_req: unknown, file: Express.Multer.File, cb: (err: Error | null, accept: boolean) => void) => { const ext = path.extname(file.originalname).toLowerCase(); const reject = () => { - const err: Error & { statusCode?: number } = new Error('File type not allowed'); + // i18n key — the client resolves it via t() (see translateApiError). + const err: Error & { statusCode?: number } = new Error('files.uploadErrorType'); err.statusCode = 400; cb(err, false); }; diff --git a/server/src/services/backupService.ts b/server/src/services/backupService.ts index de3731c24..79a6befa3 100644 --- a/server/src/services/backupService.ts +++ b/server/src/services/backupService.ts @@ -184,9 +184,21 @@ export async function createBackup(): Promise { // Exclude the place-photo and trek-memory caches: both are re-derivable // (re-fetched on demand, keyed on stable ids) and would otherwise dominate // backup size. Restores self-heal — the cache dirs are recreated at startup. + // + // Also exclude backups/ and restore-*/: these live under data/, not uploads/, + // but when an install maps data and uploads to the SAME directory (a + // misconfiguration, but a catastrophic one) the glob would otherwise sweep + // every prior backup zip into the new archive — each run embedding all + // previous runs, so size compounds without bound (see issue #1358). Ignoring + // them keeps the backup bounded regardless of how the volumes are mounted. archive.glob( '**/*', - { cwd: uploadsDir, ignore: ['photos/google/**', 'photos/trek/**'], nodir: true, dot: true }, + { + cwd: uploadsDir, + ignore: ['photos/google/**', 'photos/trek/**', 'backups/**', 'restore-*/**'], + nodir: true, + dot: true, + }, { prefix: 'uploads' }, ); } diff --git a/server/src/services/shareService.ts b/server/src/services/shareService.ts index 7021ce60f..75ec76bf9 100644 --- a/server/src/services/shareService.ts +++ b/server/src/services/shareService.ts @@ -2,6 +2,7 @@ import { db, canAccessTrip } from '../db/database'; import crypto from 'crypto'; import { loadTagsByPlaceIds } from './queryHelpers'; import { serveFilePath } from './placePhotoCache'; +import { getUserSettings } from './settingsService'; const PLACE_PHOTO_PROXY_PREFIX = '/api/maps/place-photo/'; @@ -219,8 +220,23 @@ export function getSharedTripData(token: string): Record | null { ? db.prepare('SELECT m.*, u.username, u.avatar FROM collab_messages m JOIN users u ON m.user_id = u.id WHERE m.trip_id = ? AND m.deleted = 0 ORDER BY m.created_at').all(tripId) : []; + // Display currency the share owner sees in their Costs view. A public viewer has + // no logged-in user, so the owner's per-user `default_currency` (with the admin + // instance default already merged in by getUserSettings) is embedded in the + // payload and used by the client to convert every expense — otherwise guests + // fall back to the trip's base currency and see the wrong totals (#1361). + // getUserSettings merges admin defaults under the user's own settings, so this + // honours per-user → admin-default; we then fall back to trip currency → EUR. + let baseCurrency = (trip as { currency?: string }).currency || 'EUR'; + if (shareRow.created_by != null) { + const ownerDefault = getUserSettings(shareRow.created_by)['default_currency']; + if (typeof ownerDefault === 'string' && ownerDefault.trim()) { + baseCurrency = ownerDefault.trim(); + } + } + return { - trip, days, assignments, dayNotes, places, categories, permissions, + trip, baseCurrency, days, assignments, dayNotes, places, categories, permissions, reservations: permissions.share_bookings ? reservations : [], accommodations: permissions.share_bookings ? accommodations : [], packing: permissions.share_packing ? packing : [], diff --git a/server/tests/integration/files.test.ts b/server/tests/integration/files.test.ts index c7223c1b2..32733332f 100644 --- a/server/tests/integration/files.test.ts +++ b/server/tests/integration/files.test.ts @@ -126,6 +126,8 @@ describe('Upload file', () => { .set('Cookie', authCookie(user.id)) .attach('file', svgPath); expect(res.status).toBe(400); + // The error is an i18n key the client resolves via t() (issue #1363). + expect(res.body.error).toBe('files.uploadErrorType'); } finally { if (fs.existsSync(svgPath)) fs.unlinkSync(svgPath); } diff --git a/server/tests/integration/share.test.ts b/server/tests/integration/share.test.ts index 794a53f62..0dfd67c8a 100644 --- a/server/tests/integration/share.test.ts +++ b/server/tests/integration/share.test.ts @@ -354,6 +354,56 @@ describe('Shared trip — ordering parity (issue #981)', () => { }); }); +describe('Shared trip — display currency (issue #1361)', () => { + it('SHARE-021 — baseCurrency resolves from the share owner\'s default_currency setting', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + // Trip keeps the EUR default; the owner's Costs display currency is CAD. + testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'default_currency', ?)") + .run(user.id, JSON.stringify('CAD')); + + const { body: { token } } = await request(app) + .post(`/api/trips/${trip.id}/share-link`) + .set('Cookie', authCookie(user.id)) + .send({ share_budget: true }); + + const res = await request(app).get(`/api/shared/${token}`); + expect(res.status).toBe(200); + expect(res.body.baseCurrency).toBe('CAD'); + }); + + it('SHARE-022 — baseCurrency falls back to the trip currency when the owner has no setting', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + testDb.prepare('UPDATE trips SET currency = ? WHERE id = ?').run('GBP', trip.id); + + const { body: { token } } = await request(app) + .post(`/api/trips/${trip.id}/share-link`) + .set('Cookie', authCookie(user.id)) + .send({ share_budget: true }); + + const res = await request(app).get(`/api/shared/${token}`); + expect(res.status).toBe(200); + expect(res.body.baseCurrency).toBe('GBP'); + }); + + it('SHARE-023 — baseCurrency uses the admin instance default when the owner has no per-user setting', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); // EUR trip default, no user setting + testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('default_user_setting_default_currency', ?)") + .run(JSON.stringify('USD')); + + const { body: { token } } = await request(app) + .post(`/api/trips/${trip.id}/share-link`) + .set('Cookie', authCookie(user.id)) + .send({ share_budget: true }); + + const res = await request(app).get(`/api/shared/${token}`); + expect(res.status).toBe(200); + expect(res.body.baseCurrency).toBe('USD'); + }); +}); + describe('Shared trip — place photos in shared links (issue #1100)', () => { const PLACE_ID = 'ChIJsharedPhoto1100'; const PROXY_URL = `/api/maps/place-photo/${encodeURIComponent(PLACE_ID)}/bytes`; diff --git a/server/tests/unit/services/backupService.test.ts b/server/tests/unit/services/backupService.test.ts index 9fdca6f8f..a23c9cf3b 100644 --- a/server/tests/unit/services/backupService.test.ts +++ b/server/tests/unit/services/backupService.test.ts @@ -475,7 +475,7 @@ describe('BACKUP-036 createBackup', () => { '**/*', expect.objectContaining({ cwd: expect.stringContaining('uploads'), - ignore: ['photos/google/**', 'photos/trek/**'], + ignore: ['photos/google/**', 'photos/trek/**', 'backups/**', 'restore-*/**'], }), { prefix: 'uploads' }, ); @@ -483,6 +483,38 @@ describe('BACKUP-036 createBackup', () => { expect(archiverInstanceMock.directory).not.toHaveBeenCalled(); }); + it('BACKUP-036h — never sweeps backups/ or restore-* into the archive (issue #1358)', async () => { + // Regression guard: when data and uploads map to the same directory, the + // uploads glob would otherwise pick up the backups/ dir and recursively + // embed every prior backup zip, compounding size without bound. + fsMock.existsSync.mockImplementation((p: string) => String(p).endsWith('uploads')); + fsMock.mkdirSync.mockReturnValue(undefined); + + const writableEvents: Record = {}; + const fakeWriteStream = { + on: vi.fn((event: string, cb: Function) => { + writableEvents[event] = cb; + }), + }; + fsMock.createWriteStream.mockReturnValue(fakeWriteStream); + + archiverInstanceMock.on.mockImplementation((_e: string, _cb: Function) => {}); + archiverInstanceMock.pipe.mockReturnValue(undefined); + archiverInstanceMock.finalize.mockImplementation(() => { + if (writableEvents['close']) writableEvents['close'](); + }); + archiverMock.mockReturnValue(archiverInstanceMock); + + fsMock.statSync.mockReturnValue({ size: 1024, birthtime: new Date('2026-04-06T12:00:00Z') }); + + await createBackup(); + + const globCall = archiverInstanceMock.glob.mock.calls.at(-1); + const ignore: string[] = globCall?.[1]?.ignore ?? []; + expect(ignore).toContain('backups/**'); + expect(ignore).toContain('restore-*/**'); + }); + it('BACKUP-036f — bundles .encryption_key when present and ENCRYPTION_KEY env is unset', async () => { const prevEnvKey = process.env.ENCRYPTION_KEY; delete process.env.ENCRYPTION_KEY; diff --git a/shared/src/i18n/ar/files.ts b/shared/src/i18n/ar/files.ts index 6d9e1869b..24dea49f5 100644 --- a/shared/src/i18n/ar/files.ts +++ b/shared/src/i18n/ar/files.ts @@ -11,6 +11,7 @@ const files: TranslationStrings = { 'files.countSingular': 'ملف واحد', 'files.uploaded': 'تم رفع {count}', 'files.uploadError': 'فشل الرفع', + 'files.uploadErrorType': 'نوع الملف هذا غير مدعوم', 'files.dropzone': 'أسقط الملفات هنا', 'files.dropzoneHint': 'أو انقر للتصفح', 'files.allowedTypes': 'صور، PDF، DOC، DOCX، XLS، XLSX، TXT، CSV · حد أقصى 50 ميغابايت', diff --git a/shared/src/i18n/br/files.ts b/shared/src/i18n/br/files.ts index 07d62cdfc..b21df1a72 100644 --- a/shared/src/i18n/br/files.ts +++ b/shared/src/i18n/br/files.ts @@ -11,6 +11,7 @@ const files: TranslationStrings = { 'files.countSingular': '1 arquivo', 'files.uploaded': '{count} enviado(s)', 'files.uploadError': 'Falha no envio', + 'files.uploadErrorType': 'Esse tipo de arquivo não é suportado', 'files.dropzone': 'Solte os arquivos aqui', 'files.dropzoneHint': 'ou clique para escolher', 'files.allowedTypes': 'Imagens, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Máx. 50 MB', diff --git a/shared/src/i18n/cs/files.ts b/shared/src/i18n/cs/files.ts index 63cfc330c..402d4be3e 100644 --- a/shared/src/i18n/cs/files.ts +++ b/shared/src/i18n/cs/files.ts @@ -11,6 +11,7 @@ const files: TranslationStrings = { 'files.countSingular': '1 soubor', 'files.uploaded': '{count} nahráno', 'files.uploadError': 'Nahrávání se nezdařilo', + 'files.uploadErrorType': 'Tento typ souboru není podporován', 'files.dropzone': 'Přetáhněte soubory sem', 'files.dropzoneHint': 'nebo klikněte pro výběr', 'files.allowedTypes': 'Obrázky, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Max 50 MB', diff --git a/shared/src/i18n/de/files.ts b/shared/src/i18n/de/files.ts index 6b331d0b1..77eda2247 100644 --- a/shared/src/i18n/de/files.ts +++ b/shared/src/i18n/de/files.ts @@ -11,6 +11,7 @@ const files: TranslationStrings = { 'files.countSingular': '1 Datei', 'files.uploaded': '{count} hochgeladen', 'files.uploadError': 'Fehler beim Hochladen', + 'files.uploadErrorType': 'Dieser Dateityp wird nicht unterstützt', 'files.dropzone': 'Dateien hier ablegen', 'files.dropzoneHint': 'oder klicken zum Auswählen', 'files.allowedTypes': 'Bilder, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Max 50 MB', diff --git a/shared/src/i18n/en/files.ts b/shared/src/i18n/en/files.ts index dbcd9ed29..ca279d54e 100644 --- a/shared/src/i18n/en/files.ts +++ b/shared/src/i18n/en/files.ts @@ -11,6 +11,7 @@ const files: TranslationStrings = { 'files.countSingular': '1 file', 'files.uploaded': '{count} uploaded', 'files.uploadError': 'Upload failed', + 'files.uploadErrorType': "This file type isn't supported", 'files.dropzone': 'Drop files here', 'files.dropzoneHint': 'or click to browse', 'files.allowedTypes': 'Images, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Max 50 MB', diff --git a/shared/src/i18n/es/files.ts b/shared/src/i18n/es/files.ts index 071a8a354..d0c0d347a 100644 --- a/shared/src/i18n/es/files.ts +++ b/shared/src/i18n/es/files.ts @@ -11,6 +11,7 @@ const files: TranslationStrings = { 'files.countSingular': '1 archivo', 'files.uploaded': '{count} archivos subidos', 'files.uploadError': 'La subida falló', + 'files.uploadErrorType': 'Este tipo de archivo no es compatible', 'files.dropzone': 'Arrastra aquí los archivos', 'files.dropzoneHint': 'o haz clic para explorar', 'files.allowedTypes': 'Imágenes, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Máx. 50 MB', diff --git a/shared/src/i18n/fr/files.ts b/shared/src/i18n/fr/files.ts index a1bf92a1e..692af4b08 100644 --- a/shared/src/i18n/fr/files.ts +++ b/shared/src/i18n/fr/files.ts @@ -11,6 +11,7 @@ const files: TranslationStrings = { 'files.countSingular': '1 fichier', 'files.uploaded': '{count} importés', 'files.uploadError': "Échec de l'import", + 'files.uploadErrorType': "Ce type de fichier n'est pas pris en charge", 'files.dropzone': 'Déposez les fichiers ici', 'files.dropzoneHint': 'ou cliquez pour parcourir', 'files.allowedTypes': 'Images, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Max 50 Mo', diff --git a/shared/src/i18n/gr/files.ts b/shared/src/i18n/gr/files.ts index 493ec57e5..25dffd71f 100644 --- a/shared/src/i18n/gr/files.ts +++ b/shared/src/i18n/gr/files.ts @@ -11,6 +11,7 @@ const files: TranslationStrings = { 'files.countSingular': '1 αρχείο', 'files.uploaded': '{count} μεταφορτώθηκαν', 'files.uploadError': 'Η μεταφόρτωση απέτυχε', + 'files.uploadErrorType': 'Αυτός ο τύπος αρχείου δεν υποστηρίζεται', 'files.dropzone': 'Αποθέστε αρχεία εδώ', 'files.dropzoneHint': 'ή κάντε κλικ για περιήγηση', 'files.allowedTypes': 'Εικόνες, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Μέγ. 50 MB', diff --git a/shared/src/i18n/hu/files.ts b/shared/src/i18n/hu/files.ts index ad7a95e80..170bebbb0 100644 --- a/shared/src/i18n/hu/files.ts +++ b/shared/src/i18n/hu/files.ts @@ -11,6 +11,7 @@ const files: TranslationStrings = { 'files.countSingular': '1 fájl', 'files.uploaded': '{count} feltöltve', 'files.uploadError': 'Feltöltés sikertelen', + 'files.uploadErrorType': 'Ez a fájltípus nem támogatott', 'files.dropzone': 'Húzd ide a fájlokat', 'files.dropzoneHint': 'vagy kattints a böngészéshez', 'files.allowedTypes': 'Képek, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Max 50 MB', diff --git a/shared/src/i18n/id/files.ts b/shared/src/i18n/id/files.ts index 4517da0a5..bd656c453 100644 --- a/shared/src/i18n/id/files.ts +++ b/shared/src/i18n/id/files.ts @@ -11,6 +11,7 @@ const files: TranslationStrings = { 'files.countSingular': '1 berkas', 'files.uploaded': '{count} diunggah', 'files.uploadError': 'Gagal mengunggah', + 'files.uploadErrorType': 'Tipe file ini tidak didukung', 'files.dropzone': 'Jatuhkan file di sini', 'files.dropzoneHint': 'atau klik untuk memilih', 'files.allowedTypes': 'Gambar, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Maks 50 MB', diff --git a/shared/src/i18n/it/files.ts b/shared/src/i18n/it/files.ts index 0ea2a93e3..0448b82e6 100644 --- a/shared/src/i18n/it/files.ts +++ b/shared/src/i18n/it/files.ts @@ -11,6 +11,7 @@ const files: TranslationStrings = { 'files.countSingular': '1 documento', 'files.uploaded': '{count} caricati', 'files.uploadError': 'Caricamento non riuscito', + 'files.uploadErrorType': 'Questo tipo di file non è supportato', 'files.dropzone': 'Trascina qui i file', 'files.dropzoneHint': 'oppure clicca per sfogliare', 'files.allowedTypes': 'Immagini, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Max 50 MB', diff --git a/shared/src/i18n/ja/files.ts b/shared/src/i18n/ja/files.ts index fe77bdf8f..e5f69b92a 100644 --- a/shared/src/i18n/ja/files.ts +++ b/shared/src/i18n/ja/files.ts @@ -11,6 +11,7 @@ const files: TranslationStrings = { 'files.countSingular': '1件のファイル', 'files.uploaded': '{count}件アップロード', 'files.uploadError': 'アップロードに失敗しました', + 'files.uploadErrorType': 'このファイル形式はサポートされていません', 'files.dropzone': 'ここにファイルをドロップ', 'files.dropzoneHint': 'またはクリックして参照', 'files.allowedTypes': '画像、PDF、DOC、DOCX、XLS、XLSX、TXT、CSV · 最大50MB', diff --git a/shared/src/i18n/ko/files.ts b/shared/src/i18n/ko/files.ts index bce919ea0..2506d174b 100644 --- a/shared/src/i18n/ko/files.ts +++ b/shared/src/i18n/ko/files.ts @@ -11,6 +11,7 @@ const files: TranslationStrings = { 'files.countSingular': '파일 1개', 'files.uploaded': '{count}개 업로드됨', 'files.uploadError': '업로드 실패', + 'files.uploadErrorType': '지원되지 않는 파일 형식입니다', 'files.dropzone': '여기에 파일을 놓으세요', 'files.dropzoneHint': '또는 클릭하여 탐색', 'files.allowedTypes': '이미지, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · 최대 50 MB', diff --git a/shared/src/i18n/nl/files.ts b/shared/src/i18n/nl/files.ts index 414e1a7b0..7bbf70e74 100644 --- a/shared/src/i18n/nl/files.ts +++ b/shared/src/i18n/nl/files.ts @@ -11,6 +11,7 @@ const files: TranslationStrings = { 'files.countSingular': '1 bestand', 'files.uploaded': '{count} geüpload', 'files.uploadError': 'Uploaden mislukt', + 'files.uploadErrorType': 'Dit bestandstype wordt niet ondersteund', 'files.dropzone': 'Sleep bestanden hierheen', 'files.dropzoneHint': 'of klik om te bladeren', 'files.allowedTypes': 'Afbeeldingen, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Max 50 MB', diff --git a/shared/src/i18n/pl/files.ts b/shared/src/i18n/pl/files.ts index f8fc0dd58..3e95c20b0 100644 --- a/shared/src/i18n/pl/files.ts +++ b/shared/src/i18n/pl/files.ts @@ -11,6 +11,7 @@ const files: TranslationStrings = { 'files.countSingular': '1 plik', 'files.uploaded': '{count} przesłanych', 'files.uploadError': 'Przesyłanie nie powiodło się', + 'files.uploadErrorType': 'Ten typ pliku nie jest obsługiwany', 'files.dropzone': 'Przeciągnij pliki tutaj', 'files.dropzoneHint': 'lub kliknij, aby przeglądać', 'files.allowedTypes': 'Obrazki, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Maks 50 MB', diff --git a/shared/src/i18n/ru/files.ts b/shared/src/i18n/ru/files.ts index 488242ebf..01c2608f5 100644 --- a/shared/src/i18n/ru/files.ts +++ b/shared/src/i18n/ru/files.ts @@ -11,6 +11,7 @@ const files: TranslationStrings = { 'files.countSingular': '1 файл', 'files.uploaded': '{count} загружено', 'files.uploadError': 'Ошибка загрузки', + 'files.uploadErrorType': 'Этот тип файла не поддерживается', 'files.dropzone': 'Перетащите файлы сюда', 'files.dropzoneHint': 'или нажмите для выбора', 'files.allowedTypes': 'Изображения, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Макс. 50 МБ', diff --git a/shared/src/i18n/sv/files.ts b/shared/src/i18n/sv/files.ts index c4778446a..ad22dfa7c 100644 --- a/shared/src/i18n/sv/files.ts +++ b/shared/src/i18n/sv/files.ts @@ -11,6 +11,7 @@ const files: TranslationStrings = { 'files.countSingular': '1 fil', 'files.uploaded': '{count} uppladdade', 'files.uploadError': 'Uppladdning misslyckades', + 'files.uploadErrorType': 'Den här filtypen stöds inte', 'files.dropzone': 'Släpp filer här', 'files.dropzoneHint': 'eller klicka för att bläddra', 'files.allowedTypes': 'Foton, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Högst 50 MB', diff --git a/shared/src/i18n/tr/files.ts b/shared/src/i18n/tr/files.ts index c73f426dc..e1fb2558d 100644 --- a/shared/src/i18n/tr/files.ts +++ b/shared/src/i18n/tr/files.ts @@ -11,6 +11,7 @@ const files: TranslationStrings = { 'files.countSingular': '1 dosya', 'files.uploaded': '{count} yüklendi', 'files.uploadError': 'Yükleme başarısız oldu', + 'files.uploadErrorType': 'Bu dosya türü desteklenmiyor', 'files.dropzone': 'Dosyaları buraya bırakın', 'files.dropzoneHint': 'veya göz atmak için tıklayın', 'files.allowedTypes': 'Görsel, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Maks. 50 MB', diff --git a/shared/src/i18n/uk/files.ts b/shared/src/i18n/uk/files.ts index 985acd132..b8d6a8a5e 100644 --- a/shared/src/i18n/uk/files.ts +++ b/shared/src/i18n/uk/files.ts @@ -11,6 +11,7 @@ const files: TranslationStrings = { 'files.countSingular': '1 файл', 'files.uploaded': '{count} завантажено', 'files.uploadError': 'Помилка завантаження', + 'files.uploadErrorType': 'Цей тип файлу не підтримується', 'files.dropzone': 'Перетягніть файли сюди', 'files.dropzoneHint': 'або натисніть для вибору', 'files.allowedTypes': 'Зображення, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Макс. 50 МБ', diff --git a/shared/src/i18n/vi/files.ts b/shared/src/i18n/vi/files.ts index dee3bb0d0..a0c57febc 100644 --- a/shared/src/i18n/vi/files.ts +++ b/shared/src/i18n/vi/files.ts @@ -1,6 +1,7 @@ import type { TranslationStrings } from '../types'; const files: TranslationStrings = { + 'files.uploadErrorType': 'Loại tập tin này không được hỗ trợ', 'files.title': 'Tập tin', 'files.pageTitle': 'Tập tin & Tài liệu', 'files.subtitle': '{count} tệp cho {trip}', diff --git a/shared/src/i18n/zh-TW/files.ts b/shared/src/i18n/zh-TW/files.ts index 9213d84c2..0f7356700 100644 --- a/shared/src/i18n/zh-TW/files.ts +++ b/shared/src/i18n/zh-TW/files.ts @@ -11,6 +11,7 @@ const files: TranslationStrings = { 'files.countSingular': '1 個檔案', 'files.uploaded': '已上傳 {count} 個', 'files.uploadError': '上傳失敗', + 'files.uploadErrorType': '不支援此檔案類型', 'files.dropzone': '將檔案拖放到此處', 'files.dropzoneHint': '或點選瀏覽', 'files.allowedTypes': '圖片、PDF、DOC、DOCX、XLS、XLSX、TXT、CSV · 最大 50 MB', diff --git a/shared/src/i18n/zh/files.ts b/shared/src/i18n/zh/files.ts index 2794a33b7..a19541a2f 100644 --- a/shared/src/i18n/zh/files.ts +++ b/shared/src/i18n/zh/files.ts @@ -11,6 +11,7 @@ const files: TranslationStrings = { 'files.countSingular': '1 个文件', 'files.uploaded': '已上传 {count} 个', 'files.uploadError': '上传失败', + 'files.uploadErrorType': '不支持此文件类型', 'files.dropzone': '将文件拖放到此处', 'files.dropzoneHint': '或点击浏览', 'files.allowedTypes': '图片、PDF、DOC、DOCX、XLS、XLSX、TXT、CSV · 最大 50 MB',