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.
This commit is contained in:
Maurice
2026-09-11 11:01:25 +02:00
parent 87c73e50fb
commit a8e4c45576
8 changed files with 617 additions and 21 deletions
+98
View File
@@ -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();
});
});
});
+6 -15
View File
@@ -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}
</div>
{(place.address || place.description) && (
<div
className="text-[#9ca3af]"
style={{
fontSize: 'calc(10px * var(--fs-scale-caption, 1))',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{place.address || place.description}
</div>
)}
<SharedPlaceDetails place={place} assignmentNotes={item.data.notes} />
</div>
{place.place_time && (
<span
@@ -834,7 +824,7 @@ export default function SharedTripPage() {
<div
key={r.id}
className="border border-edge-faint bg-surface-card"
style={{ borderRadius: 10, padding: '12px 16px', display: 'flex', alignItems: 'center', gap: 12 }}
style={{ borderRadius: 10, padding: '12px 16px', display: 'flex', alignItems: 'flex-start', gap: 12 }}
>
<div
className="bg-[#f3f4f6]"
@@ -900,6 +890,7 @@ export default function SharedTripPage() {
</span>
)}
</div>
<SharedBookingDetails notes={r.notes} url={r.url} />
</div>
<span
className={
@@ -0,0 +1,44 @@
import { ExternalLink, FileText } from 'lucide-react'
import { isHttpUrl, linkHost } from './sharedTripModel'
/**
* The note and the link on a shared booking (#2320).
*
* A booking on a public link used to be its title, its time and its status.
* The note the owner attached ("meet at the north entrance") and the booking
* page they linked are what a fellow traveller opens the link for, so both
* show now. What does not show is anything that would let a stranger touch
* the booking: the server keeps the confirmation number and the ticket data
* back, and this block only knows the two fields it is handed.
*
* The link opens in a new tab with no opener, and only when it is http(s) —
* checked here as well as on the server, so the page never trusts a payload
* to have done it.
*/
export function SharedBookingDetails({ notes, url }: { notes?: string | null; url?: string | null }) {
const note = notes?.trim() || null
const link = isHttpUrl(url) ? url.trim() : null
if (!note && !link) return null
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 3, marginTop: 4 }}>
{note && (
<div className="text-[#374151]" style={{ fontSize: 'calc(11px * var(--fs-scale-body, 1))', display: 'flex', alignItems: 'flex-start', gap: 4, whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }}> {/* theme-lint-disable — public page, no user theme */}
<FileText size={10} style={{ flexShrink: 0, marginTop: 2 }} />
<span>{note}</span>
</div>
)}
{link && (
<a
href={link}
target="_blank"
rel="noopener noreferrer"
className="text-[#2563eb]" // theme-lint-disable — public page, no user theme
style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))', display: 'inline-flex', alignItems: 'center', gap: 3, textDecoration: 'none', width: 'fit-content' }}
>
<ExternalLink size={10} />
{linkHost(link)}
</a>
)}
</div>
)
}
@@ -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 (
<div className="shared-place-details" style={{ display: 'flex', flexDirection: 'column', gap: 3, minWidth: 0 }}>
{place.address && (
<div className="text-[#6b7280]" style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))', display: 'flex', alignItems: 'flex-start', gap: 4 }}> {/* theme-lint-disable — public page, no user theme */}
<MapPin size={10} style={{ flexShrink: 0, marginTop: 2 }} />
<span style={{ minWidth: 0, overflowWrap: 'anywhere' }}>{place.address}</span>
</div>
)}
{place.description && (
<div className="text-[#4b5563]" style={{ fontSize: 'calc(11px * var(--fs-scale-body, 1))', whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }}> {/* theme-lint-disable — public page, no user theme */}
{place.description}
</div>
)}
{dayNote && (
<div className="text-[#374151]" style={{ fontSize: 'calc(11px * var(--fs-scale-body, 1))', display: 'flex', alignItems: 'flex-start', gap: 4, whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }} title={t('places.assignmentNotes')}> {/* theme-lint-disable — public page, no user theme */}
<FileText size={10} style={{ flexShrink: 0, marginTop: 2 }} />
<span>{dayNote}</span>
</div>
)}
{placeNote && (
<div className="text-[#6b7280]" style={{ fontSize: 'calc(11px * var(--fs-scale-body, 1))', display: 'flex', alignItems: 'flex-start', gap: 4, whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }} title={t('places.formNotes')}> {/* theme-lint-disable — public page, no user theme */}
<FileText size={10} style={{ flexShrink: 0, marginTop: 2 }} />
<span>{placeNote}</span>
</div>
)}
{(duration || hasLinks) && (
<div className="text-[#6b7280]" style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))', display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: '2px 10px', marginTop: 1 }}> {/* theme-lint-disable — public page, no user theme */}
{duration && (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3 }}>
<Clock size={10} />
{duration}
</span>
)}
{mapsUrl && (
<a href={mapsUrl} target="_blank" rel="noopener noreferrer" className="text-[#2563eb]" style={{ display: 'inline-flex', alignItems: 'center', gap: 3, textDecoration: 'none' }}> {/* theme-lint-disable — public page, no user theme */}
<ExternalLink size={10} />
{t('planner.openGoogleMaps')}
</a>
)}
{website && (
<a href={website} target="_blank" rel="noopener noreferrer" className="text-[#2563eb]" style={{ display: 'inline-flex', alignItems: 'center', gap: 3, textDecoration: 'none' }}> {/* theme-lint-disable — public page, no user theme */}
<Globe size={10} />
{t('places.formWebsite')}
</a>
)}
{phone && (
<a href={`tel:${phone.replace(/[^+\d]/g, '')}`} className="text-[#2563eb]" style={{ display: 'inline-flex', alignItems: 'center', gap: 3, textDecoration: 'none' }}> {/* theme-lint-disable — public page, no user theme */}
<Phone size={10} />
{phone}
</a>
)}
</div>
)}
</div>
)
}
@@ -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')
})
})
@@ -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
}
}
+146 -5
View File
@@ -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<string, unknown>, keys: Set<string>): Record<string, unknown> {
const out: Record<string, unknown> = {};
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<string, unknown>;
const out = pickKeys(source, PUBLIC_METADATA_KEYS);
if (Array.isArray(source.legs)) {
out.legs = source.legs
.filter((leg): leg is Record<string, unknown> => !!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<number, Array<Record<string, unknown>>> {
const rows = this.dbs.all<{ reservation_id: number } & Record<string, unknown>>(
`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<number, Array<Record<string, unknown>>>();
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<string, any> | null {
const shareRow = this.dbs.get<any>(
"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<any>(`
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<any>(
`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);
+135 -1
View File
@@ -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', () => {