Files
TREK/client/src/utils/safeUrl.ts
T
Maurice 2350c3ae14 fix(places): a website without a scheme no longer blocks saving a place
A place from the TREK index can carry its website the way Overture has it,
such as fr.wikipedia.org/wiki/Chapelle_Sainte-Barbe_du_Faouët, and saving
it to a collection or a trip failed with "website must be an http or https
URL" over a field the dialog does not show (#2483). OpenStreetMap, Amap and
booking mails hand over websites the same way.

Every place a website comes in from outside now goes through one shared
helper, normalizePlaceWebsite: the index client and its nearby search, the
Overpass and OSM detail mappers, Google search and details, Amap, plugin
search and the booking import. It completes a bare or protocol-relative
host to https, keeps http and https as they are, and drops every other
scheme and anything that is no address. Only the scheme is added, so an
accented path is stored as written.

placeWebsiteSchema completes a bare host the same way before its check, so
an older client, a cached offline result, an MCP agent or a plugin is no
longer refused, while javascript:, data: and mailto: still are. The REST
controller, the plugin RPC and the enrichment now store the parsed value
instead of the raw one, and create_place_accommodation takes the website
through the same schema as create_place. safeHttpUrl still links only a
value already stored in its final form, so nothing changes on screen.

Tests: SHARED-WEBSITE-001 to 012, SHARED-PLACE-030 to 034,
SHARED-COLLFILE-2483-01, COLLECTIONS-E2E-089 and 090,
PLACES-E2E-2483-01 and 02, PLACES-CTRL-2483-01 (the louvre.fr case
adjusted), MCP-PLACES-2483-01 to 03, MCP-ACCOM-2483-01, MCP-COLL-2483-01,
PLACES-RPC-2483-01, MAPS-2483-01 to 05, MAPS-POIS-015, MAPS-GERS-010,
AMAP-012b, TREK-PLACES-2483-01, PLUGIN-SEARCH-2483-01,
BOOKING-IMPORT-2483-01, ENRICH-052e, ENRICH-102 extended, ENRICH-120 adjusted,
FE-UTIL-SAFEURL-007.
2026-09-23 22:35:01 +02:00

56 lines
2.8 KiB
TypeScript

import { placeWebsiteSchema } from '@trek/shared'
/**
* Allow-list a URL before handing it to window.open.
*
* A place's website is a plain string in the older contracts, and it reaches
* window.open from the inspector, both sidebar context menus and the mobile
* place sheet. `window.open('javascript:…')` does not open a page — it evaluates
* the script in a fresh document that inherits this origin, so a co-traveller
* with place_edit could plant one and wait for someone to click Website.
*
* Server-side validation closes the write path. This closes the read path, which
* is what covers rows written before that validation existed and instances that
* have not updated yet — the same split as safeHexColor.
*/
export function safeHttpUrl(value: string | null | undefined): string | null {
if (typeof value !== 'string') return null
const parsed = placeWebsiteSchema.safeParse(value)
// The contract completes a bare host to https on the way in (#2483), but the
// value handed to window.open here is the one stored, and "louvre.fr" would
// resolve against this app. So only a value already in its final form passes.
return parsed.success && parsed.data === value ? value : null
}
/**
* A user-pasted link, made safe for an href without narrowing what people are
* allowed to paste.
*
* Different job from safeHttpUrl above. That one guards fields whose contract
* really is http(s)-only and capped. A booking url is deliberately free-form -
* shared's reservationUrlSchema says so in as many words, because people paste
* bare hosts and very long provider deep links - so holding it to the stricter
* contract would turn a link somebody saved last year into dead text.
*
* Only the schemes that execute in this origin are refused. A value with no
* scheme gets https://, which is what an address bar does with the same input;
* without it the browser would resolve "www.hotel.com" against the current page
* and navigate inside the app.
*/
export function safeExternalHref(value: string | null | undefined): string | null {
if (typeof value !== 'string') return null
const trimmed = value.trim()
if (!trimmed) return null
// Browsers strip control characters before they resolve a scheme, so the test
// has to run against the same string they will see.
const collapsed = Array.from(trimmed).filter(c => c > ' ').join('')
if (/^https?:\/\//i.test(collapsed)) return trimmed
// Any other scheme, executing or merely unexpected, stays unlinked.
if (/^[a-z][a-z0-9+.-]*:/i.test(collapsed)) return null
// A bare host gets https://, the way an address bar would take it. Free text
// does not: "www.hotel.com" is a site somebody meant to link, "call the hotel"
// is a note, and turning the second into a link is worse than leaving it flat.
if (/^[^\s/?#]+\.[^\s/?#]+/.test(trimmed)) return `https://${trimmed}`
return null
}