mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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>;
|
||||
|
||||
|
||||
@@ -19,6 +19,34 @@ const open = z.record(z.string(), z.unknown());
|
||||
/** `#rgb` / `#rrggbb`, the form both map renderers and CSS accept. */
|
||||
export const hexColorSchema = z.string().regex(/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/);
|
||||
|
||||
/**
|
||||
* The four shapes a place thumbnail is ever allowed to take: a file we stored, a
|
||||
* photo-proxy path, an inline thumbnail, or a remote https image.
|
||||
*
|
||||
* The map markers build their HTML as a string, so what lands here is one input
|
||||
* away from a renderer that forgets to escape. Pinning the scheme on the way in
|
||||
* means a stored value cannot carry anything a future consumer has to defend
|
||||
* against — the escaping in the marker builders stops being the only thing
|
||||
* standing between the database and the DOM.
|
||||
*/
|
||||
export const placeImageUrlSchema = z.string().max(2048).refine(
|
||||
v =>
|
||||
v.startsWith('/uploads/')
|
||||
|| v.startsWith('/api/maps/place-photo/')
|
||||
|| /^data:image\/(png|jpe?g|webp|gif|avif);base64,/i.test(v)
|
||||
|| /^https:\/\//i.test(v),
|
||||
{ message: 'must be an uploaded path, a photo-proxy path, an inline image or an https URL' },
|
||||
);
|
||||
|
||||
/**
|
||||
* A place's homepage. It reaches window.open() on the client, where a
|
||||
* javascript: value would run in this origin rather than opening a page.
|
||||
*/
|
||||
export const placeWebsiteSchema = z.string().max(500).refine(
|
||||
v => /^https?:\/\//i.test(v),
|
||||
{ message: 'must be an http or https URL' },
|
||||
);
|
||||
|
||||
/**
|
||||
* Embedded category as returned on a place — a trimmed projection of the
|
||||
* categories row (id/name/color/icon), built inline by placeService and
|
||||
|
||||
Reference in New Issue
Block a user