fix(places): pin the image and website URL fields on the way in

image_url and website were bare strings in the contract, and the create/update
bodies are open records, so the Zod pipe never looked at either. Both leave the
database for something that treats them as a URL: the thumbnail is interpolated
into hand-built marker HTML, and the homepage is handed to window.open, where a
javascript: value would run in this origin rather than opening a page.

Both are allow-listed on the way in now, next to hexColorSchema and checked in
the handler the way validateRouteColor already is, on all three write surfaces
so REST, the plugin RPC and MCP agree on what they accept. The entity schemas
stay open: rows written before this still have to round-trip.

safeHttpUrl covers the read side for exactly those rows, the same split
safeHexColor uses, and the window.open calls it guards pick up
noopener,noreferrer while they are being touched.
This commit is contained in:
Maurice
2026-08-16 21:37:52 +02:00
committed by Maurice
parent 0f0bf50cd3
commit c8607ba234
15 changed files with 205 additions and 19 deletions
+7 -4
View File
@@ -1,4 +1,4 @@
import { placeCategorySchema, placeRatingVoteSchema } from '../place/place.schema';
import { placeCategorySchema, placeImageUrlSchema, placeRatingVoteSchema, placeWebsiteSchema } from '../place/place.schema';
import { tagSchema } from '../tag/tag.schema';
import { z } from 'zod';
@@ -53,6 +53,9 @@ export const collectionPlaceSchema = z.object({
price: z.number().nullable().optional(),
currency: z.string().nullable().optional(),
notes: z.string().nullable().optional(),
// Deliberately open on the way out, pinned on the way in (see the save/update
// request schemas below): rows written before the request-side check existed
// still have to round-trip.
image_url: z.string().nullable().optional(),
google_place_id: z.string().nullable().optional(),
google_ftid: z.string().nullable().optional(),
@@ -150,11 +153,11 @@ export const collectionSavePlaceRequestSchema = z.object({
price: z.number().nullable().optional(),
currency: z.string().nullable().optional(),
notes: z.string().nullable().optional(),
image_url: z.string().nullable().optional(),
image_url: placeImageUrlSchema.nullable().optional(),
google_place_id: z.string().nullable().optional(),
google_ftid: z.string().nullable().optional(),
osm_id: z.string().nullable().optional(),
website: z.string().nullable().optional(),
website: placeWebsiteSchema.nullable().optional(),
phone: z.string().nullable().optional(),
status: collectionStatusSchema.optional(),
links: collectionLinksSchema.optional(),
@@ -203,7 +206,7 @@ export const collectionPlaceUpdateRequestSchema = z.object({
// Replace the place's per-collection label assignments (omit to leave unchanged).
label_ids: z.array(z.number()).optional(),
// Custom thumbnail (#1136): null clears it (falls back to the auto-fetched photo).
image_url: z.string().nullable().optional(),
image_url: placeImageUrlSchema.nullable().optional(),
});
export type CollectionPlaceUpdateRequest = z.infer<typeof collectionPlaceUpdateRequestSchema>;