mirror of
https://github.com/supabase/supabase.git
synced 2026-10-11 04:15:04 +03:00
feat(og-generator): custom color logos for partnerships/acquisitions
Line icons are stroke-only by brand rule, but a partner's logo (Grafana,
Multigres, etc.) needs to render in ITS OWN colors — a second asset kind
alongside the existing icon system, not a replacement for it.
- supabase/migrations/0002_logo_assets.sql: add width/height to `assets`
(kind + storage_path already existed from 0001)
- lib/assets/sanitize-svg.ts: sanitizeLogoSvg() — a separate allowlist that
PRESERVES fill/stroke/gradients/style color declarations (unlike the icon
sanitizer, which strips them for the stroke-only treatment), while still
blocking scripts, event handlers, and external references (internal `#id`
refs are kept so gradient defs/<use> keep working). Unit-tested against a
mixed color+attack payload.
- lib/supabase/assets.ts: insertLogoAsset() uploads to the og-assets Storage
bucket (raster-safe, unlike inline SVG bodies) and records client-measured
width/height; rowToIcon exposes kind/url/width/height for both kinds.
- app/api/assets/route.ts: POST branches on a `kind` field ('icon', default,
or 'logo'); logo accepts SVG/PNG/JPEG/WebP up to 2MB, requires width+height.
- app/api/og/route.tsx: icon slots render logos as-is (no stroke
normalization), fit to their natural aspect ratio via a new fitBox() helper.
- app/page.tsx: a second "+ Upload logo (color)" control measures the file's
natural dimensions in-browser (Image().onload) before POSTing; the icon
picker renders logo entries as real <img> (actual colors) instead of the
forced-stroke SVG redraw.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
a15601fe9c
commit
5ec0a89f5d
7 files changed
+482
-81
No files matched your search
@@ -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<string, string> = {
|
||||
'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<Response> {
|
||||
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<Response> {
|
||||
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<Response> {
|
||||
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<Response> {
|
||||
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(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="${clean.viewBox}">${clean.body}</svg>`
|
||||
)
|
||||
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 }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
<img
|
||||
{...fitBox(iconObj.width ?? 1, iconObj.height ?? 1, thumbSize * s)}
|
||||
src={iconObj.url}
|
||||
/>
|
||||
) : iconObj ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
width={thumbSize * s}
|
||||
@@ -245,18 +259,23 @@ export async function GET(req: Request) {
|
||||
</div>
|
||||
)
|
||||
|
||||
const iconEl = iconObj ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
width={ICON_SIZE * s}
|
||||
height={ICON_SIZE * s}
|
||||
src={iconDataUri(iconObj, {
|
||||
sizePx: ICON_SIZE * s,
|
||||
strokePx: ICON_STROKE * s,
|
||||
color: color('illustration.stroke'),
|
||||
})}
|
||||
/>
|
||||
) : 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
|
||||
<img {...fitBox(iconObj.width ?? 1, iconObj.height ?? 1, ICON_SIZE * s)} src={iconObj.url} />
|
||||
) : iconObj ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
width={ICON_SIZE * s}
|
||||
height={ICON_SIZE * s}
|
||||
src={iconDataUri(iconObj, {
|
||||
sizePx: ICON_SIZE * s,
|
||||
strokePx: ICON_STROKE * s,
|
||||
color: color('illustration.stroke'),
|
||||
})}
|
||||
/>
|
||||
) : null
|
||||
|
||||
const cfg = resolvePattern(template.defaultPattern)
|
||||
// Grid-snap (§4): phase the pattern so a grid line lands on the safe-area
|
||||
|
||||
+103
-33
@@ -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<string | null>(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'
|
||||
}`}
|
||||
>
|
||||
<svg
|
||||
width={22}
|
||||
height={22}
|
||||
viewBox={ic.viewBox}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
dangerouslySetInnerHTML={{ __html: ic.body }}
|
||||
/>
|
||||
{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
|
||||
<img src={ic.url} alt={ic.label} className="max-h-full max-w-full object-contain" />
|
||||
) : (
|
||||
<svg
|
||||
width={22}
|
||||
height={22}
|
||||
viewBox={ic.viewBox}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
dangerouslySetInnerHTML={{ __html: ic.body }}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<label
|
||||
className={`rounded-md border border-dashed border-default px-3 py-2 text-center text-xs text-foreground-light hover:border-strong ${
|
||||
uploading ? 'cursor-wait opacity-70' : 'cursor-pointer'
|
||||
}`}
|
||||
>
|
||||
{uploading ? 'Uploading…' : '+ Upload SVG icon'}
|
||||
<input
|
||||
type="file"
|
||||
accept=".svg,image/svg+xml"
|
||||
className="hidden"
|
||||
disabled={uploading}
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0]
|
||||
if (f) uploadSvg(f)
|
||||
e.target.value = ''
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<label
|
||||
className={`rounded-md border border-dashed border-default px-3 py-2 text-center text-xs text-foreground-light hover:border-strong ${
|
||||
uploading ? 'cursor-wait opacity-70' : 'cursor-pointer'
|
||||
}`}
|
||||
>
|
||||
{uploading ? 'Uploading…' : '+ Upload SVG icon'}
|
||||
<input
|
||||
type="file"
|
||||
accept=".svg,image/svg+xml"
|
||||
className="hidden"
|
||||
disabled={uploading}
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0]
|
||||
if (f) uploadSvg(f)
|
||||
e.target.value = ''
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label
|
||||
className={`rounded-md border border-dashed border-default px-3 py-2 text-center text-xs text-foreground-light hover:border-strong ${
|
||||
uploadingLogo ? 'cursor-wait opacity-70' : 'cursor-pointer'
|
||||
}`}
|
||||
>
|
||||
{uploadingLogo ? 'Uploading…' : '+ Upload logo (color)'}
|
||||
<input
|
||||
type="file"
|
||||
accept=".svg,.png,.jpg,.jpeg,.webp,image/svg+xml,image/png,image/jpeg,image/webp"
|
||||
className="hidden"
|
||||
disabled={uploadingLogo}
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0]
|
||||
if (f) uploadLogo(f)
|
||||
e.target.value = ''
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{uploadError && <p className="text-xs text-warning-600">{uploadError}</p>}
|
||||
{logoError && <p className="text-xs text-warning-600">{logoError}</p>}
|
||||
<p className="text-xs text-foreground-lighter">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
@@ -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 (/<!DOCTYPE|<!ENTITY|<\?xml-stylesheet/i.test(s)) return null
|
||||
|
||||
const svgMatch = s.match(/<svg\b([^>]*)>([\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(/<!--[\s\S]*?-->/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 <use>
|
||||
// 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 }
|
||||
}
|
||||
@@ -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[] = [
|
||||
|
||||
@@ -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<SeedIcon[]> {
|
||||
@@ -35,21 +66,23 @@ export async function listAssets(): Promise<SeedIcon[]> {
|
||||
.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<SeedIcon | null> {
|
||||
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<SeedIcon> {
|
||||
const admin = getSupabaseAdmin()
|
||||
if (!admin) throw new Error('NO_ADMIN')
|
||||
@@ -75,3 +108,50 @@ export async function insertAsset(a: NewAsset): Promise<SeedIcon> {
|
||||
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<SeedIcon> {
|
||||
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)
|
||||
}
|
||||
@@ -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;
|
||||
Reference in new issue
Block a user