fix(places): follow-ups from the review of the schemeless website fix

create_place_accommodation refused the whole booking again once its website
went through the place contract, for '' and free text as well, which it always
took. It keeps its old input schema now and leaves a website off when the
contract refuses it, so a bare host still gains https and a script link is
still not stored.

normalizePlaceWebsite keeps an http or https value whenever a browser opens it,
the way the write contract does, so a plugin or source answering with an
intranet host, an IPv6 literal or a trailing dot keeps its website. The dot
check only tells a bare host from a word. A details row cached before the fix
(a week for Google and Amap, until a refresh for an expanded one) hands its
website out through the same helper.

Tests: SHARED-WEBSITE-007 adjusted, SHARED-WEBSITE-013 and 014,
MCP-ACCOM-2483-01 narrowed, MCP-ACCOM-2483-02, MAPS-2483-06 and 07, AMAP-079,
PLUGIN-SEARCH-2483-02.
This commit is contained in:
Maurice
2026-09-23 21:25:02 +02:00
parent 2350c3ae14
commit 1423ba8933
8 changed files with 147 additions and 23 deletions
+36 -1
View File
@@ -1,4 +1,5 @@
import { PLACE_WEBSITE_MAX_LENGTH, normalizePlaceWebsite } from './place-website';
import { placeWebsiteSchema } from './place.schema';
import { describe, expect, it } from 'vitest';
@@ -66,8 +67,8 @@ describe('normalizePlaceWebsite (#2483)', () => {
'example-.com',
'example.com.',
'https://',
'https://localhost:3000',
'https://exa mple.com',
'http://%zz/',
]) {
expect(normalizePlaceWebsite(value), value).toBeNull();
}
@@ -103,4 +104,38 @@ describe('normalizePlaceWebsite (#2483)', () => {
expect(normalizePlaceWebsite(42)).toBeNull();
expect(normalizePlaceWebsite({ href: 'https://example.com' })).toBeNull();
});
// The dot is how a bare host is told from a word. A value that names http(s)
// has settled that already, so it is kept whenever a browser opens it, the
// way the write contract keeps it: a plugin on the same network may answer
// with an intranet address.
it('SHARED-WEBSITE-013: an http(s) value the contract stores is kept, whatever its host looks like', () => {
for (const value of [
'https://localhost:3000',
'http://intranet/wiki',
'https://[2001:db8::1]:8443/x',
'http://[::1]/',
'https://example.com.',
'https://ex%41mple.com',
]) {
expect(normalizePlaceWebsite(value), value).toBe(value);
expect(placeWebsiteSchema.safeParse(value).data, value).toBe(value);
}
});
it('SHARED-WEBSITE-014: a backslash never turns a value into another scheme or a bare host into a kept one', () => {
// A browser reads the backslash as a slash, so this opens localhost over
// https. It is kept like any other intranet address: the scheme is http(s).
expect(normalizePlaceWebsite('https://localhost\\@example.com')).toBe('https://localhost\\@example.com');
for (const value of [
'https:\\\\evil.example',
'\\\\evil.example',
'//\\evil.example',
'example.com\\@evil.example',
'intranet\\@example.com',
'javascript:1\\@example.com',
]) {
expect(normalizePlaceWebsite(value), value).toBeNull();
}
});
});
+9 -8
View File
@@ -35,7 +35,8 @@ function authorityOf(rest: string): string {
/**
* A name with at least one dot and a top-level label that has a letter in it,
* or an IPv4 address. "Chapelle", "localhost" and "3.5" are not websites.
* or an IPv4 address. Without a scheme, "Chapelle", "localhost" and "3.5" are
* not websites.
*/
function isHostName(host: string): boolean {
const labels = host.split('.');
@@ -55,10 +56,13 @@ function hasHost(authority: string): boolean {
/**
* The website a place gets from `value`, or null.
*
* Trimmed; http and https stay as they are; a bare host or a protocol-relative
* `//host` gains https. Any other scheme (`javascript:`, `data:`, `mailto:`,
* `ftp:`), free text, a host without a dot and anything a browser cannot
* parse come back as null, as does a value longer than the contract allows.
* Trimmed; http and https stay as they are whenever a browser can open them,
* an intranet host or an IPv6 literal included, which is what the write
* contract keeps as well. A bare host or a protocol-relative `//host` gains
* https, and only there does the host need a dot: it is what tells a site
* from a word. Any other scheme (`javascript:`, `data:`, `mailto:`, `ftp:`),
* free text and anything a browser cannot parse come back as null, as does a
* value longer than the contract allows.
*/
export function normalizePlaceWebsite(value: unknown): string | null {
if (typeof value !== 'string') return null;
@@ -67,9 +71,6 @@ export function normalizePlaceWebsite(value: unknown): string | null {
let url: string;
if (HTTP_URL.test(text)) {
const authority = authorityOf(text.replace(HTTP_URL, ''));
// Credentials in front of the host are not part of the name being checked.
if (!hasHost(authority.slice(authority.lastIndexOf('@') + 1))) return null;
url = text;
} else if (text.startsWith('//')) {
if (!hasHost(authorityOf(text.slice(2)))) return null;