From a8e4c455761f276eb3ab1de1b958561b93b93766 Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 11 Sep 2026 11:01:25 +0200 Subject: [PATCH] feat(share): show a stop and a booking in full on a shared link, and no more (#2320) A public link showed a stop as its name and one line under it, the address or the description, whichever came first. The owner had written both, and a note on the place and another on the day, and had set how long they meant to stay and how to reach it. All of that shows now, laid out the way the planner lays it out, and every link leaves the page in a new tab with no opener. A booking shows the note the owner attached and the page they linked, labelled by where it goes. What travels to the page is chosen column by column now, where it used to be the whole row. The whole row of a booking carried its confirmation number, its import trail and its raw metadata, and the metadata is where an imported ticket keeps its seat and its record locator; the whole row of a place carried the owner's booking notes on it and the Google identifiers. None of that belongs to somebody holding a link. Metadata keeps the parts that describe the journey, by an allow-list, so a field the import starts writing tomorrow does not become public by appearing. Links are accepted only as http or https, on the server and again on the page. --- client/src/pages/SharedTripPage.test.tsx | 98 ++++++++++++ client/src/pages/SharedTripPage.tsx | 21 +-- .../pages/sharedTrip/SharedBookingDetails.tsx | 44 +++++ .../pages/sharedTrip/SharedPlaceDetails.tsx | 103 ++++++++++++ .../pages/sharedTrip/sharedTripModel.test.ts | 37 +++++ .../src/pages/sharedTrip/sharedTripModel.ts | 48 ++++++ server/src/nest/share/share.service.ts | 151 +++++++++++++++++- server/tests/unit/nest/share.service.test.ts | 136 +++++++++++++++- 8 files changed, 617 insertions(+), 21 deletions(-) create mode 100644 client/src/pages/sharedTrip/SharedBookingDetails.tsx create mode 100644 client/src/pages/sharedTrip/SharedPlaceDetails.tsx create mode 100644 client/src/pages/sharedTrip/sharedTripModel.test.ts create mode 100644 client/src/pages/sharedTrip/sharedTripModel.ts diff --git a/client/src/pages/SharedTripPage.test.tsx b/client/src/pages/SharedTripPage.test.tsx index 6737651f0..68efc409a 100644 --- a/client/src/pages/SharedTripPage.test.tsx +++ b/client/src/pages/SharedTripPage.test.tsx @@ -1352,4 +1352,102 @@ describe('SharedTripPage', () => { await waitFor(() => expect(screen.getByText(/Airport Parking/)).toBeInTheDocument()); }); }); + + // ── #2320: the richer read-only detail ───────────────────────────────── + + describe('FE-PAGE-SHARED-038: a stop shows its address, description, both notes, duration and contact (#2320)', () => { + const days = [{ id: 21, trip_id: 1, day_number: 1, date: '2026-07-01', title: 'Day One' }]; + const assignments = { + 21: [ + { + id: 301, day_id: 21, order_index: 0, notes: 'go early, before the coaches', + place: { + id: 501, name: 'Louvre', description: 'The big one', address: 'Rue de Rivoli, Paris', + lat: 48.86, lng: 2.34, category_id: null, price: null, place_time: '09:00', end_time: null, + duration_minutes: 150, notes: 'Skip the pyramid queue', website: 'https://louvre.fr', + phone: '+33 1 40 20 50 50', image_url: null, transport_mode: 'walking', category: null, tags: [], + }, + }, + ], + }; + + it('renders every field the owner filled, as separate lines', async () => { + await open('detail-token', payload({ days, assignments })); + fireEvent.click(screen.getByText('Day One')); + // The name is also on the map marker's tooltip, so the list row is the one with the address under it. + await waitFor(() => expect(screen.getByText('Rue de Rivoli, Paris')).toBeInTheDocument()); + expect(screen.getAllByText('Louvre').length).toBeGreaterThan(0); + + expect(screen.getByText('The big one')).toBeInTheDocument(); + expect(screen.getByText('go early, before the coaches')).toBeInTheDocument(); + expect(screen.getByText('Skip the pyramid queue')).toBeInTheDocument(); + expect(screen.getByText('2 h 30 min')).toBeInTheDocument(); + + const website = screen.getByRole('link', { name: /website/i }); + expect(website).toHaveAttribute('href', 'https://louvre.fr'); + expect(website).toHaveAttribute('target', '_blank'); + expect(website).toHaveAttribute('rel', 'noopener noreferrer'); + + const maps = screen.getByRole('link', { name: /google maps/i }); + expect(maps.getAttribute('href')).toContain('google.com/maps/search/'); + expect(maps.getAttribute('href')).toContain(encodeURIComponent('Louvre, Rue de Rivoli, Paris')); + expect(maps).toHaveAttribute('rel', 'noopener noreferrer'); + + const phone = screen.getByRole('link', { name: '+33 1 40 20 50 50' }); + expect(phone).toHaveAttribute('href', 'tel:+33140205050'); + }); + + it('leaves out what is empty and never renders a link that is not http(s)', async () => { + const bare = { + 21: [{ + id: 302, day_id: 21, order_index: 0, notes: null, + place: { + id: 502, name: 'Somewhere', description: null, address: null, lat: null, lng: null, + duration_minutes: 0, notes: ' ', website: 'javascript:alert(1)', phone: null, + category_id: null, price: null, place_time: null, end_time: null, image_url: null, + transport_mode: 'walking', category: null, tags: [], + }, + }], + }; + await open('bare-token', payload({ days, assignments: bare })); + fireEvent.click(screen.getByText('Day One')); + await waitFor(() => expect(screen.getByText('Somewhere')).toBeInTheDocument()); + + expect(screen.queryByRole('link', { name: /website/i })).toBeNull(); + expect(screen.queryByRole('link', { name: /google maps/i })).toBeNull(); + expect(document.querySelector('a[href^="javascript:"]')).toBeNull(); + expect(screen.queryByText(/min$/)).toBeNull(); + }); + }); + + describe('FE-PAGE-SHARED-039: a booking shows its note and its link, and nothing that is not http(s) (#2320)', () => { + it('renders the note and a link labelled by its host', async () => { + await open('booking-token', payload({ + permissions: { share_bookings: true, share_packing: false, share_budget: false, share_collab: false }, + reservations: [ + { + id: 91, title: 'Night train', type: 'train', status: 'confirmed', day_id: null, end_day_id: null, + reservation_time: '2026-07-01T21:00:00', reservation_end_time: null, metadata: null, + notes: 'Meet at the north entrance', url: 'https://www.bahn.example/booking/abc', + }, + { + id: 92, title: 'Sketchy', type: 'other', status: 'pending', day_id: null, end_day_id: null, + reservation_time: null, reservation_end_time: null, metadata: null, + notes: null, url: 'javascript:alert(1)', + }, + ], + })); + fireEvent.click(screen.getByText('Bookings')); + await waitFor(() => expect(screen.getByText('Night train')).toBeInTheDocument()); + + expect(screen.getByText('Meet at the north entrance')).toBeInTheDocument(); + const link = screen.getByRole('link', { name: 'bahn.example' }); + expect(link).toHaveAttribute('href', 'https://www.bahn.example/booking/abc'); + expect(link).toHaveAttribute('target', '_blank'); + expect(link).toHaveAttribute('rel', 'noopener noreferrer'); + + expect(screen.getByText('Sketchy')).toBeInTheDocument(); + expect(document.querySelector('a[href^="javascript:"]')).toBeNull(); + }); + }); }); diff --git a/client/src/pages/SharedTripPage.tsx b/client/src/pages/SharedTripPage.tsx index 169e5d4db..c9dadaf61 100644 --- a/client/src/pages/SharedTripPage.tsx +++ b/client/src/pages/SharedTripPage.tsx @@ -32,6 +32,8 @@ import { splitReservationDateTime } from '../utils/formatters'; import { computeMapViewport, TILE_SIZE_RASTER } from '../utils/mapViewport'; import { resolveBasemap } from '../utils/tileUrl'; import { useSharedTrip } from './sharedTrip/useSharedTrip'; +import { SharedPlaceDetails } from './sharedTrip/SharedPlaceDetails'; +import { SharedBookingDetails } from './sharedTrip/SharedBookingDetails'; const TRANSPORT_ICONS = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship }; @@ -738,7 +740,7 @@ export default function SharedTripPage() { key={`p-${item.data.id}`} style={{ display: 'flex', - alignItems: 'center', + alignItems: 'flex-start', gap: 10, padding: '6px 8px', borderRadius: 6, @@ -773,19 +775,7 @@ export default function SharedTripPage() { > {place.name} - {(place.address || place.description) && ( -
- {place.address || place.description} -
- )} + {place.place_time && (
)}
+ + {note && ( +
{/* theme-lint-disable — public page, no user theme */} + + {note} +
+ )} + {link && ( + + + {linkHost(link)} + + )} + + ) +} diff --git a/client/src/pages/sharedTrip/SharedPlaceDetails.tsx b/client/src/pages/sharedTrip/SharedPlaceDetails.tsx new file mode 100644 index 000000000..0526ed858 --- /dev/null +++ b/client/src/pages/sharedTrip/SharedPlaceDetails.tsx @@ -0,0 +1,103 @@ +import { Clock, ExternalLink, FileText, Globe, MapPin, Phone } from 'lucide-react' +import { useTranslation } from '../../i18n' +import { getGoogleMapsUrlForPlace } from '../../components/Planner/placeGoogleMaps' +import { formatDurationMinutes, isHttpUrl } from './sharedTripModel' + +/** + * The read-only detail of one stop on a shared plan (#2320). + * + * The page used to show a place as a name and, under it, the address *or* the + * description, one string on one line. The owner had written both, and had + * usually also left a note on the place and another on the day it was + * planned for, and a link would show none of that. This shows what the owner + * chose to share, laid out the way the planner lays it out: address, then + * description, then the two notes, then how long they meant to stay, then + * the ways to reach the place. + * + * Nothing here is editable and nothing here is a route into the app: every + * link leaves the page, in a new tab, with no opener. The server has already + * decided what a public viewer may see; this only renders what arrived. + */ +export interface SharedPlaceLike { + name: string + address?: string | null + description?: string | null + notes?: string | null + lat?: number | null + lng?: number | null + duration_minutes?: number | null + website?: string | null + phone?: string | null + place_time?: string | null + end_time?: string | null +} + +export function SharedPlaceDetails({ place, assignmentNotes }: { place: SharedPlaceLike; assignmentNotes?: string | null }) { + const { t } = useTranslation() + const website = isHttpUrl(place.website) ? place.website : null + const phone = place.phone?.trim() || null + const mapsUrl = getGoogleMapsUrlForPlace({ + name: place.name, address: place.address ?? null, lat: place.lat ?? null, lng: place.lng ?? null, + google_place_id: null, google_ftid: null, + }) + const duration = formatDurationMinutes(place.duration_minutes) + const dayNote = assignmentNotes?.trim() || null + const placeNote = place.notes?.trim() || null + const hasLinks = !!(website || phone || mapsUrl) + + return ( +
+ {place.address && ( +
{/* theme-lint-disable — public page, no user theme */} + + {place.address} +
+ )} + {place.description && ( +
{/* theme-lint-disable — public page, no user theme */} + {place.description} +
+ )} + {dayNote && ( +
{/* theme-lint-disable — public page, no user theme */} + + {dayNote} +
+ )} + {placeNote && ( +
{/* theme-lint-disable — public page, no user theme */} + + {placeNote} +
+ )} + {(duration || hasLinks) && ( + + )} +
+ ) +} diff --git a/client/src/pages/sharedTrip/sharedTripModel.test.ts b/client/src/pages/sharedTrip/sharedTripModel.test.ts new file mode 100644 index 000000000..7858e44f6 --- /dev/null +++ b/client/src/pages/sharedTrip/sharedTripModel.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from 'vitest' +import { formatDurationMinutes, isHttpUrl, linkHost } from './sharedTripModel' + +describe('sharedTripModel (#2320)', () => { + it('accepts http and https and nothing else', () => { + expect(isHttpUrl('https://a.example/x')).toBe(true) + expect(isHttpUrl(' http://a.example ')).toBe(true) + expect(isHttpUrl('javascript:alert(1)')).toBe(false) + expect(isHttpUrl('data:text/html,hi')).toBe(false) + expect(isHttpUrl('ftp://a.example')).toBe(false) + expect(isHttpUrl('not a url')).toBe(false) + expect(isHttpUrl('')).toBe(false) + expect(isHttpUrl(null)).toBe(false) + expect(isHttpUrl(42)).toBe(false) + }) + + it('writes a planned stay as minutes, hours, or both', () => { + expect(formatDurationMinutes(45)).toBe('45 min') + expect(formatDurationMinutes(60)).toBe('1 h') + expect(formatDurationMinutes(150)).toBe('2 h 30 min') + expect(formatDurationMinutes(89.6)).toBe('1 h 30 min') + }) + + it('writes nothing for a missing, zero or nonsense figure', () => { + expect(formatDurationMinutes(null)).toBeNull() + expect(formatDurationMinutes(undefined)).toBeNull() + expect(formatDurationMinutes(0)).toBeNull() + expect(formatDurationMinutes(-5)).toBeNull() + expect(formatDurationMinutes(Number.NaN)).toBeNull() + }) + + it('labels a link by its host, without the www', () => { + expect(linkHost('https://www.bahn.example/booking/abc')).toBe('bahn.example') + expect(linkHost('https://booking.example')).toBe('booking.example') + expect(linkHost('nonsense')).toBe('nonsense') + }) +}) diff --git a/client/src/pages/sharedTrip/sharedTripModel.ts b/client/src/pages/sharedTrip/sharedTripModel.ts new file mode 100644 index 000000000..4eff44662 --- /dev/null +++ b/client/src/pages/sharedTrip/sharedTripModel.ts @@ -0,0 +1,48 @@ +/** + * Pure helpers for the public shared-trip page (#2320). React-free, so the + * page and its detail blocks can share them and a test can drive them + * without rendering. + */ + +/** + * Whether a string is a link the page may render as one. + * + * The server already drops anything that is not http(s) (share.service.ts), + * so this is the second gate rather than the first — the page must not become + * the place where a `javascript:` value turns into an anchor because a future + * payload forgot to filter it. + */ +export function isHttpUrl(value: unknown): value is string { + if (typeof value !== 'string' || !value.trim()) return false + try { + const url = new URL(value.trim()) + return url.protocol === 'http:' || url.protocol === 'https:' + } catch { + return false + } +} + +/** + * "45 min", "2 h", "1 h 30 min" — the planned time at a place. + * + * Nothing for a missing, zero or negative figure: the planner's default of an + * hour is a default, not a plan, and the field stores what the owner typed. + */ +export function formatDurationMinutes(minutes: number | null | undefined): string | null { + if (typeof minutes !== 'number' || !Number.isFinite(minutes) || minutes <= 0) return null + const whole = Math.round(minutes) + const h = Math.floor(whole / 60) + const m = whole % 60 + if (h === 0) return `${m} min` + if (m === 0) return `${h} h` + return `${h} h ${m} min` +} + +/** The hostname a booking link points at, for a label that says where it goes. */ +export function linkHost(url: string): string { + try { + return new URL(url).hostname.replace(/^www\./, '') + } catch { + return url + } +} diff --git a/server/src/nest/share/share.service.ts b/server/src/nest/share/share.service.ts index 5a266ec6f..9b36e992b 100644 --- a/server/src/nest/share/share.service.ts +++ b/server/src/nest/share/share.service.ts @@ -51,6 +51,102 @@ export interface ShareTokenInfo { * DatabaseService. Trip access and the 'share_manage' permission gate * create/delete; the shared read is public. */ +/** + * What a place shows the public: where it is, what it is, how to reach it. + * + * Left out on purpose: the owner's booking notes and status on the place, the + * Google identifiers, the routing bookkeeping and the fill figures a road trip + * keeps for itself. `notes` stays — it is the note somebody wrote to be read + * on the plan, and the plan is what a link shares. + */ +const PUBLIC_PLACE_COLUMNS = [ + 'id', 'trip_id', 'name', 'description', 'lat', 'lng', 'address', 'category_id', 'price', 'currency', + 'place_time', 'end_time', 'duration_minutes', 'notes', 'image_url', 'website', 'phone', + 'transport_mode', 'created_at', 'updated_at', +].map(c => `p.${c}`).join(', '); + +/** + * What a booking shows the public: when, where, what kind, and the note and + * link the owner attached to it. Never the confirmation number, never the + * import trail, never who is travelling on it. + */ +const PUBLIC_RESERVATION_COLUMNS = [ + 'id', 'trip_id', 'day_id', 'end_day_id', 'place_id', 'accommodation_id', 'title', 'type', 'status', 'location', + 'reservation_time', 'reservation_end_time', 'notes', 'url', 'metadata', 'created_at', +].map(c => `r.${c}`).join(', '); + +/** A stay: which place, which nights, when the desk opens. Not the confirmation. */ +const PUBLIC_ACCOMMODATION_COLUMNS = [ + 'id', 'trip_id', 'place_id', 'start_day_id', 'end_day_id', 'check_in', 'check_in_end', 'check_out', 'notes', +].map(c => `a.${c}`).join(', '); + +/** + * The parts of a booking's metadata that describe the journey rather than the + * ticket. Legs keep their route, carrier and times; the record locator on a + * leg, the seat, the price and anything this list does not name stay behind. + * An allow-list rather than a block-list, because the import writes whatever a + * provider's confirmation carried, and a new field must not become public by + * appearing. + */ +const PUBLIC_METADATA_KEYS = new Set([ + 'airline', 'flight_number', 'departure_airport', 'arrival_airport', + 'train_number', 'platform', 'operator', 'from', 'to', + 'check_in_time', 'check_in_end_time', 'check_out_time', 'hotel', + 'pickup_location', 'dropoff_location', 'vehicle', +]); +const PUBLIC_LEG_KEYS = new Set([ + 'from', 'to', 'airline', 'flight_number', 'train_number', 'platform', 'operator', + 'dep_day_id', 'dep_time', 'arr_day_id', 'arr_time', +]); + +function pickKeys(source: Record, keys: Set): Record { + const out: Record = {}; + for (const key of keys) { + if (source[key] !== undefined) out[key] = source[key]; + } + return out; +} + +/** The public face of a booking's metadata, as the JSON string the row stores. */ +export function publicReservationMetadata(raw: unknown): string | null { + if (raw == null) return null; + let parsed: unknown = raw; + if (typeof raw === 'string') { + try { + parsed = JSON.parse(raw); + } catch { + return null; + } + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null; + const source = parsed as Record; + const out = pickKeys(source, PUBLIC_METADATA_KEYS); + if (Array.isArray(source.legs)) { + out.legs = source.legs + .filter((leg): leg is Record => !!leg && typeof leg === 'object' && !Array.isArray(leg)) + .map(leg => pickKeys(leg, PUBLIC_LEG_KEYS)); + } + return JSON.stringify(out); +} + +/** + * A link the page may render as one: http or https, nothing else. + * + * The owner types these, and a `javascript:` or `data:` value would otherwise + * become an anchor on a page anyone with the link can open. + */ +export function publicHttpUrl(value: unknown): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + if (!trimmed) return null; + try { + const parsed = new URL(trimmed); + return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? trimmed : null; + } catch { + return null; + } +} + @Injectable() export class ShareService { constructor( @@ -140,6 +236,28 @@ export class ShareService { * is never even queried. share_map covers the whole itinerary: days, their * assignments/notes, and the place list with coordinates, addresses and notes. */ + /** + * The ordered stops of every public booking on the trip, by booking id. + * + * One query for the lot rather than one per booking: the map draws the + * route from these, and a trip with forty bookings is not forty round trips + * to the database. + */ + private publicEndpointsByReservation(tripId: number): Map>> { + const rows = this.dbs.all<{ reservation_id: number } & Record>( + `SELECT e.reservation_id, e.role, e.sequence, e.name, e.code, e.lat, e.lng, e.timezone, e.local_date, e.local_time + FROM reservation_endpoints e JOIN reservations r ON r.id = e.reservation_id + WHERE r.trip_id = ? ORDER BY e.reservation_id ASC, e.sequence ASC`, + tripId, + ); + const out = new Map>>(); + for (const { reservation_id, ...endpoint } of rows) { + if (!out.has(reservation_id)) out.set(reservation_id, []); + out.get(reservation_id)!.push(endpoint); + } + return out; + } + getSharedTripData(token: string): Record | null { const shareRow = this.dbs.get( "SELECT * FROM share_tokens WHERE token = ? AND (expires_at IS NULL OR expires_at > datetime('now'))", @@ -181,6 +299,7 @@ export class ShareService { COALESCE(da.assignment_time, p.place_time) as place_time, COALESCE(da.assignment_end_time, p.end_time) as end_time, p.duration_minutes, p.notes as place_notes, p.image_url, p.transport_mode, + p.website, p.phone, c.name as category_name, c.color as category_color, c.icon as category_icon FROM day_assignments da JOIN places p ON da.place_id = p.id @@ -201,6 +320,8 @@ export class ShareService { id: a.place_id, name: a.place_name, description: a.place_description, lat: a.lat, lng: a.lng, address: a.address, category_id: a.category_id, price: a.price, place_time: a.place_time, end_time: a.end_time, + duration_minutes: a.duration_minutes, notes: a.place_notes, + website: publicHttpUrl(a.website), phone: a.phone, image_url: rewritePlacePhotoUrl(a.image_url, token), transport_mode: a.transport_mode, category: a.category_id ? { id: a.category_id, name: a.category_name, color: a.category_color, icon: a.category_icon } : null, tags: tagsByPlace[a.place_id] ?? [], @@ -218,11 +339,18 @@ export class ShareService { dayNotes = notesByDay; } + // Named columns, not p.*: the pool used to travel whole, which put the + // owner's booking notes on the place, the Google ids and the import + // bookkeeping in front of anybody holding the link (#2320). places = this.dbs.all(` - SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon + SELECT ${PUBLIC_PLACE_COLUMNS}, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.trip_id = ? ORDER BY p.created_at DESC - `, tripId).map((p) => ({ ...p, image_url: rewritePlacePhotoUrl(p.image_url, token) })); + `, tripId).map((p) => ({ + ...p, + image_url: rewritePlacePhotoUrl(p.image_url, token), + website: publicHttpUrl(p.website), + })); } // Bookings — reservations carry per-day positions so the client can render @@ -244,14 +372,27 @@ export class ShareService { } // The alias is not cosmetic: the visibility predicate qualifies its column, // and this query had no alias to qualify against. + // Named columns here too. r.* carried the confirmation number, the + // import bookkeeping and the raw metadata — and the metadata is where + // an imported ticket keeps its seat and its record locator. A public + // link shows what the booking is, not what it would take to change it + // (#2320). The endpoints ride along, since the map draws from them. + const endpoints = this.publicEndpointsByReservation(tripId); reservations = this.dbs.all( - `SELECT r.* FROM reservations r + `SELECT ${PUBLIC_RESERVATION_COLUMNS} FROM reservations r WHERE r.trip_id = ? AND ${publicReservationSql('r')} ORDER BY r.reservation_time ASC`, tripId) - .map((r) => ({ ...r, day_positions: posMap.get(r.id) ?? null })); + .map((r) => ({ + ...r, + url: publicHttpUrl(r.url), + metadata: publicReservationMetadata(r.metadata), + endpoints: endpoints.get(r.id) ?? [], + day_positions: posMap.get(r.id) ?? null, + })); accommodations = this.dbs.all(` - SELECT a.*, p.name as place_name, p.address as place_address, p.lat as place_lat, p.lng as place_lng + SELECT ${PUBLIC_ACCOMMODATION_COLUMNS}, + p.name as place_name, p.address as place_address, p.lat as place_lat, p.lng as place_lng FROM day_accommodations a JOIN places p ON a.place_id = p.id WHERE a.trip_id = ? AND ${publicStaySql('a')} `, tripId); diff --git a/server/tests/unit/nest/share.service.test.ts b/server/tests/unit/nest/share.service.test.ts index 1aeedf23a..604eaf0cb 100644 --- a/server/tests/unit/nest/share.service.test.ts +++ b/server/tests/unit/nest/share.service.test.ts @@ -56,7 +56,7 @@ import { } from '../../helpers/factories'; import { DatabaseService } from '../../../src/nest/database/database.service'; import type { PermissionsService } from '../../../src/nest/permissions/permissions.service'; -import { ShareService } from '../../../src/nest/share/share.service'; +import { ShareService, publicReservationMetadata } from '../../../src/nest/share/share.service'; import { SettingsService } from '../../../src/nest/settings/settings.service'; import { QueryHelpersService } from '../../../src/nest/query-helpers/query-helpers.service'; import type { User } from '../../../src/types'; @@ -428,6 +428,140 @@ describe('getSharedTripData', () => { }); }); +// ── #2320: what the public payload carries, and what it withholds ─────────── + +describe('getSharedTripData redaction (#2320)', () => { + it('SHARE-SVC-032: a booking travels without its confirmation, import trail or travellers', () => { + const { trip, token } = seedSharedTrip(); + testDb.prepare(`INSERT INTO reservations + (trip_id, title, type, status, confirmation_number, notes, url, external_source, external_id, sync_enabled, needs_review) + VALUES (?, 'Night train', 'train', 'confirmed', 'PNR9XY', 'Bring the tickets', 'https://bahn.example/booking/1', 'kitinerary', 'ext-42', 1, 0)`) + .run(trip.id); + + const [row] = svc.getSharedTripData(token)!.reservations as any[]; + + expect(Object.keys(row).sort()).toEqual([ + 'accommodation_id', 'created_at', 'day_id', 'day_positions', 'end_day_id', 'endpoints', 'id', 'location', + 'metadata', 'notes', 'place_id', 'reservation_end_time', 'reservation_time', 'status', 'title', 'trip_id', 'type', 'url', + ]); + expect(row.notes).toBe('Bring the tickets'); + expect(row.url).toBe('https://bahn.example/booking/1'); + expect(JSON.stringify(svc.getSharedTripData(token))).not.toContain('PNR9XY'); + expect(JSON.stringify(svc.getSharedTripData(token))).not.toContain('ext-42'); + }); + + it('SHARE-SVC-033: booking metadata keeps the journey and drops the ticket', () => { + const { trip, token } = seedSharedTrip(); + const metadata = JSON.stringify({ + airline: 'LH', flight_number: 'LH400', departure_airport: 'FRA', arrival_airport: 'JFK', + seat: '14A', price: 812, passenger_name: 'Ada Lovelace', confirmation_number: 'LOC123', + legs: [ + { from: 'FRA', to: 'BER', airline: 'LH', flight_number: 'LH170', dep_time: '08:00', arr_time: '09:05', confirmation_number: 'LEG1', seat: '3C' }, + { from: 'BER', to: 'JFK', airline: 'LH', flight_number: 'LH400', dep_time: '11:00', arr_time: '14:10', confirmation_number: 'LEG2' }, + ], + }); + testDb.prepare(`INSERT INTO reservations (trip_id, title, type, status, metadata) + VALUES (?, 'To New York', 'flight', 'confirmed', ?)`).run(trip.id, metadata); + + const data = svc.getSharedTripData(token)!; + const [row] = data.reservations as any[]; + const meta = JSON.parse(row.metadata); + + expect(meta).toEqual({ + airline: 'LH', flight_number: 'LH400', departure_airport: 'FRA', arrival_airport: 'JFK', + legs: [ + { from: 'FRA', to: 'BER', airline: 'LH', flight_number: 'LH170', dep_time: '08:00', arr_time: '09:05' }, + { from: 'BER', to: 'JFK', airline: 'LH', flight_number: 'LH400', dep_time: '11:00', arr_time: '14:10' }, + ], + }); + const whole = JSON.stringify(data); + for (const secret of ['14A', '812', 'Ada Lovelace', 'LOC123', 'LEG1', 'LEG2', '3C']) { + expect(whole, secret).not.toContain(secret); + } + }); + + it('SHARE-SVC-034: a booking carries its ordered stops', () => { + const { trip, token } = seedSharedTrip(); + const id = testDb.prepare(`INSERT INTO reservations (trip_id, title, type, status) + VALUES (?, 'Coach', 'bus', 'confirmed')`).run(trip.id).lastInsertRowid; + const ins = testDb.prepare(`INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng) + VALUES (?, ?, ?, ?, ?, ?, ?)`); + ins.run(id, 'to', 1, 'Lyon', null, 45.76, 4.84); + ins.run(id, 'from', 0, 'Paris', 'PAR', 48.85, 2.35); + + const [row] = svc.getSharedTripData(token)!.reservations as any[]; + + expect(row.endpoints.map((e: any) => [e.role, e.name])).toEqual([['from', 'Paris'], ['to', 'Lyon']]); + expect(Object.keys(row.endpoints[0]).sort()).toEqual( + ['code', 'lat', 'lng', 'local_date', 'local_time', 'name', 'role', 'sequence', 'timezone'], + ); + }); + + it('SHARE-SVC-035: only http(s) links reach the page, on bookings and places alike', () => { + const { trip, token } = seedSharedTrip(); + const day = createDay(testDb, trip.id, { date: '2026-09-01' }); + for (const [title, url] of [['ok', 'https://example.com/x'], ['js', 'javascript:alert(1)'], ['data', 'data:text/html,hi'], ['junk', 'not a url'], ['blank', ' ']]) { + testDb.prepare(`INSERT INTO reservations (trip_id, title, type, status, url) VALUES (?, ?, 'other', 'confirmed', ?)`).run(trip.id, title, url); + } + const place = createPlace(testDb, trip.id, { name: 'Museum' }); + testDb.prepare('UPDATE places SET website = ?, phone = ? WHERE id = ?').run('javascript:alert(2)', '+43 1 234', place.id); + createDayAssignment(testDb, day.id, place.id, {}); + + const data = svc.getSharedTripData(token)!; + const urls = Object.fromEntries((data.reservations as any[]).map((r) => [r.title, r.url])); + expect(urls).toEqual({ ok: 'https://example.com/x', js: null, data: null, junk: null, blank: null }); + expect(data.assignments[day.id][0].place.website).toBeNull(); + expect(data.assignments[day.id][0].place.phone).toBe('+43 1 234'); + expect((data.places as any[])[0].website).toBeNull(); + }); + + it('SHARE-SVC-036: an assignment carries the place notes, duration and contact, the pool carries no booking notes', () => { + const { trip, token } = seedSharedTrip(); + const day = createDay(testDb, trip.id, { date: '2026-09-01' }); + const place = createPlace(testDb, trip.id, { name: 'Louvre' }); + testDb.prepare(`UPDATE places SET description = 'Big museum', address = 'Rue de Rivoli', notes = 'Skip the pyramid queue', + duration_minutes = 180, website = 'https://louvre.fr', phone = '+33 1', reservation_notes = 'Booked under Ada', google_place_id = 'ChIJ123' WHERE id = ?`).run(place.id); + createDayAssignment(testDb, day.id, place.id, { notes: 'go early' }); + + const data = svc.getSharedTripData(token)!; + const entry = data.assignments[day.id][0]; + expect(entry.notes).toBe('go early'); + expect(entry.place).toEqual(expect.objectContaining({ + description: 'Big museum', address: 'Rue de Rivoli', notes: 'Skip the pyramid queue', + duration_minutes: 180, website: 'https://louvre.fr', phone: '+33 1', + })); + const pool = (data.places as any[])[0]; + expect(pool.notes).toBe('Skip the pyramid queue'); + expect(pool).not.toHaveProperty('reservation_notes'); + expect(pool).not.toHaveProperty('google_place_id'); + expect(JSON.stringify(data)).not.toContain('Booked under Ada'); + expect(JSON.stringify(data)).not.toContain('ChIJ123'); + }); + + it('SHARE-SVC-037: a stay carries its desk times and note, not its confirmation', () => { + const { trip, token } = seedSharedTrip(); + const place = createPlace(testDb, trip.id, { name: 'Hotel' }); + const day = createDay(testDb, trip.id, { date: '2026-09-01' }); + testDb.prepare(`INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes) + VALUES (?, ?, ?, ?, '15:00', '11:00', 'HOTEL-777', 'Ask for a quiet room')`).run(trip.id, place.id, day.id, day.id); + + const data = svc.getSharedTripData(token)!; + const [stay] = data.accommodations as any[]; + expect(stay).toEqual(expect.objectContaining({ check_in: '15:00', check_out: '11:00', notes: 'Ask for a quiet room', place_name: 'Hotel' })); + expect(stay).not.toHaveProperty('confirmation'); + expect(JSON.stringify(data)).not.toContain('HOTEL-777'); + }); + + it('SHARE-SVC-038: metadata that is not an object comes back as nothing', () => { + expect(publicReservationMetadata(null)).toBeNull(); + expect(publicReservationMetadata('not json')).toBeNull(); + expect(publicReservationMetadata('[1,2]')).toBeNull(); + expect(publicReservationMetadata('"str"')).toBeNull(); + expect(publicReservationMetadata({ airline: 'OS', legs: [null, 'x', { from: 'VIE', seat: '1A' }] })) + .toBe(JSON.stringify({ airline: 'OS', legs: [{ from: 'VIE' }] })); + }); +}); + // ── getSharedPlacePhotoKey ─────────────────────────────────────────────────── describe('getSharedPlacePhotoKey', () => {