diff --git a/apps/og-generator/app/api/assets/route.ts b/apps/og-generator/app/api/assets/route.ts index dc25076dde9..3f15dbe0f65 100644 --- a/apps/og-generator/app/api/assets/route.ts +++ b/apps/og-generator/app/api/assets/route.ts @@ -1,5 +1,5 @@ -import { sanitizeSvg } from '@/lib/assets/sanitize-svg' -import { insertAsset, listAssets } from '@/lib/supabase/assets' +import { sanitizeLogoSvg, sanitizeSvg } from '@/lib/assets/sanitize-svg' +import { insertAsset, insertLogoAsset, listAssets } from '@/lib/supabase/assets' import { getSupabaseAdmin } from '@/lib/supabase/server' // Node runtime — uses the Supabase server clients + parses uploaded files. @@ -16,43 +16,64 @@ function slugify(s: string): string { .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, '') - .slice(0, 40) || 'icon' + .slice(0, 40) || 'asset' ) } +const NO_ADMIN_ERROR = + 'Uploads need SUPABASE_SECRET_KEY in .env.local (and the migrations applied).' + +const RASTER_TYPES: Record = { + 'image/png': 'png', + 'image/jpeg': 'jpg', + 'image/webp': 'webp', +} +const MAX_LOGO_BYTES = 2_000_000 // 2 MB + /** - * POST (multipart: `file` = SVG, optional `label`) → upload a line-art icon. - * Sanitizes the SVG, then inserts it via the admin client. Writes need - * SUPABASE_SECRET_KEY; without it we return a clear, actionable error. + * POST — two upload kinds, selected by the `kind` form field ('icon', default, + * or 'logo'): + * + * - kind=icon (multipart: `file` = SVG, optional `label`): a line-art icon. + * Sanitized to stroke-only and inserted inline (unchanged behavior). + * - kind=logo (multipart: `file` = SVG/PNG/JPEG/WebP, `label`, `width`, + * `height`): a full-color partner/acquisition logo (brief follow-up). + * Colors are preserved; the file is stored in the og-assets bucket rather + * than inline, and `width`/`height` (client-measured) drive aspect-correct + * rendering later. + * + * Both need SUPABASE_SECRET_KEY — without it we return a clear 503 so the + * button never just silently fails. */ export async function POST(req: Request): Promise { if (!getSupabaseAdmin()) { - return Response.json( - { - error: - 'Uploads need SUPABASE_SECRET_KEY in .env.local (and the 0001_init.sql migration applied).', - }, - { status: 503 } - ) + return Response.json({ error: NO_ADMIN_ERROR }, { status: 503 }) } - let svgText = '' - let label = '' + let form: FormData try { - const form = await req.formData() - const file = form.get('file') - label = typeof form.get('label') === 'string' ? (form.get('label') as string) : '' - if (file instanceof File) { - if (file.size > 100_000) { - return Response.json({ error: 'SVG too large (max 100 KB).' }, { status: 400 }) - } - svgText = await file.text() - if (!label) label = file.name.replace(/\.svg$/i, '') - } + form = await req.formData() } catch { - return Response.json({ error: 'Expected multipart form data with an SVG file.' }, { status: 400 }) + return Response.json({ error: 'Expected multipart form data.' }, { status: 400 }) } + const kind = form.get('kind') === 'logo' ? 'logo' : 'icon' + return kind === 'logo' ? handleLogoUpload(form) : handleIconUpload(form) +} + +async function handleIconUpload(form: FormData): Promise { + const file = form.get('file') + let label = typeof form.get('label') === 'string' ? (form.get('label') as string) : '' + + if (!(file instanceof File)) { + return Response.json({ error: 'Expected an SVG file.' }, { status: 400 }) + } + if (file.size > 100_000) { + return Response.json({ error: 'SVG too large (max 100 KB).' }, { status: 400 }) + } + const svgText = await file.text() + if (!label) label = file.name.replace(/\.svg$/i, '') + const clean = sanitizeSvg(svgText) if (!clean) { return Response.json( @@ -69,9 +90,75 @@ export async function POST(req: Request): Promise { const asset = await insertAsset({ name, label: displayLabel, tags, viewBox: clean.viewBox, body: clean.body }) return Response.json({ asset }) } catch (err) { - console.error('[api/assets] insert failed:', err) + console.error('[api/assets] icon insert failed:', err) + return Response.json({ error: 'Could not save the icon — is 0001_init.sql applied?' }, { status: 500 }) + } +} + +async function handleLogoUpload(form: FormData): Promise { + const file = form.get('file') + let label = typeof form.get('label') === 'string' ? (form.get('label') as string) : '' + const width = Number(form.get('width')) + const height = Number(form.get('height')) + + if (!(file instanceof File)) { + return Response.json({ error: 'Expected an SVG, PNG, JPEG, or WebP file.' }, { status: 400 }) + } + if (file.size > MAX_LOGO_BYTES) { + return Response.json({ error: 'Logo too large (max 2 MB).' }, { status: 400 }) + } + if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) { + return Response.json({ error: 'Missing or invalid logo dimensions.' }, { status: 400 }) + } + if (!label) label = file.name.replace(/\.\w+$/, '') + + const displayLabel = (label || 'Logo').trim().slice(0, 40) || 'Logo' + const name = `${slugify(displayLabel)}-${Date.now().toString(36).slice(-4)}` + const tags = Array.from(new Set(slugify(displayLabel).split('-').filter((w) => w.length > 1))) + + let fileBody: Buffer + let contentType: string + let ext: string + + const isSvg = file.type === 'image/svg+xml' || file.name.toLowerCase().endsWith('.svg') + if (isSvg) { + const clean = sanitizeLogoSvg(await file.text()) + if (!clean) { + return Response.json( + { error: 'Not a usable SVG logo (empty, too complex, or contained unsupported content).' }, + { status: 400 } + ) + } + fileBody = Buffer.from( + `${clean.body}` + ) + contentType = 'image/svg+xml' + ext = 'svg' + } else { + ext = RASTER_TYPES[file.type] + if (!ext) { + return Response.json({ error: 'Unsupported file type — use SVG, PNG, JPEG, or WebP.' }, { status: 400 }) + } + fileBody = Buffer.from(await file.arrayBuffer()) + contentType = file.type + } + + try { + const asset = await insertLogoAsset({ + name, + label: displayLabel, + tags, + fileBody, + contentType, + ext, + width: Math.round(width), + height: Math.round(height), + }) + return Response.json({ asset }) + } catch (err) { + console.error('[api/assets] logo insert failed:', err) return Response.json( - { error: 'Could not save the asset — is the 0001_init.sql migration applied?' }, + { error: 'Could not save the logo — is 0002_logo_assets.sql applied?' }, { status: 500 } ) } diff --git a/apps/og-generator/app/api/og/route.tsx b/apps/og-generator/app/api/og/route.tsx index 6e2977715ac..8d695d3bffd 100644 --- a/apps/og-generator/app/api/og/route.tsx +++ b/apps/og-generator/app/api/og/route.tsx @@ -34,6 +34,12 @@ const EYEBROW = typography.roles.eyebrow const ICON_SIZE = 220 // OG icon size (1x design px) const ICON_STROKE = illustration.defaultStrokePx +/** Scale (naturalW, naturalH) to fit within a boxSize square, preserving aspect ratio. */ +function fitBox(naturalW: number, naturalH: number, boxSize: number): { w: number; h: number } { + const ratio = naturalW / naturalH || 1 + return ratio >= 1 ? { w: boxSize, h: boxSize / ratio } : { w: boxSize * ratio, h: boxSize } +} + const THUMB_ICON_DEFAULT = 380 const THUMB_ICON_MIN = 160 const THUMB_ICON_MAX = 480 @@ -120,7 +126,15 @@ export async function GET(req: Request) { }} > {patternLayer(cfg)} - {iconObj ? ( + {iconObj && iconObj.kind === 'logo' && iconObj.url ? ( + // Custom color logo — rendered as-is (no stroke normalization), + // fit to its natural aspect ratio (brief follow-up: partnerships). + // eslint-disable-next-line @next/next/no-img-element + + ) : iconObj ? ( // eslint-disable-next-line @next/next/no-img-element ) - const iconEl = iconObj ? ( - // eslint-disable-next-line @next/next/no-img-element - - ) : null + const iconEl = + iconObj && iconObj.kind === 'logo' && iconObj.url ? ( + // Custom color logo — rendered as-is, fit to its natural aspect ratio. + // eslint-disable-next-line @next/next/no-img-element + + ) : iconObj ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : null const cfg = resolvePattern(template.defaultPattern) // Grid-snap (§4): phase the pattern so a grid line lands on the safe-area diff --git a/apps/og-generator/app/page.tsx b/apps/og-generator/app/page.tsx index b94db2f82be..ab2608cfb4b 100644 --- a/apps/og-generator/app/page.tsx +++ b/apps/og-generator/app/page.tsx @@ -340,6 +340,48 @@ export default function Page() { setUploading(false) } } + + // Custom color logos (partnerships, acquisitions, co-marketing) — rendered + // full-color, no stroke normalization. The browser measures natural pixel + // size before upload so /api/og can fit it without distortion. + const [uploadingLogo, setUploadingLogo] = useState(false) + const [logoError, setLogoError] = useState(null) + const uploadLogo = async (file: File) => { + setUploadingLogo(true) + setLogoError(null) + try { + const { width, height } = await new Promise<{ width: number; height: number }>((resolve, reject) => { + const url = URL.createObjectURL(file) + const img = new Image() + img.onload = () => { + resolve({ width: img.naturalWidth, height: img.naturalHeight }) + URL.revokeObjectURL(url) + } + img.onerror = () => { + URL.revokeObjectURL(url) + reject(new Error('Could not read the image — is it a valid SVG/PNG/JPEG/WebP?')) + } + img.src = url + }) + const fd = new FormData() + fd.append('file', file) + fd.append('kind', 'logo') + fd.append('width', String(width)) + fd.append('height', String(height)) + const res = await fetch('/api/assets', { method: 'POST', body: fd }) + const data = await res.json() + if (!res.ok) { + setLogoError(data.error ?? 'Upload failed') + return + } + setUploadedIcons((prev) => [data.asset as SeedIcon, ...prev]) + setIcon((data.asset as SeedIcon).name) + } catch (err) { + setLogoError(err instanceof Error ? err.message : 'Upload failed — please try again.') + } finally { + setUploadingLogo(false) + } + } const [thumbSize, setThumbSize] = useState(380) const [scale, setScale] = useState<1 | 2>(1) const [showSafeArea, setShowSafeArea] = useState(false) @@ -677,49 +719,77 @@ export default function Page() { key={ic.name} type="button" onClick={() => setIcon(ic.name)} - title={ic.label} - className={`flex h-14 items-center justify-center rounded-md border ${ + title={ic.kind === 'logo' ? `${ic.label} (color logo)` : ic.label} + className={`flex h-14 items-center justify-center rounded-md border p-1.5 ${ icon === ic.name ? 'border-brand bg-brand/10 text-brand' : 'border-default bg-surface-100 text-foreground-light hover:border-strong' }`} > - + {ic.kind === 'logo' && ic.url ? ( + // Real colors, no forced stroke — this is the point of a logo. + // eslint-disable-next-line @next/next/no-img-element + {ic.label} + ) : ( + + )} ))} - +
+ + +
{uploadError &&

{uploadError}

} + {logoError &&

{logoError}

}

