diff --git a/app/db/schema.sql b/app/db/schema.sql index 72f2ef12..00cc689c 100644 --- a/app/db/schema.sql +++ b/app/db/schema.sql @@ -318,3 +318,8 @@ CREATE TABLE IF NOT EXISTS bb_quote_tokens ( checked_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY (chain_id, address) ); + +-- ── token banners (2026-10-05) ───────────────────────────────────────────── +-- An optional wide image the creator uploads beside the logo. Same rules as image_url (https only; uploads are +-- re-encoded server-side, here to a 1500×500 WebP). NULL means no banner: the market cards draw one from the logo. +ALTER TABLE bb_launch_meta ADD COLUMN IF NOT EXISTS banner_url text; diff --git a/app/src/app/api/launch/image/route.ts b/app/src/app/api/launch/image/route.ts index 4c3048a0..456b3af1 100644 --- a/app/src/app/api/launch/image/route.ts +++ b/app/src/app/api/launch/image/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from "next/server"; import { rateLimited } from "@/lib/launchpad/editServer"; -import { IMAGE_MAX_BYTES, IMAGE_UPLOADS_PER_HOUR, checkUpload, isWalletParam, randomImageKey } from "@/lib/launchpad/images"; -import { toLogoWebp } from "@/lib/launchpad/imageProcess"; +import { BANNER_MAX_BYTES, IMAGE_UPLOADS_PER_HOUR, checkUpload, imageRole, isWalletParam, maxBytesFor, randomImageKey } from "@/lib/launchpad/images"; +import { toBannerWebp, toLogoWebp } from "@/lib/launchpad/imageProcess"; import { imageUploadsEnabled, putImage } from "@/lib/launchpad/imageStore"; export const dynamic = "force-dynamic"; @@ -10,9 +10,10 @@ export const runtime = "nodejs"; const HOUR = 60 * 60_000; /** - * POST multipart {wallet, file} → {url}. Token logo upload for the launch form / edit sheet. - * Order: wallet + rate limits → size (Content-Length, then real bytes) → magic bytes → sharp re-encode - * to a 512² WebP (metadata stripped; the decoder is the validator) → random key → public bucket. + * POST multipart {wallet, file, kind?} → {url}. Token logo (or, with kind=banner, banner) upload for the launch + * form / edit sheet. Order: wallet + rate limits → size (Content-Length, then real bytes) → magic bytes → sharp + * re-encode to a 512² logo or a 1500×500 banner WebP (metadata stripped; the decoder is the validator) → random key + * → public bucket. Logos and banners share the upload limits. * The URL then flows through the normal metadata path (https-only rule, signed edits) unchanged. */ export async function POST(req: Request) { @@ -20,7 +21,8 @@ export async function POST(req: Request) { const ip = (req.headers.get("fly-client-ip") || req.headers.get("x-forwarded-for") || "").split(",")[0].trim() || "0.0.0.0"; if (rateLimited(`img:ip:${ip}`, IMAGE_UPLOADS_PER_HOUR * 2, HOUR)) return NextResponse.json({ error: "slow down" }, { status: 429 }); const declared = Number(req.headers.get("content-length") || 0); - if (declared > IMAGE_MAX_BYTES + 16 * 1024) return NextResponse.json({ error: "image must be ≤ 2 MB" }, { status: 413 }); + // the role is inside the body, so the early check allows the larger (banner) cap; the real cap is applied below + if (declared > BANNER_MAX_BYTES + 16 * 1024) return NextResponse.json({ error: "image must be ≤ 4 MB" }, { status: 413 }); let form: FormData; try { @@ -30,18 +32,20 @@ export async function POST(req: Request) { } const wallet = form.get("wallet"); const file = form.get("file"); + const role = imageRole(form.get("kind")); + const max = maxBytesFor(role); if (!isWalletParam(wallet)) return NextResponse.json({ error: "connect a wallet first" }, { status: 400 }); if (!(file instanceof Blob)) return NextResponse.json({ error: "file required" }, { status: 400 }); if (rateLimited(`img:wallet:${wallet.toLowerCase()}`, IMAGE_UPLOADS_PER_HOUR, HOUR)) return NextResponse.json({ error: "upload limit reached, try later" }, { status: 429 }); - if (file.size > IMAGE_MAX_BYTES) return NextResponse.json({ error: "image must be ≤ 2 MB" }, { status: 413 }); + if (file.size > max) return NextResponse.json({ error: `image must be ≤ ${max / 1024 / 1024} MB` }, { status: 413 }); const bytes = new Uint8Array(await file.arrayBuffer()); - const pre = checkUpload(bytes); + const pre = checkUpload(bytes, max); if (!pre.ok) return NextResponse.json({ error: pre.error }, { status: pre.status }); let out: Buffer; try { - out = await toLogoWebp(bytes); + out = role === "banner" ? await toBannerWebp(bytes) : await toLogoWebp(bytes); } catch (e) { const msg = e instanceof Error && /too small/.test(e.message) ? e.message : "not a valid image"; return NextResponse.json({ error: msg }, { status: 415 }); diff --git a/app/src/components/launchpad/EditTokenSheet.tsx b/app/src/components/launchpad/EditTokenSheet.tsx index f4fba301..f81c31f2 100644 --- a/app/src/components/launchpad/EditTokenSheet.tsx +++ b/app/src/components/launchpad/EditTokenSheet.tsx @@ -17,7 +17,7 @@ import { friendlyError } from "@/lib/errors"; export default function EditTokenSheet({ chain, token, symbol, initial, onClose, onSaved }: { chain: ChainKey; token: string; symbol: string; initial: EditFields; onClose: () => void; onSaved: (f: EditFields) => void }) { const config = useConfig(); const { address } = useAccount(); - const [f, setF] = useState({ description: initial.description ?? "", image_url: initial.image_url ?? "", website: initial.website ?? "", x_handle: initial.x_handle ?? "" }); + const [f, setF] = useState({ description: initial.description ?? "", image_url: initial.image_url ?? "", banner_url: initial.banner_url ?? "", website: initial.website ?? "", x_handle: initial.x_handle ?? "" }); const [phase, setPhase] = useState<"idle" | "nonce" | "sign" | "save" | "done">("idle"); const [err, setErr] = useState(null); const v = validateEdit(f); @@ -61,6 +61,10 @@ export default function EditTokenSheet({ chain, token, symbol, initial, onClose,

Image

setF({ ...f, image_url: url })} wallet={address} /> +
+

Banner

+ setF({ ...f, banner_url: url })} wallet={address} /> +
diff --git a/app/src/components/launchpad/ImageUpload.tsx b/app/src/components/launchpad/ImageUpload.tsx index a8bf6eac..bc699769 100644 --- a/app/src/components/launchpad/ImageUpload.tsx +++ b/app/src/components/launchpad/ImageUpload.tsx @@ -1,34 +1,41 @@ "use client"; import { useId, useRef, useState } from "react"; +import { ImagePlus, X } from "lucide-react"; import { helper, input as inputCls } from "@/components/ui"; -import { IMAGE_MAX_BYTES, sniffImage } from "@/lib/launchpad/images"; +import { BANNER_HEIGHT, BANNER_WIDTH, maxBytesFor, sniffImage, type ImageRole } from "@/lib/launchpad/images"; import { Spinner } from "@/components/Skeleton"; /** - * Token logo picker: drop zone / tap-to-browse → POST /api/launch/image → https URL into `value`. - * The URL field stays available for people who already host a logo. Wallet must be connected - * (uploads are rate-limited per wallet server-side). + * Token image picker: drop zone / tap-to-browse → POST /api/launch/image → https URL into `value`. + * The URL field stays available for people who already host the image. Wallet must be connected + * (uploads are rate-limited per wallet server-side). `kind="banner"` picks the wide banner instead of + * the square logo: a 3:1 drop zone that previews the banner across its full width. `stacked` (from sm up) + * fills the height it is given, so a logo and a banner side by side end level: the logo's tile sits above + * its words, and the banner zone drops its fixed 3:1 for the row's height. */ -export default function ImageUpload({ value, onChange, wallet, compact = false }: { value: string; onChange: (url: string) => void; wallet: string | undefined; compact?: boolean }) { +export default function ImageUpload({ value, onChange, wallet, compact = false, kind = "logo", stacked = false }: { value: string; onChange: (url: string) => void; wallet: string | undefined; compact?: boolean; kind?: ImageRole; stacked?: boolean }) { const id = useId(); const fileRef = useRef(null); const [busy, setBusy] = useState(false); const [err, setErr] = useState(null); const [over, setOver] = useState(false); const preview = /^https?:\/\//.test(value.trim()) ? value.trim() : null; + const banner = kind === "banner"; + const max = maxBytesFor(kind); async function upload(file: File) { setErr(null); if (!wallet) return setErr("Connect a wallet to upload."); - if (file.size > IMAGE_MAX_BYTES) return setErr("Max 2 MB."); + if (file.size > max) return setErr(`Max ${max / 1024 / 1024} MB.`); const head = new Uint8Array(await file.slice(0, 16).arrayBuffer()); if (!sniffImage(head)) return setErr("PNG, JPEG, WebP or GIF only."); setBusy(true); try { const fd = new FormData(); fd.set("wallet", wallet); - fd.set("file", file, "logo"); + fd.set("kind", kind); + fd.set("file", file, kind); const r = await fetch("/api/launch/image", { method: "POST", body: fd }); const j = (await r.json().catch(() => ({}))) as { url?: string; error?: string }; if (!r.ok || !j.url) throw new Error(j.error || `upload failed (${r.status})`); @@ -40,68 +47,96 @@ export default function ImageUpload({ value, onChange, wallet, compact = false } } } + // the drop zone behaves the same for both kinds; only what it draws differs + const zone = { + role: "button" as const, + tabIndex: 0, + "aria-label": banner ? "Upload token banner" : "Upload token image", + onClick: () => !busy && fileRef.current?.click(), + onKeyDown: (e: React.KeyboardEvent) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + fileRef.current?.click(); + } + }, + onDragOver: (e: React.DragEvent) => { + e.preventDefault(); + setOver(true); + }, + onDragLeave: () => setOver(false), + onDrop: (e: React.DragEvent) => { + e.preventDefault(); + setOver(false); + const f = e.dataTransfer.files?.[0]; + if (f) void upload(f); + }, + }; + const edge = `${over ? "border-brand bg-brand-soft" : "border-line hover:border-ink/40 bg-paper"} ${busy ? "opacity-70 cursor-progress" : ""}`; + const fileInput = ( + { + const f = e.target.files?.[0]; + e.target.value = ""; + if (f) void upload(f); + }} + /> + ); + const tile = compact ? 64 : 88; return ( -
-
!busy && fileRef.current?.click()} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - fileRef.current?.click(); - } - }} - onDragOver={(e) => { - e.preventDefault(); - setOver(true); - }} - onDragLeave={() => setOver(false)} - onDrop={(e) => { - e.preventDefault(); - setOver(false); - const f = e.dataTransfer.files?.[0]; - if (f) void upload(f); - }} - className={`flex items-center gap-4 rounded-2xl border border-dashed px-4 py-3 cursor-pointer transition-colors select-none ${over ? "border-brand bg-brand-soft" : "border-line hover:border-ink/40 bg-paper"} ${busy ? "opacity-70 cursor-progress" : ""}`} - > -
- {preview ? ( - // eslint-disable-next-line @next/next/no-img-element - - ) : busy ? ( - - ) : ( - - - - - - )} +
+ {banner ? ( +
+
+ {preview ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : null} +
+ {busy ? : preview ? null :
+ {fileInput} +
+ {preview && !busy ? ( + + ) : null}
-
-

{busy ? "Uploading…" : preview ? "Change image" : "Upload image"}

-

{busy ? "Resizing to 512×512" : "PNG, JPEG, WebP or GIF · max 2 MB · drop it here or tap"}

+ ) : ( +
+
+ {preview ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : busy ? ( + + ) : ( + + + + + + )} +
+
+

{busy ? "Uploading…" : preview ? "Change image" : "Upload image"}

+

{busy ? "Resizing to 512×512" : stacked ? "Square · PNG, JPEG, WebP or GIF · max 2 MB" : "PNG, JPEG, WebP or GIF · max 2 MB · drop it here or tap"}

+
+ {fileInput}
- { - const f = e.target.files?.[0]; - e.target.value = ""; - if (f) void upload(f); - }} - /> -
+ )} {err ?

{err}

: null}
- or paste an image URL - onChange(e.target.value)} placeholder="https://…/logo.png" inputMode="url" aria-label="Image URL" /> + {banner ? "or paste a banner URL" : "or paste an image URL"} + onChange(e.target.value)} placeholder={banner ? "https://…/banner.png" : "https://…/logo.png"} inputMode="url" aria-label={banner ? "Banner URL" : "Image URL"} />
); diff --git a/app/src/lib/launchpad/creator.test.ts b/app/src/lib/launchpad/creator.test.ts index fb0f182e..fbac32c2 100644 --- a/app/src/lib/launchpad/creator.test.ts +++ b/app/src/lib/launchpad/creator.test.ts @@ -116,3 +116,22 @@ test("clampSocial: ≤125 chars on a word boundary with an ellipsis; short text assert.equal(clampSocial("x".repeat(200)).length, 125, "no spaces → hard cut + ellipsis"); assert.ok(clampSocial("x".repeat(200), 155).length <= 155); }); + +test("a banner is signed when set, and a message signed before banners existed still matches", () => { + const args = { chain: "base", token: "0xab", wallet: "0xcd", nonce: "n", expiresAt: 0 }; + const withBanner = buildEditMessage({ ...args, fields: { description: "hi", banner_url: "https://x.y/b.webp" } }); + assert.match(withBanner, /\nbanner: https:\/\/x\.y\/b\.webp\n/); + const without = buildEditMessage({ ...args, fields: { description: "hi" } }); + assert.doesNotMatch(without, /banner:/); + assert.equal(buildEditMessage({ ...args, fields: { description: "hi", banner_url: "" } }), without, "an empty banner reads like no banner"); + assert.notEqual(withBanner, without); +}); + +test("validateEdit: the banner follows the image rules", () => { + const v = validateEdit({ banner_url: "https://x.y/b.webp" }); + assert.equal(v.ok, true); + if (v.ok) assert.equal(v.value.banner_url, "https://x.y/b.webp"); + assert.equal(validateEdit({ banner_url: "http://x.y/b.webp" }).ok, false); + assert.equal(validateEdit({ banner_url: "https://localhost/b.webp" }).ok, false); + assert.equal(validateEdit({ banner_url: "https://10.0.0.5/b.webp" }).ok, false); +}); diff --git a/app/src/lib/launchpad/editAuth.ts b/app/src/lib/launchpad/editAuth.ts index 7b44f3d4..c7ac4957 100644 --- a/app/src/lib/launchpad/editAuth.ts +++ b/app/src/lib/launchpad/editAuth.ts @@ -11,7 +11,7 @@ import { parseXHandle } from "./xHandle.ts"; export const EDIT_TTL_MS = 5 * 60_000; export const EDIT_DOMAIN = "openlaunch.lol"; -export type EditFields = { description?: string; image_url?: string; website?: string; x_handle?: string }; +export type EditFields = { description?: string; image_url?: string; banner_url?: string; website?: string; x_handle?: string }; export function buildEditMessage(p: { chain: string; token: string; wallet: string; nonce: string; expiresAt: number; fields: EditFields }): string { const lines = [ @@ -26,6 +26,8 @@ export function buildEditMessage(p: { chain: string; token: string; wallet: stri `Changes:`, `description: ${p.fields.description ?? ""}`, `image: ${p.fields.image_url ?? ""}`, + // only when set, so a message signed before banners existed still verifies (a set banner is always signed) + ...(p.fields.banner_url ? [`banner: ${p.fields.banner_url}`] : []), `website: ${p.fields.website ?? ""}`, `x: ${p.fields.x_handle ?? ""}`, ``, @@ -55,12 +57,14 @@ export function validateEdit(f: Partial>): { o } }; const image_url = url(f.image_url); + const banner_url = url(f.banner_url); const website = url(f.website); if (image_url === null) return { ok: false, error: "image: https URL only" }; + if (banner_url === null) return { ok: false, error: "banner: https URL only" }; if (website === null) return { ok: false, error: "website: https URL only" }; const x = parseXHandle(f.x_handle); if (!x.ok) return { ok: false, error: x.error }; const x_handle = x.handle; if (/<[a-z!/]/i.test(description)) return { ok: false, error: "description: no HTML" }; - return { ok: true, value: { description, image_url, website, x_handle } }; + return { ok: true, value: { description, image_url, banner_url, website, x_handle } }; } diff --git a/app/src/lib/launchpad/editServer.ts b/app/src/lib/launchpad/editServer.ts index 03dd1e77..b998372e 100644 --- a/app/src/lib/launchpad/editServer.ts +++ b/app/src/lib/launchpad/editServer.ts @@ -106,9 +106,9 @@ export async function applySignedEdit(r: EditRequest): Promise<{ ok: true } | { RETURNING nonce`; if (consumed.length === 0) return { ok: false as const, error: "nonce invalid or already used", status: 401 }; await t` - INSERT INTO bb_launch_meta (chain_id, token, launcher, name, symbol, description, image_url, website, x_handle, updated_at) - VALUES (${cid}, ${token}, ${wallet}, ${row[0].name}, ${row[0].symbol}, ${v.value.description || null}, ${v.value.image_url || null}, ${v.value.website || null}, ${v.value.x_handle || null}, now()) - ON CONFLICT (chain_id, token) DO UPDATE SET description = EXCLUDED.description, image_url = EXCLUDED.image_url, website = EXCLUDED.website, x_handle = EXCLUDED.x_handle, updated_at = now()`; + INSERT INTO bb_launch_meta (chain_id, token, launcher, name, symbol, description, image_url, banner_url, website, x_handle, updated_at) + VALUES (${cid}, ${token}, ${wallet}, ${row[0].name}, ${row[0].symbol}, ${v.value.description || null}, ${v.value.image_url || null}, ${v.value.banner_url || null}, ${v.value.website || null}, ${v.value.x_handle || null}, now()) + ON CONFLICT (chain_id, token) DO UPDATE SET description = EXCLUDED.description, image_url = EXCLUDED.image_url, banner_url = EXCLUDED.banner_url, website = EXCLUDED.website, x_handle = EXCLUDED.x_handle, updated_at = now()`; return { ok: true as const }; }); return result; diff --git a/app/src/lib/launchpad/imageProcess.test.ts b/app/src/lib/launchpad/imageProcess.test.ts index 68f82326..4ca3d65e 100644 --- a/app/src/lib/launchpad/imageProcess.test.ts +++ b/app/src/lib/launchpad/imageProcess.test.ts @@ -1,8 +1,8 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import sharp from "sharp"; -import { describeWebp, toLogoWebp } from "./imageProcess.ts"; -import { IMAGE_SIZE, sniffImage } from "./images.ts"; +import { describeWebp, toBannerWebp, toLogoWebp } from "./imageProcess.ts"; +import { BANNER_HEIGHT, BANNER_WIDTH, IMAGE_SIZE, sniffImage } from "./images.ts"; async function fixture(format: "png" | "jpeg" | "webp" | "gif", w: number, h: number, extra?: (s: sharp.Sharp) => sharp.Sharp): Promise { let s = sharp({ create: { width: w, height: h, channels: 4, background: { r: 0, g: 82, b: 255, alpha: 1 } } }); @@ -53,3 +53,16 @@ test("big inputs are cropped to a square, never enlarged blurrily beyond the log assert.equal(d.height, IMAGE_SIZE); assert.ok(out.length < 200_000, `output ${out.length}B is small`); }); + +test("a banner is re-encoded to a 1500×500 WebP whatever its shape, and a tiny one is refused", async () => { + for (const [w, h] of [[3000, 1000], [1200, 1200], [800, 200]]) { + const out = await toBannerWebp(await fixture("png", w, h)); + const d = await describeWebp(out); + assert.equal(d.format, "webp"); + assert.equal(d.width, BANNER_WIDTH); + assert.equal(d.height, BANNER_HEIGHT); + assert.equal(d.hasExif, false); + } + await assert.rejects(toBannerWebp(await fixture("png", 200, 80)), /too small/); + await assert.rejects(toBannerWebp(new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13]))); +}); diff --git a/app/src/lib/launchpad/imageProcess.ts b/app/src/lib/launchpad/imageProcess.ts index 3a9f9bb0..8735055b 100644 --- a/app/src/lib/launchpad/imageProcess.ts +++ b/app/src/lib/launchpad/imageProcess.ts @@ -4,7 +4,7 @@ * No "@/" imports so node --test can load it. */ import sharp from "sharp"; -import { IMAGE_SIZE } from "./images.ts"; +import { BANNER_HEIGHT, BANNER_WIDTH, IMAGE_SIZE } from "./images.ts"; export const MAX_INPUT_PIXELS = 30_000_000; // 30 MP decode ceiling (decompression-bomb guard) @@ -20,6 +20,19 @@ export async function toLogoWebp(input: Uint8Array): Promise { .toBuffer(); // sharp drops EXIF/ICC/XMP unless withMetadata() is called } +/** The same treatment for a banner: decoded, cropped to BANNER_WIDTH×BANNER_HEIGHT around the subject, re-encoded. */ +export async function toBannerWebp(input: Uint8Array): Promise { + const img = sharp(Buffer.from(input), { animated: false, limitInputPixels: MAX_INPUT_PIXELS, failOn: "error" }); + const meta = await img.metadata(); + if (!meta.width || !meta.height || meta.width < 300 || meta.height < 100) throw new Error("banner too small (min 300×100)"); + return img + .rotate() + .resize(BANNER_WIDTH, BANNER_HEIGHT, { fit: "cover", position: "attention", withoutEnlargement: false }) + .flatten({ background: "#ffffff" }) + .webp({ quality: 82, effort: 4 }) + .toBuffer(); +} + /** Cheap post-check used by tests and the route: output really is a WebP of the expected size. */ export async function describeWebp(buf: Uint8Array): Promise<{ format: string | undefined; width: number | undefined; height: number | undefined; hasExif: boolean }> { const m = await sharp(Buffer.from(buf)).metadata(); diff --git a/app/src/lib/launchpad/images.test.ts b/app/src/lib/launchpad/images.test.ts index 9556bb20..60b288dd 100644 --- a/app/src/lib/launchpad/images.test.ts +++ b/app/src/lib/launchpad/images.test.ts @@ -1,6 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { IMAGE_KEY_BYTES, IMAGE_MAX_BYTES, canonicalImageUrl, checkUpload, imageKey, imageUrlFor, isOwnImageUrl, isWalletParam, randomImageKey, sniffImage } from "./images.ts"; +import { BANNER_MAX_BYTES, IMAGE_KEY_BYTES, IMAGE_MAX_BYTES, canonicalImageUrl, checkUpload, imageRole, maxBytesFor, imageKey, imageUrlFor, isOwnImageUrl, isWalletParam, randomImageKey, sniffImage } from "./images.ts"; const pad = (head: number[], len = 64) => new Uint8Array([...head, ...new Array(Math.max(0, len - head.length)).fill(0)]); const PNG = pad([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); @@ -94,3 +94,15 @@ test("canonicalImageUrl: legacy bucket-host URLs with our key become same-origin assert.equal(canonicalImageUrl("not a url", base), "not a url"); assert.equal(isOwnImageUrl(canonicalImageUrl(`https://openlaunch-images.fly.storage.tigris.dev/${key}`, base), base), true, "canonical form passes the OG allow-list"); }); + +test("a banner is the only other role, and gets a larger upload cap than the logo", () => { + assert.equal(imageRole("banner"), "banner"); + for (const v of ["logo", undefined, null, "", "Banner", "../banner"]) assert.equal(imageRole(v), "logo", String(v)); + assert.equal(maxBytesFor("logo"), IMAGE_MAX_BYTES); + assert.equal(maxBytesFor("banner"), BANNER_MAX_BYTES); + assert.ok(BANNER_MAX_BYTES > IMAGE_MAX_BYTES); + const big = new Uint8Array(IMAGE_MAX_BYTES + 1); + big.set(PNG.slice(0, 8)); + assert.equal(checkUpload(big).ok, false, "over the logo cap"); + assert.equal(checkUpload(big, BANNER_MAX_BYTES).ok, true, "within the banner cap"); +}); diff --git a/app/src/lib/launchpad/images.ts b/app/src/lib/launchpad/images.ts index 4ab51b5a..6d5abd5b 100644 --- a/app/src/lib/launchpad/images.ts +++ b/app/src/lib/launchpad/images.ts @@ -5,6 +5,19 @@ */ export const IMAGE_MAX_BYTES = 2 * 1024 * 1024; // request body cap export const IMAGE_SIZE = 512; // stored logo is IMAGE_SIZE×IMAGE_SIZE WebP +/** A banner is wide (3:1, the shape of a profile header) and allowed a larger upload. */ +export const BANNER_WIDTH = 1500; +export const BANNER_HEIGHT = 500; +export const BANNER_MAX_BYTES = 4 * 1024 * 1024; + +/** What an upload is for: the square logo, or the wide banner. Anything else is a logo, the original upload. */ +export type ImageRole = "logo" | "banner"; +export function imageRole(v: unknown): ImageRole { + return v === "banner" ? "banner" : "logo"; +} +export function maxBytesFor(role: ImageRole): number { + return role === "banner" ? BANNER_MAX_BYTES : IMAGE_MAX_BYTES; +} export const IMAGE_UPLOADS_PER_HOUR = 10; // per wallet and per IP export const IMAGE_KEY_BYTES = 24; @@ -22,8 +35,8 @@ export function sniffImage(b: Uint8Array): ImageKind | null { } /** Upload pre-checks that need no decoder: size window + magic bytes. */ -export function checkUpload(bytes: Uint8Array): { ok: true; kind: ImageKind } | { ok: false; status: 413 | 415; error: string } { - if (bytes.length > IMAGE_MAX_BYTES) return { ok: false, status: 413, error: `image must be ≤ ${IMAGE_MAX_BYTES / 1024 / 1024} MB` }; +export function checkUpload(bytes: Uint8Array, maxBytes = IMAGE_MAX_BYTES): { ok: true; kind: ImageKind } | { ok: false; status: 413 | 415; error: string } { + if (bytes.length > maxBytes) return { ok: false, status: 413, error: `image must be ≤ ${maxBytes / 1024 / 1024} MB` }; const kind = sniffImage(bytes); if (!kind) return { ok: false, status: 415, error: "PNG, JPEG, WebP or GIF only" }; return { ok: true, kind }; @@ -73,6 +86,17 @@ export function isWalletParam(v: unknown): v is string { return typeof v === "string" && /^0x[0-9a-fA-F]{40}$/.test(v); } +/** The store key (`t/.webp`) a logo URL points at, whatever host it names; null for any other URL. */ +export function storedImageKey(url: string | null | undefined): string | null { + if (!url) return null; + try { + const m = new URL(url).pathname.match(new RegExp(`/(t/[0-9a-f]{${IMAGE_KEY_BYTES * 2}}\\.webp)$`)); + return m ? m[1] : null; + } catch { + return null; + } +} + /** Hosts we handed out before switching to same-origin URLs (objects there are not publicly readable). */ export const LEGACY_IMAGE_HOSTS = ["openlaunch-images.fly.storage.tigris.dev", "fly.storage.tigris.dev"]; diff --git a/app/src/lib/launchpad/meta.ts b/app/src/lib/launchpad/meta.ts index 60320870..a6266332 100644 --- a/app/src/lib/launchpad/meta.ts +++ b/app/src/lib/launchpad/meta.ts @@ -53,38 +53,38 @@ export async function saveMeta(m: Input): Promise<{ uri: string; token: Address const db = tx as unknown as NonNullable>; await db`SELECT pg_advisory_xact_lock(hashtext(${`meta:${cid}:${launcher}:${m.meta_key}`}))`; const [owner] = await db` - SELECT launcher, name, symbol, description, image_url, website, x_handle FROM bb_launch_meta + SELECT launcher, name, symbol, description, image_url, banner_url, website, x_handle FROM bb_launch_meta WHERE chain_id = ${cid} AND launcher = ${launcher} AND meta_key = ${m.meta_key} ORDER BY created_at ASC, token ASC LIMIT 1`; if (owner && metaWriteDecision(owner, m) === "conflict") throw new MetaConflict("this metadata key is already registered with different details. Edit after launch from your dashboard, or start a new launch"); const inserted = await db` - INSERT INTO bb_launch_meta (chain_id, token, launcher, meta_key, name, symbol, description, image_url, website, x_handle) - VALUES (${cid}, ${token}, ${launcher}, ${m.meta_key}, ${m.name}, ${m.symbol}, ${m.description ?? null}, ${m.image_url ?? null}, ${m.website ?? null}, ${m.x_handle ?? null}) + INSERT INTO bb_launch_meta (chain_id, token, launcher, meta_key, name, symbol, description, image_url, banner_url, website, x_handle) + VALUES (${cid}, ${token}, ${launcher}, ${m.meta_key}, ${m.name}, ${m.symbol}, ${m.description ?? null}, ${m.image_url ?? null}, ${m.banner_url ?? null}, ${m.website ?? null}, ${m.x_handle ?? null}) ON CONFLICT (chain_id, token) DO NOTHING RETURNING token`; if (inserted.length === 0) { - const [existing] = await db`SELECT launcher, name, symbol, description, image_url, website, x_handle FROM bb_launch_meta WHERE chain_id = ${cid} AND token = ${token}`; + const [existing] = await db`SELECT launcher, name, symbol, description, image_url, banner_url, website, x_handle FROM bb_launch_meta WHERE chain_id = ${cid} AND token = ${token}`; if (metaWriteDecision(existing ?? null, m) === "conflict") throw new MetaConflict("metadata for this launch is already registered. Edit it after launch from your dashboard, or pick a new salt"); } return { uri, token }; }) as Promise<{ uri: string; token: Address }>; } -export type MetaJson = { name: string; symbol: string; description?: string; image?: string; external_url?: string; x?: string; token?: string }; +export type MetaJson = { name: string; symbol: string; description?: string; image?: string; banner?: string; external_url?: string; x?: string; token?: string }; export async function readMeta(where: { chain?: ChainKey; token?: string; launcher?: string; key?: string }): Promise { const db = maybeDb(); if (!db) return null; const chainCond = where.chain ? db`AND m.chain_id = ${chainIdOf(where.chain)}` : db``; const rows = where.token - ? await db<{ token: string; name: string; symbol: string; description: string | null; image_url: string | null; website: string | null; x_handle: string | null }[]>` - SELECT m.token, m.name, m.symbol, m.description, m.image_url, m.website, m.x_handle FROM bb_launch_meta m WHERE m.token = ${where.token.toLowerCase()} ${chainCond} ORDER BY m.created_at DESC LIMIT 1` - : await db<{ token: string; name: string; symbol: string; description: string | null; image_url: string | null; website: string | null; x_handle: string | null }[]>` - SELECT m.token, m.name, m.symbol, m.description, m.image_url, m.website, m.x_handle FROM bb_launch_meta m + ? await db<{ token: string; name: string; symbol: string; description: string | null; image_url: string | null; banner_url: string | null; website: string | null; x_handle: string | null }[]>` + SELECT m.token, m.name, m.symbol, m.description, m.image_url, m.banner_url, m.website, m.x_handle FROM bb_launch_meta m WHERE m.token = ${where.token.toLowerCase()} ${chainCond} ORDER BY m.created_at DESC LIMIT 1` + : await db<{ token: string; name: string; symbol: string; description: string | null; image_url: string | null; banner_url: string | null; website: string | null; x_handle: string | null }[]>` + SELECT m.token, m.name, m.symbol, m.description, m.image_url, m.banner_url, m.website, m.x_handle FROM bb_launch_meta m LEFT JOIN bb_launches l ON l.chain_id = m.chain_id AND l.token = m.token WHERE m.launcher = ${(where.launcher ?? "").toLowerCase()} AND (m.meta_key = ${(where.key ?? "").toLowerCase()} OR l.metadata_uri = ${uriFor(where.launcher ?? "", where.key ?? "0x")}) ORDER BY (l.token IS NOT NULL) DESC, m.created_at ASC, m.token ASC LIMIT 1`; // the launched row wins; before indexing, the FIRST registration under the key (never a later one) const r = rows[0]; if (!r) return null; - return { name: r.name, symbol: r.symbol, description: r.description ?? undefined, image: canonicalImageUrl(r.image_url, imagePublicBase()) ?? undefined, external_url: r.website ?? undefined, x: r.x_handle ?? undefined, token: r.token }; + return { name: r.name, symbol: r.symbol, description: r.description ?? undefined, image: canonicalImageUrl(r.image_url, imagePublicBase()) ?? undefined, banner: canonicalImageUrl(r.banner_url, imagePublicBase()) ?? undefined, external_url: r.website ?? undefined, x: r.x_handle ?? undefined, token: r.token }; } diff --git a/app/src/lib/launchpad/metaShared.test.ts b/app/src/lib/launchpad/metaShared.test.ts index 6296ec53..9064d8ea 100644 --- a/app/src/lib/launchpad/metaShared.test.ts +++ b/app/src/lib/launchpad/metaShared.test.ts @@ -54,3 +54,25 @@ test("validateMeta: x_handle takes a handle or an x.com link and stores the bare assert.equal(metaWriteDecision(row, bare.value), "same"); } }); + +test("validateMeta: a banner is optional and, like the logo, https only", () => { + const ok = validateMeta({ ...base, banner_url: "https://cdn.example/banner.webp" }); + assert.ok(ok.ok); + if (ok.ok) assert.equal(ok.value.banner_url, "https://cdn.example/banner.webp"); + const none = validateMeta(base); + assert.ok(none.ok); + if (none.ok) assert.equal(none.value.banner_url, undefined); + const bad = validateMeta({ ...base, banner_url: "http://cdn.example/banner.webp" }); + assert.equal(bad.ok, false); + if (!bad.ok) assert.match(bad.error, /banner/); +}); + +test("metaWriteDecision: a different banner is a conflict, the same one is an idempotent re-send", () => { + const v = validateMeta({ ...base, banner_url: "https://cdn.example/b.webp" }); + assert.ok(v.ok); + if (!v.ok) return; + const row = { launcher: L.toLowerCase(), name: v.value.name, symbol: v.value.symbol, description: null, image_url: null, banner_url: "https://cdn.example/b.webp", website: null, x_handle: null }; + assert.equal(metaWriteDecision(row, v.value), "same"); + assert.equal(metaWriteDecision({ ...row, banner_url: "https://cdn.example/other.webp" }, v.value), "conflict"); + assert.equal(metaWriteDecision({ ...row, banner_url: null }, v.value), "conflict"); +}); diff --git a/app/src/lib/launchpad/metaShared.ts b/app/src/lib/launchpad/metaShared.ts index 88bde726..9d8ac106 100644 --- a/app/src/lib/launchpad/metaShared.ts +++ b/app/src/lib/launchpad/metaShared.ts @@ -12,7 +12,7 @@ import { isAddress, type Hex } from "viem"; import { SITE_URL, isChainKey, type ChainKey, CHAIN_KEY_PATTERN } from "../chainPublic.ts"; import { parseXHandle } from "./xHandle.ts"; -export type MetaInput = { chain: ChainKey; launcher: string; salt: Hex; meta_key: Hex; name: string; symbol: string; description?: string; image_url?: string; website?: string; x_handle?: string }; +export type MetaInput = { chain: ChainKey; launcher: string; salt: Hex; meta_key: Hex; name: string; symbol: string; description?: string; image_url?: string; banner_url?: string; website?: string; x_handle?: string }; export const LIMITS = { name: 32, symbol: 10, description: 280 } as const; const BYTES32 = /^0x[0-9a-fA-F]{64}$/; @@ -39,13 +39,15 @@ export function validateMeta(m: Partial & { meta_key?: string }): { o } }; const image_url = url(m.image_url); + const banner_url = url(m.banner_url); const website = url(m.website); if (image_url === null) return { ok: false, error: "image: https URL only" }; + if (banner_url === null) return { ok: false, error: "banner: https URL only" }; if (website === null) return { ok: false, error: "website: https URL only" }; const x = parseXHandle(m.x_handle); if (!x.ok) return { ok: false, error: x.error }; const x_handle = x.handle || undefined; - return { ok: true, value: { chain: m.chain, launcher: m.launcher, salt: m.salt as Hex, meta_key: meta_key.toLowerCase() as Hex, name, symbol, description, image_url, website, x_handle } }; + return { ok: true, value: { chain: m.chain, launcher: m.launcher, salt: m.salt as Hex, meta_key: meta_key.toLowerCase() as Hex, name, symbol, description, image_url, banner_url, website, x_handle } }; } /** The on-chain metadataURI: keyed by (launcher, meta_key) — stable across the salt search. */ @@ -53,7 +55,7 @@ export function metaUriFor(launcher: string, metaKey: string): string { return `${SITE_URL}/api/launch/meta/${launcher.toLowerCase()}/${metaKey.toLowerCase()}`; } -export type MetaRow = { launcher: string; name: string; symbol: string; description: string | null; image_url: string | null; website: string | null; x_handle: string | null }; +export type MetaRow = { launcher: string; name: string; symbol: string; description: string | null; image_url: string | null; banner_url?: string | null; website: string | null; x_handle: string | null }; /** * Unsigned writes are INSERT-only. The creator registers before broadcasting, while the salt is @@ -69,6 +71,7 @@ export function metaWriteDecision(existing: MetaRow | null, incoming: MetaInput) existing.symbol === incoming.symbol && (existing.description ?? null) === (incoming.description ?? null) && (existing.image_url ?? null) === (incoming.image_url ?? null) && + (existing.banner_url ?? null) === (incoming.banner_url ?? null) && (existing.website ?? null) === (incoming.website ?? null) && (existing.x_handle ?? null) === (incoming.x_handle ?? null); return same ? "same" : "conflict"; diff --git a/app/src/lib/launchpad/queries.ts b/app/src/lib/launchpad/queries.ts index bcf8b86c..9d8634aa 100644 --- a/app/src/lib/launchpad/queries.ts +++ b/app/src/lib/launchpad/queries.ts @@ -69,6 +69,8 @@ export type LaunchRow = { launcher_collapsed: number; // sort "live" only: further rows of this launcher folded into this one description: string | null; image_url: string | null; + /** A creator-uploaded banner (null when none; the cards then draw one from the logo). */ + banner_url: string | null; holders: number; website: string | null; x_handle: string | null; @@ -228,6 +230,7 @@ function shape(raw: Raw & { last_swap_block?: bigint; last_swap_log?: number; lo fdv_quote: fdv, change_from_launch: launchFdv > 0 ? fdv / launchFdv - 1 : 0, image_url: canonicalImageUrl(r.image_url, imagePublicBase()), + banner_url: canonicalImageUrl(r.banner_url, imagePublicBase()), quote_usd: qu, price_usd: qu === null ? null : price * qu, fdv_usd: qu === null ? null : fdv * qu, @@ -246,7 +249,7 @@ function shape(raw: Raw & { last_swap_block?: bigint; last_swap_log?: number; lo // bb_launches.last_trade_at also moves on the launcher's own swaps. const OUTSIDE = `s.trader <> l.launcher AND s.block_number > l.block_number + ${SNIPER_BLOCKS}`; const HOUR = `s.block_time > now() - interval '1 hour'`; -const SELECT = `SELECT l.*, m.description, m.image_url, m.website, m.x_handle, +const SELECT = `SELECT l.*, m.description, m.image_url, m.banner_url, m.website, m.x_handle, w.n1 AS trades_1h, w.t1 AS traders_1h, w.t1_ex AS traders_1h_ex, w.v1 AS volume_1h, w.n24 AS trades_24h, w.t24_ex AS traders_24h_ex, w.v24 AS volume_24h, w.last_outside_at AS last_outside_trade_at FROM bb_launches l LEFT JOIN bb_launch_meta m ON m.chain_id = l.chain_id AND m.token = l.token