Files
drinktracker/src/lib/validators.ts
JP 058735b1a0 Mirror external images into our own storage so they render
Bar items added by barcode stored the Open Food Facts image URL
directly. The CSP in next.config.mjs restricts img-src to our own
origin, so the browser blocked those and showed a broken image - the
picture was fine, we just could not display it.

Copy externally-hosted images into MinIO at lookup time and hand back a
/minio-images path instead. That fixes the class rather than the
instance: no CSP entry is needed per image source, the picture survives
the source deleting or reorganising it, and the user's browser never
has to talk to a third party to render their own bar.

Also fixes a latent bug this uncovered: imageUrl was validated with
z.string().url(), which rejects the relative /minio-images/... paths
that uploadImage returns, so saving an uploaded drink image would fail
validation. That matches production having zero drinks with an image.
Both schemas now accept either form.

CSP keeps two third-party entries for OAuth avatars, which the provider
hosts and we only ever receive as a URL at sign-in.

Existing rows were backfilled separately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 19:06:45 +00:00

118 lines
4.5 KiB
TypeScript

import { z } from "zod"
/**
* An image reference. Uploaded and mirrored images are stored as relative paths
* (`/minio-images/...`) served through the Next.js proxy, so a plain `.url()` check
* rejects them - which silently broke saving any uploaded image. Absolute http(s)
* URLs stay allowed for data restored from older backups.
*/
export const imageUrlSchema = z
.string()
.max(2048)
.refine(
(v) => v.startsWith("/minio-images/") || /^https?:\/\//i.test(v),
"Must be an uploaded image path or an http(s) URL"
)
export const drinkCreateSchema = z.object({
name: z.string().min(1, "Name is required").max(200),
type: z.enum(["BEER", "WINE", "COCKTAIL", "SPIRIT", "OTHER"]),
subType: z.string().max(100).optional(),
brewery: z.string().max(200).optional(),
region: z.string().max(200).optional(),
abv: z.number().min(0).max(100).optional(),
description: z.string().max(2000).optional(),
imageUrl: imageUrlSchema.optional(),
})
export const drinkUpdateSchema = drinkCreateSchema.partial()
export const ratingCreateSchema = z.object({
drinkId: z.string().min(1),
score: z.number().int().min(1).max(5),
notes: z.string().max(2000).optional(),
wouldReorder: z.boolean().optional(),
location: z.string().max(200).optional(),
})
export const ratingUpdateSchema = ratingCreateSchema.omit({ drinkId: true }).partial()
export const apiKeySchema = z.object({
provider: z.literal("switchboard"),
apiKey: z.string().min(1, "API key is required"),
label: z.string().max(100).optional(),
})
export const userPreferenceSchema = z.object({
preferredStyles: z.array(z.string().max(50)).max(20).optional(),
avoidedStyles: z.array(z.string().max(50)).max(20).optional(),
minAbv: z.number().min(0).max(100).optional().nullable(),
maxAbv: z.number().min(0).max(100).optional().nullable(),
// defaultProvider is intentionally absent: it was stored but never read, and there
// is only one provider now. The Prisma column stays so restoring an old backup works.
})
export const wishlistCreateSchema = z.object({
name: z.string().min(1, "Name is required").max(200),
type: z.enum(["BEER", "WINE", "COCKTAIL", "SPIRIT", "OTHER"]),
subType: z.string().max(100).optional(),
brewery: z.string().max(200).optional(),
abv: z.number().min(0).max(100).optional(),
description: z.string().max(2000).optional(),
notes: z.string().max(2000).optional(),
source: z.string().max(50).optional(),
})
export const sharedListCreateSchema = z.object({
title: z.string().min(1, "Title is required").max(200),
description: z.string().max(2000).optional(),
listType: z.enum(["collection", "wishlist", "custom"]).default("collection"),
isPublic: z.boolean().default(true),
drinkIds: z.array(z.string()).default([]),
})
export const sharedListUpdateSchema = z.object({
title: z.string().min(1).max(200).optional(),
description: z.string().max(2000).optional().nullable(),
isPublic: z.boolean().optional(),
})
export type DrinkCreate = z.infer<typeof drinkCreateSchema>
export type DrinkUpdate = z.infer<typeof drinkUpdateSchema>
export type RatingCreate = z.infer<typeof ratingCreateSchema>
export type RatingUpdate = z.infer<typeof ratingUpdateSchema>
export type ApiKeyInput = z.infer<typeof apiKeySchema>
export type UserPreferenceInput = z.infer<typeof userPreferenceSchema>
export type WishlistCreate = z.infer<typeof wishlistCreateSchema>
export type SharedListCreate = z.infer<typeof sharedListCreateSchema>
export type SharedListUpdate = z.infer<typeof sharedListUpdateSchema>
export const barItemCreateSchema = z.object({
name: z.string().min(1, "Name is required").max(200),
category: z.enum(["SPIRITS", "LIQUEURS", "MIXERS", "BITTERS", "GARNISHES", "TOOLS"]),
quantity: z.enum(["FULL", "HALF", "LOW", "EMPTY"]).default("FULL"),
notes: z.string().max(2000).optional(),
barcode: z.string().max(50).optional(),
imageUrl: imageUrlSchema.optional().or(z.literal("")),
})
export const barItemUpdateSchema = barItemCreateSchema.partial()
export type BarItemCreate = z.infer<typeof barItemCreateSchema>
export type BarItemUpdate = z.infer<typeof barItemUpdateSchema>
export const recipeCreateSchema = z.object({
title: z.string().min(1).max(200),
ingredients: z.array(z.object({
name: z.string(),
amount: z.string(),
available: z.boolean(),
})),
steps: z.array(z.string()),
garnish: z.string().max(200).optional().nullable(),
glassware: z.string().max(200).optional().nullable(),
sourceDrinkId: z.string().optional().nullable(),
notes: z.string().max(2000).optional().nullable(),
})
export type RecipeCreate = z.infer<typeof recipeCreateSchema>