- Line-art SVGs become shared icons (stored in Supabase, re-drawn with the locked - stroke). Uploading needs the Supabase secret key configured. + Line-art SVGs become shared icons, re-drawn with the locked stroke. Logos (SVG, PNG, + JPEG, or WebP) keep their original colors — for partnerships, acquisitions, and + co-marketing. Both are stored in Supabase and need the secret key configured.

diff --git a/apps/og-generator/lib/assets/sanitize-svg.ts b/apps/og-generator/lib/assets/sanitize-svg.ts index 396c18f2017..85eb1791e15 100644 --- a/apps/og-generator/lib/assets/sanitize-svg.ts +++ b/apps/og-generator/lib/assets/sanitize-svg.ts @@ -78,3 +78,124 @@ export function sanitizeSvg(input: string): SanitizedSvg | null { return { viewBox, body } } + +/** + * Sanitizer for uploaded LOGOS (partnerships, acquisitions, co-marketing) — + * unlike `sanitizeSvg`, this preserves the logo's original colors (fill, + * stroke, gradients) instead of forcing the stroke-only brand treatment, since + * a partner's logo must render in its own brand colors, not ours. Still an + * allowlist: no scripts, no external references, no event handlers — the + * safety bar is the same, only the "what survives" set is different. + */ + +const ALLOWED_LOGO_ELEMENTS = new Set([ + 'path', 'circle', 'ellipse', 'rect', 'line', 'polyline', 'polygon', 'g', + 'defs', 'lineargradient', 'radialgradient', 'stop', 'clippath', 'mask', 'symbol', 'use', +]) + +const DANGEROUS_LOGO_BLOCKS = + /<\s*(script|style|foreignObject|animate\w*|set|a|image|text|iframe|marker)\b[\s\S]*?<\s*\/\s*\1\s*>/gi +const DANGEROUS_LOGO_SELFCLOSE = + /<\s*(script|style|foreignObject|animate\w*|set|image|iframe)\b[^>]*\/?\s*>/gi + +const SAFE_STYLE_PROPS = new Set([ + 'fill', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin', 'stroke-dasharray', + 'opacity', 'fill-opacity', 'stroke-opacity', 'stop-color', 'stop-opacity', 'fill-rule', 'clip-rule', +]) + +/** Only allow `url(#internal)` references; reject external/data/js URLs. */ +function isSafeUrlRef(value: string): boolean { + const m = value.match(/url\(\s*['"]?([^'")]*)['"]?\s*\)/i) + if (!m) return true // no url(...) present — nothing to check + return m[1].startsWith('#') +} + +function sanitizeStyleValue(raw: string): string | null { + const kept = raw + .split(';') + .map((decl) => { + const i = decl.indexOf(':') + if (i === -1) return null + const prop = decl.slice(0, i).trim().toLowerCase() + const val = decl.slice(i + 1).trim() + if (!SAFE_STYLE_PROPS.has(prop)) return null + if (/javascript|expression\(|@import/i.test(val)) return null + if (!isSafeUrlRef(val)) return null + return `${prop}:${val}` + }) + .filter((d): d is string => d !== null) + return kept.length ? kept.join(';') : null +} + +export function sanitizeLogoSvg(input: string): SanitizedSvg | null { + if (!input || input.length > 300_000) return null + const s = input.trim() + + if (/]*)>([\s\S]*?)<\/svg>/i) + if (!svgMatch) return null + const attrs = svgMatch[1] + let body = svgMatch[2] + + let viewBox = '0 0 300 300' + const vb = attrs.match(/viewBox\s*=\s*["']([^"']+)["']/i) + if (vb) { + viewBox = vb[1].trim() + } else { + const w = attrs.match(/\bwidth\s*=\s*["']([\d.]+)/i) + const h = attrs.match(/\bheight\s*=\s*["']([\d.]+)/i) + if (w && h) viewBox = `0 0 ${w[1]} ${h[1]}` + } + if (!/^[-\d.\s]+$/.test(viewBox)) viewBox = '0 0 300 300' + + body = body.replace(//g, '') + body = body.replace(DANGEROUS_LOGO_BLOCKS, '') + body = body.replace(DANGEROUS_LOGO_SELFCLOSE, '') + + // Drop any element whose tag isn't allowlisted (case-insensitively). + body = body.replace(/<\s*\/?\s*([a-zA-Z][\w:-]*)\b[^>]*>/g, (tag, name: string) => + ALLOWED_LOGO_ELEMENTS.has(name.toLowerCase()) ? tag : '' + ) + + // Event handlers: always stripped. + body = body.replace(/\son\w+\s*=\s*"[^"]*"/gi, '') + body = body.replace(/\son\w+\s*=\s*'[^']*'/gi, '') + + // href/xlink:href: keep only internal (#id) references — needed for + // and gradient reuse; strip anything external, data:, or javascript:. + body = body.replace(/\s(xlink:)?href\s*=\s*"([^"]*)"/gi, (m, xl, val) => + val.startsWith('#') ? ` ${xl ?? ''}href="${val}"` : '' + ) + body = body.replace(/\s(xlink:)?href\s*=\s*'([^']*)'/gi, (m, xl, val) => + val.startsWith('#') ? ` ${xl ?? ''}href='${val}'` : '' + ) + + // style="...": keep only a safe color/opacity property allowlist. + body = body.replace(/\sstyle\s*=\s*"([^"]*)"/gi, (m, val) => { + const safe = sanitizeStyleValue(val) + return safe ? ` style="${safe}"` : '' + }) + body = body.replace(/\sstyle\s*=\s*'([^']*)'/gi, (m, val) => { + const safe = sanitizeStyleValue(val) + return safe ? ` style='${safe}'` : '' + }) + + // fill/stroke/clip-path/mask/filter as plain attributes: block external + // url(...) refs (internal #id refs, hex colors, named colors all pass). + body = body.replace(/\sfilter\s*=\s*["'][^"']*["']/gi, '') // no filter support + for (const attr of ['fill', 'stroke', 'clip-path', 'mask']) { + const re = new RegExp(`\\s${attr}\\s*=\\s*(["'])([^"']*)\\1`, 'gi') + body = body.replace(re, (m, q, val) => (isSafeUrlRef(val) ? m : '')) + } + + // Defense in depth — href/url(...) refs above already reject non-# targets, + // so this shouldn't normally match anything, but strip stray javascript: + // schemes if they slipped through in some other attribute. + body = body.replace(/\bjavascript\s*:/gi, '') + + body = body.trim() + if (!body) return null + + return { viewBox, body } +} diff --git a/apps/og-generator/lib/assets/seed-icons.ts b/apps/og-generator/lib/assets/seed-icons.ts index c3c92257b6f..58a643e2b6c 100644 --- a/apps/og-generator/lib/assets/seed-icons.ts +++ b/apps/og-generator/lib/assets/seed-icons.ts @@ -16,6 +16,18 @@ export interface SeedIcon { viewBox: string /** Inner SVG markup — strokes use currentColor, fill none. */ body: string + /** + * Asset kind. Seed/Lucide icons and uploaded line icons are 'icon' (the + * default when omitted) — rendered stroke-only via lib/design/icons.ts. + * Custom color logos (partnerships, acquisitions) are 'logo' — rendered + * full-color, fit to their natural aspect ratio, no stroke normalization. + */ + kind?: 'icon' | 'logo' + /** Public URL for a logo stored in Supabase Storage (kind: 'logo'). */ + url?: string + /** Natural pixel dimensions, client-measured at upload (kind: 'logo'). */ + width?: number + height?: number } export const SEED_ICONS: SeedIcon[] = [ diff --git a/apps/og-generator/lib/supabase/assets.ts b/apps/og-generator/lib/supabase/assets.ts index 7e3a1e48642..d357abb74b5 100644 --- a/apps/og-generator/lib/supabase/assets.ts +++ b/apps/og-generator/lib/supabase/assets.ts @@ -9,21 +9,52 @@ import { getSupabase, getSupabaseAdmin } from '@/lib/supabase/server' * writes use the admin (secret) client through our server route, so the * publishable key stays read-only. Everything degrades to seed-only when the * project isn't configured. + * + * Two asset kinds share this table (brief follow-up — custom color logos): + * - 'icon' (default): line art, inline SVG `body`, rendered stroke-only. + * - 'logo': full-color partner/acquisition logos. Stored as a file in the + * og-assets Storage bucket (`storage_path`), not inline — may be raster. + * `width`/`height` (client-measured at upload) drive aspect-correct + * rendering. Rendered as-is, no stroke normalization. */ +const OG_ASSETS_BUCKET = 'og-assets' +const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL + interface AssetRow { name: string label: string tags: string[] | null + kind: string | null view_box: string body: string | null + storage_path: string | null + width: number | null + height: number | null +} + +/** Public URL for a Storage object, without an extra network round-trip. */ +function publicStorageUrl(path: string): string | undefined { + if (!SUPABASE_URL) return undefined + return `${SUPABASE_URL}/storage/v1/object/public/${OG_ASSETS_BUCKET}/${path}` } function rowToIcon(r: AssetRow): SeedIcon { - return { name: r.name, label: r.label, tags: r.tags ?? [], viewBox: r.view_box, body: r.body ?? '' } + const kind = r.kind === 'logo' ? 'logo' : 'icon' + return { + name: r.name, + label: r.label, + tags: r.tags ?? [], + viewBox: r.view_box, + body: r.body ?? '', + kind, + url: r.storage_path ? publicStorageUrl(r.storage_path) : undefined, + width: r.width ?? undefined, + height: r.height ?? undefined, + } } -const COLUMNS = 'name,label,tags,view_box,body' +const COLUMNS = 'name,label,tags,kind,view_box,body,storage_path,width,height' /** Uploaded assets, newest first. Returns [] when unconfigured or on error. */ export async function listAssets(): Promise { @@ -35,21 +66,23 @@ export async function listAssets(): Promise { .select(COLUMNS) .order('created_at', { ascending: false }) if (error || !data) return [] - return (data as AssetRow[]).filter((r) => r.body).map(rowToIcon) + return (data as AssetRow[]).filter((r) => r.body || r.storage_path).map(rowToIcon) } catch { return [] } } -/** Resolve an icon by name: bundled seed first, then an uploaded asset. */ +/** Resolve an icon/logo by name: bundled seed first, then an uploaded asset. */ export async function resolveIcon(name: string): Promise { if (ICON_MAP[name]) return ICON_MAP[name] const supabase = getSupabase() if (!supabase) return null try { const { data, error } = await supabase.from('assets').select(COLUMNS).eq('name', name).maybeSingle() - if (error || !data || !(data as AssetRow).body) return null - return rowToIcon(data as AssetRow) + if (error || !data) return null + const row = data as AssetRow + if (!row.body && !row.storage_path) return null + return rowToIcon(row) } catch { return null } @@ -63,7 +96,7 @@ export interface NewAsset { body: string } -/** Insert an uploaded asset (admin/secret key). Throws 'NO_ADMIN' if unconfigured. */ +/** Insert an uploaded line icon (admin/secret key). Throws 'NO_ADMIN' if unconfigured. */ export async function insertAsset(a: NewAsset): Promise { const admin = getSupabaseAdmin() if (!admin) throw new Error('NO_ADMIN') @@ -75,3 +108,50 @@ export async function insertAsset(a: NewAsset): Promise { if (error || !data) throw new Error(error?.message ?? 'insert failed') return rowToIcon(data as AssetRow) } + +export interface NewLogo { + name: string + label: string + tags: string[] + /** Raw file bytes — sanitized SVG text (as a Buffer) or the original raster bytes. */ + fileBody: Buffer + contentType: string + /** File extension without the dot, e.g. 'svg', 'png'. */ + ext: string + /** Natural pixel size, measured client-side before upload. */ + width: number + height: number +} + +/** + * Upload + insert a color logo (admin/secret key). Stores the file in the + * og-assets Storage bucket rather than inline, so it works for raster too. + * Throws 'NO_ADMIN' if unconfigured. + */ +export async function insertLogoAsset(a: NewLogo): Promise { + const admin = getSupabaseAdmin() + if (!admin) throw new Error('NO_ADMIN') + + const path = `logos/${a.name}.${a.ext}` + const { error: uploadError } = await admin.storage + .from(OG_ASSETS_BUCKET) + .upload(path, a.fileBody, { contentType: a.contentType, upsert: false }) + if (uploadError) throw new Error(uploadError.message) + + const { data, error } = await admin + .from('assets') + .insert({ + name: a.name, + label: a.label, + tags: a.tags, + kind: 'logo', + view_box: `0 0 ${a.width} ${a.height}`, + storage_path: path, + width: a.width, + height: a.height, + }) + .select(COLUMNS) + .single() + if (error || !data) throw new Error(error?.message ?? 'insert failed') + return rowToIcon(data as AssetRow) +} diff --git a/apps/og-generator/supabase/migrations/0002_logo_assets.sql b/apps/og-generator/supabase/migrations/0002_logo_assets.sql new file mode 100644 index 00000000000..f95cbaf1488 --- /dev/null +++ b/apps/og-generator/supabase/migrations/0002_logo_assets.sql @@ -0,0 +1,12 @@ +-- Custom color logos (partnerships, acquisitions, co-marketing) — follow-up to +-- 0001_init.sql. Logos render in their ORIGINAL colors (no stroke +-- normalization, unlike line icons) via `assets.kind = 'logo'`, stored in the +-- og-assets Storage bucket (already created in 0001) rather than inline, since +-- they may be raster. width/height are captured at upload time (client-measured +-- natural pixel size) so the renderer can fit them without distortion. +-- +-- Apply in the Supabase dashboard → SQL Editor. Idempotent. + +alter table public.assets + add column if not exists width integer, + add column if not exists height integer;