* fix(backups): prevent recursion in path that is backed up

* fix(share): show user currency instead of the default euro in the share page

* fix(files): show descriptive error for unsupported upload type

Unsupported file uploads showed a generic 'Upload failed' toast even
though the server already returns a descriptive 400. The client catch
blocks discarded the error and always showed t('files.uploadError').

The server now emits the i18n key 'files.uploadErrorType' as its error
message; a new translateApiError() helper resolves a server message that
is a known translation key via t() and falls back to the generic key
otherwise. Wired into the three trip-file upload catch sites.

Closes #1363
This commit is contained in:
jubnl
2026-07-01 18:57:16 +02:00
committed by GitHub
parent 0631e34a79
commit 819aa793ae
36 changed files with 275 additions and 18 deletions
@@ -1,7 +1,7 @@
import { useState, useCallback, useEffect } from 'react' import { useState, useCallback, useEffect } from 'react'
import { useDropzone } from 'react-dropzone' import { useDropzone } from 'react-dropzone'
import { useToast } from '../shared/Toast' import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n' import { useTranslation, translateApiError } from '../../i18n'
import { filesApi } from '../../api/client' import { filesApi } from '../../api/client'
import type { Place, Reservation, TripFile, Day, AssignmentsMap } from '../../types' import type { Place, Reservation, TripFile, Day, AssignmentsMap } from '../../types'
import { useCanDo } from '../../store/permissionsStore' 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)) { if (lastId && (places.length > 0 || reservations.length > 0)) {
setAssignFileId(lastId) setAssignFileId(lastId)
} }
} catch { } catch (err) {
toast.error(t('files.uploadError')) toast.error(translateApiError(t, err, 'files.uploadError'))
} finally { } finally {
setUploading(false) setUploading(false)
} }
@@ -9,7 +9,7 @@ import { mapsApi } from '../../api/client'
import { useSettingsStore } from '../../store/settingsStore' import { useSettingsStore } from '../../store/settingsStore'
import { getCategoryIcon } from '../shared/categoryIcons' import { getCategoryIcon } from '../shared/categoryIcons'
import { useToast } from '../shared/Toast' 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 { Place, Category, Day, Assignment, Reservation, TripFile, AssignmentsMap } from '../../types'
import { splitReservationDateTime, formatTime } from '../../utils/formatters' import { splitReservationDateTime, formatTime } from '../../utils/formatters'
import { formatDistance, formatElevation } from '../../utils/units' import { formatDistance, formatElevation } from '../../utils/units'
@@ -189,7 +189,7 @@ export default function PlaceInspector({
setFilesExpanded(true) setFilesExpanded(true)
} catch (err: unknown) { } catch (err: unknown) {
console.error('Upload failed', err) console.error('Upload failed', err)
toast.error(t('files.uploadError')) toast.error(translateApiError(t, err, 'files.uploadError'))
} finally { } finally {
setIsUploading(false) setIsUploading(false)
if (fileInputRef.current) fileInputRef.current.value = '' if (fileInputRef.current) fileInputRef.current.value = ''
+1
View File
@@ -8,3 +8,4 @@ export {
SUPPORTED_LANGUAGES, SUPPORTED_LANGUAGES,
} from './TranslationContext' } from './TranslationContext'
export { TransHtml } from './TransHtml' export { TransHtml } from './TransHtml'
export { translateApiError } from './translateApiError'
+30
View File
@@ -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<string, string> = {
'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')
})
})
+17
View File
@@ -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)
}
+63
View File
@@ -509,4 +509,67 @@ describe('SharedTripPage', () => {
await waitFor(() => expect(screen.getByText('Tag 1')).toBeInTheDocument()); 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));
});
});
}); });
+9 -5
View File
@@ -42,7 +42,7 @@ function FitBoundsToPlaces({ places }: { places: any[] }) {
export default function SharedTripPage() { export default function SharedTripPage() {
const { t, locale } = useTranslation() const { t, locale } = useTranslation()
// Page = wiring container: share fetch + view state live in the hook. // 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 ( if (error) return (
<div className="bg-[#f3f4f6]" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh' }}> <div className="bg-[#f3f4f6]" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh' }}>
@@ -328,26 +328,30 @@ export default function SharedTripPage() {
{/* Budget */} {/* Budget */}
{activeTab === 'budget' && (budget || []).length > 0 && (() => { {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 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 ( return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{/* Total card */} {/* Total card */}
<div className="text-white" style={{ background: 'linear-gradient(135deg, #000 0%, #1a1a2e 100%)', borderRadius: 14, padding: '20px 24px' }}> <div className="text-white" style={{ background: 'linear-gradient(135deg, #000 0%, #1a1a2e 100%)', borderRadius: 14, padding: '20px 24px' }}>
<div style={{ fontSize: 10, fontWeight: 500, letterSpacing: 1, textTransform: 'uppercase', opacity: 0.5 }}>{t('shared.totalBudget')}</div> <div style={{ fontSize: 10, fontWeight: 500, letterSpacing: 1, textTransform: 'uppercase', opacity: 0.5 }}>{t('shared.totalBudget')}</div>
<div style={{ fontSize: 28, fontWeight: 700, marginTop: 4 }}>{total.toLocaleString(locale, { minimumFractionDigits: 2 })} {trip.currency || 'EUR'}</div> <div style={{ fontSize: 28, fontWeight: 700, marginTop: 4 }}>{total.toLocaleString(locale, { minimumFractionDigits: 2 })} {base}</div>
</div> </div>
{/* By category */} {/* By category */}
{Object.entries(grouped).map(([cat, items]: [string, any]) => ( {Object.entries(grouped).map(([cat, items]: [string, any]) => (
<div key={cat} className="bg-surface-card border border-edge-faint" style={{ borderRadius: 12, overflow: 'hidden' }}> <div key={cat} className="bg-surface-card border border-edge-faint" style={{ borderRadius: 12, overflow: 'hidden' }}>
<div className="bg-[#f9fafb]" style={{ padding: '10px 16px', display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderBottom: '1px solid #f3f4f6' }}> <div className="bg-[#f9fafb]" style={{ padding: '10px 16px', display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderBottom: '1px solid #f3f4f6' }}>
<span className="text-[#374151]" style={{ fontSize: 12, fontWeight: 700 }}>{cat}</span> <span className="text-[#374151]" style={{ fontSize: 12, fontWeight: 700 }}>{cat}</span>
<span className="text-[#6b7280]" style={{ fontSize: 12, fontWeight: 600 }}>{items.reduce((s: number, i: any) => s + (parseFloat(i.total_price) || 0), 0).toLocaleString(locale, { minimumFractionDigits: 2 })} {trip.currency || ''}</span> <span className="text-[#6b7280]" style={{ fontSize: 12, fontWeight: 600 }}>{sumIn(items).toLocaleString(locale, { minimumFractionDigits: 2 })} {base}</span>
</div> </div>
{items.map((item: any) => ( {items.map((item: any) => (
<div key={item.id} style={{ padding: '8px 16px', display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderBottom: '1px solid #fafafa' }}> <div key={item.id} style={{ padding: '8px 16px', display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderBottom: '1px solid #fafafa' }}>
<span className="text-[#111827]" style={{ fontSize: 13 }}>{item.name}</span> <span className="text-[#111827]" style={{ fontSize: 13 }}>{item.name}</span>
<span className="text-[#111827]" style={{ fontSize: 13, fontWeight: 600 }}>{item.total_price ? Number(item.total_price).toLocaleString(locale, { minimumFractionDigits: 2 }) : '—'}</span> <span className="text-[#111827]" style={{ fontSize: 13, fontWeight: 600 }}>{item.total_price ? `${convert(parseFloat(item.total_price) || 0, curOf(item)).toLocaleString(locale, { minimumFractionDigits: 2 })} ${base}` : '—'}</span>
</div> </div>
))} ))}
</div> </div>
+9 -1
View File
@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useParams } from 'react-router-dom' import { useParams } from 'react-router-dom'
import { shareApi } from '../../api/client' import { shareApi } from '../../api/client'
import { useExchangeRates } from '../../hooks/useExchangeRates'
/** /**
* Shared-trip (public) data hook — owns the token lookup, the read-only share * 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)) shareApi.getSharedTrip(token).then(setData).catch(() => setError(true))
}, [token]) }, [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 }
} }
@@ -6,7 +6,7 @@ import { useSettingsStore } from '../../store/settingsStore'
import { getCached, fetchPhoto } from '../../services/photoService' import { getCached, fetchPhoto } from '../../services/photoService'
import { useToast } from '../../components/shared/Toast' import { useToast } from '../../components/shared/Toast'
import { Map, Ticket, PackageCheck, Wallet, FolderOpen, Users, Train } from 'lucide-react' 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 } from '../../api/client' import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi, healthApi, airtrailApi } from '../../api/client'
import { accommodationRepo } from '../../repo/accommodationRepo' import { accommodationRepo } from '../../repo/accommodationRepo'
import { offlineDb } from '../../db/offlineDb' import { offlineDb } from '../../db/offlineDb'
@@ -400,7 +400,7 @@ export function useTripPlanner() {
const fd = new FormData() const fd = new FormData()
fd.append('file', file) fd.append('file', file)
fd.append('place_id', String(editingPlace.id)) 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')) toast.success(t('trip.toast.placeUpdated'))
@@ -411,7 +411,7 @@ export function useTripPlanner() {
const fd = new FormData() const fd = new FormData()
fd.append('file', file) fd.append('file', file)
fd.append('place_id', String(place.id)) 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')) toast.success(t('trip.toast.placeAdded'))
+2 -1
View File
@@ -37,7 +37,8 @@ const UPLOAD = {
fileFilter: (_req: unknown, file: Express.Multer.File, cb: (err: Error | null, accept: boolean) => void) => { fileFilter: (_req: unknown, file: Express.Multer.File, cb: (err: Error | null, accept: boolean) => void) => {
const ext = path.extname(file.originalname).toLowerCase(); const ext = path.extname(file.originalname).toLowerCase();
const reject = () => { 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; err.statusCode = 400;
cb(err, false); cb(err, false);
}; };
+13 -1
View File
@@ -184,9 +184,21 @@ export async function createBackup(): Promise<BackupInfo> {
// Exclude the place-photo and trek-memory caches: both are re-derivable // Exclude the place-photo and trek-memory caches: both are re-derivable
// (re-fetched on demand, keyed on stable ids) and would otherwise dominate // (re-fetched on demand, keyed on stable ids) and would otherwise dominate
// backup size. Restores self-heal — the cache dirs are recreated at startup. // 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( 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' }, { prefix: 'uploads' },
); );
} }
+17 -1
View File
@@ -2,6 +2,7 @@ import { db, canAccessTrip } from '../db/database';
import crypto from 'crypto'; import crypto from 'crypto';
import { loadTagsByPlaceIds } from './queryHelpers'; import { loadTagsByPlaceIds } from './queryHelpers';
import { serveFilePath } from './placePhotoCache'; import { serveFilePath } from './placePhotoCache';
import { getUserSettings } from './settingsService';
const PLACE_PHOTO_PROXY_PREFIX = '/api/maps/place-photo/'; const PLACE_PHOTO_PROXY_PREFIX = '/api/maps/place-photo/';
@@ -219,8 +220,23 @@ export function getSharedTripData(token: string): Record<string, any> | 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) ? 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 { return {
trip, days, assignments, dayNotes, places, categories, permissions, trip, baseCurrency, days, assignments, dayNotes, places, categories, permissions,
reservations: permissions.share_bookings ? reservations : [], reservations: permissions.share_bookings ? reservations : [],
accommodations: permissions.share_bookings ? accommodations : [], accommodations: permissions.share_bookings ? accommodations : [],
packing: permissions.share_packing ? packing : [], packing: permissions.share_packing ? packing : [],
+2
View File
@@ -126,6 +126,8 @@ describe('Upload file', () => {
.set('Cookie', authCookie(user.id)) .set('Cookie', authCookie(user.id))
.attach('file', svgPath); .attach('file', svgPath);
expect(res.status).toBe(400); 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 { } finally {
if (fs.existsSync(svgPath)) fs.unlinkSync(svgPath); if (fs.existsSync(svgPath)) fs.unlinkSync(svgPath);
} }
+50
View File
@@ -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)', () => { describe('Shared trip — place photos in shared links (issue #1100)', () => {
const PLACE_ID = 'ChIJsharedPhoto1100'; const PLACE_ID = 'ChIJsharedPhoto1100';
const PROXY_URL = `/api/maps/place-photo/${encodeURIComponent(PLACE_ID)}/bytes`; const PROXY_URL = `/api/maps/place-photo/${encodeURIComponent(PLACE_ID)}/bytes`;
@@ -475,7 +475,7 @@ describe('BACKUP-036 createBackup', () => {
'**/*', '**/*',
expect.objectContaining({ expect.objectContaining({
cwd: expect.stringContaining('uploads'), cwd: expect.stringContaining('uploads'),
ignore: ['photos/google/**', 'photos/trek/**'], ignore: ['photos/google/**', 'photos/trek/**', 'backups/**', 'restore-*/**'],
}), }),
{ prefix: 'uploads' }, { prefix: 'uploads' },
); );
@@ -483,6 +483,38 @@ describe('BACKUP-036 createBackup', () => {
expect(archiverInstanceMock.directory).not.toHaveBeenCalled(); 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<string, Function> = {};
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 () => { it('BACKUP-036f — bundles .encryption_key when present and ENCRYPTION_KEY env is unset', async () => {
const prevEnvKey = process.env.ENCRYPTION_KEY; const prevEnvKey = process.env.ENCRYPTION_KEY;
delete process.env.ENCRYPTION_KEY; delete process.env.ENCRYPTION_KEY;
+1
View File
@@ -11,6 +11,7 @@ const files: TranslationStrings = {
'files.countSingular': 'ملف واحد', 'files.countSingular': 'ملف واحد',
'files.uploaded': 'تم رفع {count}', 'files.uploaded': 'تم رفع {count}',
'files.uploadError': 'فشل الرفع', 'files.uploadError': 'فشل الرفع',
'files.uploadErrorType': 'نوع الملف هذا غير مدعوم',
'files.dropzone': 'أسقط الملفات هنا', 'files.dropzone': 'أسقط الملفات هنا',
'files.dropzoneHint': 'أو انقر للتصفح', 'files.dropzoneHint': 'أو انقر للتصفح',
'files.allowedTypes': 'صور، PDF، DOC، DOCX، XLS، XLSX، TXT، CSV · حد أقصى 50 ميغابايت', 'files.allowedTypes': 'صور، PDF، DOC، DOCX، XLS، XLSX، TXT، CSV · حد أقصى 50 ميغابايت',
+1
View File
@@ -11,6 +11,7 @@ const files: TranslationStrings = {
'files.countSingular': '1 arquivo', 'files.countSingular': '1 arquivo',
'files.uploaded': '{count} enviado(s)', 'files.uploaded': '{count} enviado(s)',
'files.uploadError': 'Falha no envio', 'files.uploadError': 'Falha no envio',
'files.uploadErrorType': 'Esse tipo de arquivo não é suportado',
'files.dropzone': 'Solte os arquivos aqui', 'files.dropzone': 'Solte os arquivos aqui',
'files.dropzoneHint': 'ou clique para escolher', 'files.dropzoneHint': 'ou clique para escolher',
'files.allowedTypes': 'Imagens, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Máx. 50 MB', 'files.allowedTypes': 'Imagens, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Máx. 50 MB',
+1
View File
@@ -11,6 +11,7 @@ const files: TranslationStrings = {
'files.countSingular': '1 soubor', 'files.countSingular': '1 soubor',
'files.uploaded': '{count} nahráno', 'files.uploaded': '{count} nahráno',
'files.uploadError': 'Nahrávání se nezdařilo', '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.dropzone': 'Přetáhněte soubory sem',
'files.dropzoneHint': 'nebo klikněte pro výběr', 'files.dropzoneHint': 'nebo klikněte pro výběr',
'files.allowedTypes': 'Obrázky, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Max 50 MB', 'files.allowedTypes': 'Obrázky, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Max 50 MB',
+1
View File
@@ -11,6 +11,7 @@ const files: TranslationStrings = {
'files.countSingular': '1 Datei', 'files.countSingular': '1 Datei',
'files.uploaded': '{count} hochgeladen', 'files.uploaded': '{count} hochgeladen',
'files.uploadError': 'Fehler beim Hochladen', 'files.uploadError': 'Fehler beim Hochladen',
'files.uploadErrorType': 'Dieser Dateityp wird nicht unterstützt',
'files.dropzone': 'Dateien hier ablegen', 'files.dropzone': 'Dateien hier ablegen',
'files.dropzoneHint': 'oder klicken zum Auswählen', 'files.dropzoneHint': 'oder klicken zum Auswählen',
'files.allowedTypes': 'Bilder, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Max 50 MB', 'files.allowedTypes': 'Bilder, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Max 50 MB',
+1
View File
@@ -11,6 +11,7 @@ const files: TranslationStrings = {
'files.countSingular': '1 file', 'files.countSingular': '1 file',
'files.uploaded': '{count} uploaded', 'files.uploaded': '{count} uploaded',
'files.uploadError': 'Upload failed', 'files.uploadError': 'Upload failed',
'files.uploadErrorType': "This file type isn't supported",
'files.dropzone': 'Drop files here', 'files.dropzone': 'Drop files here',
'files.dropzoneHint': 'or click to browse', 'files.dropzoneHint': 'or click to browse',
'files.allowedTypes': 'Images, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Max 50 MB', 'files.allowedTypes': 'Images, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Max 50 MB',
+1
View File
@@ -11,6 +11,7 @@ const files: TranslationStrings = {
'files.countSingular': '1 archivo', 'files.countSingular': '1 archivo',
'files.uploaded': '{count} archivos subidos', 'files.uploaded': '{count} archivos subidos',
'files.uploadError': 'La subida falló', 'files.uploadError': 'La subida falló',
'files.uploadErrorType': 'Este tipo de archivo no es compatible',
'files.dropzone': 'Arrastra aquí los archivos', 'files.dropzone': 'Arrastra aquí los archivos',
'files.dropzoneHint': 'o haz clic para explorar', 'files.dropzoneHint': 'o haz clic para explorar',
'files.allowedTypes': 'Imágenes, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Máx. 50 MB', 'files.allowedTypes': 'Imágenes, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Máx. 50 MB',
+1
View File
@@ -11,6 +11,7 @@ const files: TranslationStrings = {
'files.countSingular': '1 fichier', 'files.countSingular': '1 fichier',
'files.uploaded': '{count} importés', 'files.uploaded': '{count} importés',
'files.uploadError': "Échec de l'import", '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.dropzone': 'Déposez les fichiers ici',
'files.dropzoneHint': 'ou cliquez pour parcourir', 'files.dropzoneHint': 'ou cliquez pour parcourir',
'files.allowedTypes': 'Images, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Max 50 Mo', 'files.allowedTypes': 'Images, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Max 50 Mo',
+1
View File
@@ -11,6 +11,7 @@ const files: TranslationStrings = {
'files.countSingular': '1 αρχείο', 'files.countSingular': '1 αρχείο',
'files.uploaded': '{count} μεταφορτώθηκαν', 'files.uploaded': '{count} μεταφορτώθηκαν',
'files.uploadError': 'Η μεταφόρτωση απέτυχε', 'files.uploadError': 'Η μεταφόρτωση απέτυχε',
'files.uploadErrorType': 'Αυτός ο τύπος αρχείου δεν υποστηρίζεται',
'files.dropzone': 'Αποθέστε αρχεία εδώ', 'files.dropzone': 'Αποθέστε αρχεία εδώ',
'files.dropzoneHint': 'ή κάντε κλικ για περιήγηση', 'files.dropzoneHint': 'ή κάντε κλικ για περιήγηση',
'files.allowedTypes': 'Εικόνες, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Μέγ. 50 MB', 'files.allowedTypes': 'Εικόνες, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Μέγ. 50 MB',
+1
View File
@@ -11,6 +11,7 @@ const files: TranslationStrings = {
'files.countSingular': '1 fájl', 'files.countSingular': '1 fájl',
'files.uploaded': '{count} feltöltve', 'files.uploaded': '{count} feltöltve',
'files.uploadError': 'Feltöltés sikertelen', '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.dropzone': 'Húzd ide a fájlokat',
'files.dropzoneHint': 'vagy kattints a böngészéshez', 'files.dropzoneHint': 'vagy kattints a böngészéshez',
'files.allowedTypes': 'Képek, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Max 50 MB', 'files.allowedTypes': 'Képek, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Max 50 MB',
+1
View File
@@ -11,6 +11,7 @@ const files: TranslationStrings = {
'files.countSingular': '1 berkas', 'files.countSingular': '1 berkas',
'files.uploaded': '{count} diunggah', 'files.uploaded': '{count} diunggah',
'files.uploadError': 'Gagal mengunggah', 'files.uploadError': 'Gagal mengunggah',
'files.uploadErrorType': 'Tipe file ini tidak didukung',
'files.dropzone': 'Jatuhkan file di sini', 'files.dropzone': 'Jatuhkan file di sini',
'files.dropzoneHint': 'atau klik untuk memilih', 'files.dropzoneHint': 'atau klik untuk memilih',
'files.allowedTypes': 'Gambar, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Maks 50 MB', 'files.allowedTypes': 'Gambar, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Maks 50 MB',
+1
View File
@@ -11,6 +11,7 @@ const files: TranslationStrings = {
'files.countSingular': '1 documento', 'files.countSingular': '1 documento',
'files.uploaded': '{count} caricati', 'files.uploaded': '{count} caricati',
'files.uploadError': 'Caricamento non riuscito', 'files.uploadError': 'Caricamento non riuscito',
'files.uploadErrorType': 'Questo tipo di file non è supportato',
'files.dropzone': 'Trascina qui i file', 'files.dropzone': 'Trascina qui i file',
'files.dropzoneHint': 'oppure clicca per sfogliare', 'files.dropzoneHint': 'oppure clicca per sfogliare',
'files.allowedTypes': 'Immagini, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Max 50 MB', 'files.allowedTypes': 'Immagini, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Max 50 MB',
+1
View File
@@ -11,6 +11,7 @@ const files: TranslationStrings = {
'files.countSingular': '1件のファイル', 'files.countSingular': '1件のファイル',
'files.uploaded': '{count}件アップロード', 'files.uploaded': '{count}件アップロード',
'files.uploadError': 'アップロードに失敗しました', 'files.uploadError': 'アップロードに失敗しました',
'files.uploadErrorType': 'このファイル形式はサポートされていません',
'files.dropzone': 'ここにファイルをドロップ', 'files.dropzone': 'ここにファイルをドロップ',
'files.dropzoneHint': 'またはクリックして参照', 'files.dropzoneHint': 'またはクリックして参照',
'files.allowedTypes': '画像、PDF、DOC、DOCX、XLS、XLSX、TXT、CSV · 最大50MB', 'files.allowedTypes': '画像、PDF、DOC、DOCX、XLS、XLSX、TXT、CSV · 最大50MB',
+1
View File
@@ -11,6 +11,7 @@ const files: TranslationStrings = {
'files.countSingular': '파일 1개', 'files.countSingular': '파일 1개',
'files.uploaded': '{count}개 업로드됨', 'files.uploaded': '{count}개 업로드됨',
'files.uploadError': '업로드 실패', 'files.uploadError': '업로드 실패',
'files.uploadErrorType': '지원되지 않는 파일 형식입니다',
'files.dropzone': '여기에 파일을 놓으세요', 'files.dropzone': '여기에 파일을 놓으세요',
'files.dropzoneHint': '또는 클릭하여 탐색', 'files.dropzoneHint': '또는 클릭하여 탐색',
'files.allowedTypes': '이미지, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · 최대 50 MB', 'files.allowedTypes': '이미지, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · 최대 50 MB',
+1
View File
@@ -11,6 +11,7 @@ const files: TranslationStrings = {
'files.countSingular': '1 bestand', 'files.countSingular': '1 bestand',
'files.uploaded': '{count} geüpload', 'files.uploaded': '{count} geüpload',
'files.uploadError': 'Uploaden mislukt', 'files.uploadError': 'Uploaden mislukt',
'files.uploadErrorType': 'Dit bestandstype wordt niet ondersteund',
'files.dropzone': 'Sleep bestanden hierheen', 'files.dropzone': 'Sleep bestanden hierheen',
'files.dropzoneHint': 'of klik om te bladeren', 'files.dropzoneHint': 'of klik om te bladeren',
'files.allowedTypes': 'Afbeeldingen, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Max 50 MB', 'files.allowedTypes': 'Afbeeldingen, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Max 50 MB',
+1
View File
@@ -11,6 +11,7 @@ const files: TranslationStrings = {
'files.countSingular': '1 plik', 'files.countSingular': '1 plik',
'files.uploaded': '{count} przesłanych', 'files.uploaded': '{count} przesłanych',
'files.uploadError': 'Przesyłanie nie powiodło się', 'files.uploadError': 'Przesyłanie nie powiodło się',
'files.uploadErrorType': 'Ten typ pliku nie jest obsługiwany',
'files.dropzone': 'Przeciągnij pliki tutaj', 'files.dropzone': 'Przeciągnij pliki tutaj',
'files.dropzoneHint': 'lub kliknij, aby przeglądać', 'files.dropzoneHint': 'lub kliknij, aby przeglądać',
'files.allowedTypes': 'Obrazki, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Maks 50 MB', 'files.allowedTypes': 'Obrazki, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Maks 50 MB',
+1
View File
@@ -11,6 +11,7 @@ const files: TranslationStrings = {
'files.countSingular': '1 файл', 'files.countSingular': '1 файл',
'files.uploaded': '{count} загружено', 'files.uploaded': '{count} загружено',
'files.uploadError': 'Ошибка загрузки', 'files.uploadError': 'Ошибка загрузки',
'files.uploadErrorType': 'Этот тип файла не поддерживается',
'files.dropzone': 'Перетащите файлы сюда', 'files.dropzone': 'Перетащите файлы сюда',
'files.dropzoneHint': 'или нажмите для выбора', 'files.dropzoneHint': 'или нажмите для выбора',
'files.allowedTypes': 'Изображения, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Макс. 50 МБ', 'files.allowedTypes': 'Изображения, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Макс. 50 МБ',
+1
View File
@@ -11,6 +11,7 @@ const files: TranslationStrings = {
'files.countSingular': '1 fil', 'files.countSingular': '1 fil',
'files.uploaded': '{count} uppladdade', 'files.uploaded': '{count} uppladdade',
'files.uploadError': 'Uppladdning misslyckades', 'files.uploadError': 'Uppladdning misslyckades',
'files.uploadErrorType': 'Den här filtypen stöds inte',
'files.dropzone': 'Släpp filer här', 'files.dropzone': 'Släpp filer här',
'files.dropzoneHint': 'eller klicka för att bläddra', 'files.dropzoneHint': 'eller klicka för att bläddra',
'files.allowedTypes': 'Foton, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Högst 50 MB', 'files.allowedTypes': 'Foton, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Högst 50 MB',
+1
View File
@@ -11,6 +11,7 @@ const files: TranslationStrings = {
'files.countSingular': '1 dosya', 'files.countSingular': '1 dosya',
'files.uploaded': '{count} yüklendi', 'files.uploaded': '{count} yüklendi',
'files.uploadError': 'Yükleme başarısız oldu', 'files.uploadError': 'Yükleme başarısız oldu',
'files.uploadErrorType': 'Bu dosya türü desteklenmiyor',
'files.dropzone': 'Dosyaları buraya bırakın', 'files.dropzone': 'Dosyaları buraya bırakın',
'files.dropzoneHint': 'veya göz atmak için tıklayı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', 'files.allowedTypes': 'Görsel, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Maks. 50 MB',
+1
View File
@@ -11,6 +11,7 @@ const files: TranslationStrings = {
'files.countSingular': '1 файл', 'files.countSingular': '1 файл',
'files.uploaded': '{count} завантажено', 'files.uploaded': '{count} завантажено',
'files.uploadError': 'Помилка завантаження', 'files.uploadError': 'Помилка завантаження',
'files.uploadErrorType': 'Цей тип файлу не підтримується',
'files.dropzone': 'Перетягніть файли сюди', 'files.dropzone': 'Перетягніть файли сюди',
'files.dropzoneHint': 'або натисніть для вибору', 'files.dropzoneHint': 'або натисніть для вибору',
'files.allowedTypes': 'Зображення, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Макс. 50 МБ', 'files.allowedTypes': 'Зображення, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Макс. 50 МБ',
+1
View File
@@ -11,6 +11,7 @@ const files: TranslationStrings = {
'files.countSingular': '1 個檔案', 'files.countSingular': '1 個檔案',
'files.uploaded': '已上傳 {count} 個', 'files.uploaded': '已上傳 {count} 個',
'files.uploadError': '上傳失敗', 'files.uploadError': '上傳失敗',
'files.uploadErrorType': '不支援此檔案類型',
'files.dropzone': '將檔案拖放到此處', 'files.dropzone': '將檔案拖放到此處',
'files.dropzoneHint': '或點選瀏覽', 'files.dropzoneHint': '或點選瀏覽',
'files.allowedTypes': '圖片、PDF、DOC、DOCX、XLS、XLSX、TXT、CSV · 最大 50 MB', 'files.allowedTypes': '圖片、PDF、DOC、DOCX、XLS、XLSX、TXT、CSV · 最大 50 MB',
+1
View File
@@ -11,6 +11,7 @@ const files: TranslationStrings = {
'files.countSingular': '1 个文件', 'files.countSingular': '1 个文件',
'files.uploaded': '已上传 {count} 个', 'files.uploaded': '已上传 {count} 个',
'files.uploadError': '上传失败', 'files.uploadError': '上传失败',
'files.uploadErrorType': '不支持此文件类型',
'files.dropzone': '将文件拖放到此处', 'files.dropzone': '将文件拖放到此处',
'files.dropzoneHint': '或点击浏览', 'files.dropzoneHint': '或点击浏览',
'files.allowedTypes': '图片、PDF、DOC、DOCX、XLS、XLSX、TXT、CSV · 最大 50 MB', 'files.allowedTypes': '图片、PDF、DOC、DOCX、XLS、XLSX、TXT、CSV · 最大 50 MB',