Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions app/db/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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;
22 changes: 13 additions & 9 deletions app/src/app/api/launch/image/route.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -10,17 +10,19 @@ 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) {
if (!imageUploadsEnabled()) return NextResponse.json({ error: "uploads not configured" }, { status: 503 });
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 {
Expand All @@ -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 });
Expand Down
6 changes: 5 additions & 1 deletion app/src/components/launchpad/EditTokenSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<EditFields>({ description: initial.description ?? "", image_url: initial.image_url ?? "", website: initial.website ?? "", x_handle: initial.x_handle ?? "" });
const [f, setF] = useState<EditFields>({ 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<string | null>(null);
const v = validateEdit(f);
Expand Down Expand Up @@ -61,6 +61,10 @@ export default function EditTokenSheet({ chain, token, symbol, initial, onClose,
<p className={label}>Image</p>
<ImageUpload compact value={f.image_url ?? ""} onChange={(url) => setF({ ...f, image_url: url })} wallet={address} />
</div>
<div>
<p className={label}>Banner</p>
<ImageUpload kind="banner" value={f.banner_url ?? ""} onChange={(url) => setF({ ...f, banner_url: url })} wallet={address} />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className={label} htmlFor="e-web">Website</label>
Expand Down
161 changes: 98 additions & 63 deletions app/src/components/launchpad/ImageUpload.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLInputElement>(null);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(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})`);
Expand All @@ -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 = (
<input
ref={fileRef}
id={id}
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
className="sr-only"
onChange={(e) => {
const f = e.target.files?.[0];
e.target.value = "";
if (f) void upload(f);
}}
/>
);

const tile = compact ? 64 : 88;
return (
<div>
<div
role="button"
tabIndex={0}
aria-label="Upload token image"
onClick={() => !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" : ""}`}
>
<div className="shrink-0 rounded-2xl overflow-hidden bg-card border border-line flex items-center justify-center" style={{ width: tile, height: tile }}>
{preview ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={preview} alt="" width={tile} height={tile} className="w-full h-full object-cover" referrerPolicy="no-referrer" />
) : busy ? (
<Spinner size={20} className="text-brand" />
) : (
<svg width={26} height={26} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.8} className="text-muted" aria-hidden>
<rect x="3" y="4" width="18" height="16" rx="3" />
<circle cx="9" cy="10" r="2" />
<path d="M21 16l-5-5-8 8" />
</svg>
)}
<div className={stacked ? "sm:flex sm:h-full sm:flex-col" : undefined}>
{banner ? (
<div className={`relative ${stacked ? "sm:flex-1" : ""}`}>
<div {...zone} className={`group relative grid aspect-[3/1] w-full cursor-pointer select-none place-items-center overflow-hidden rounded-2xl border border-dashed transition-colors ${stacked ? "sm:aspect-auto sm:h-full" : ""} ${edge}`}>
{preview ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={preview} alt="" className="absolute inset-0 size-full object-cover" referrerPolicy="no-referrer" />
) : null}
<div className={`relative flex flex-col items-center gap-1 rounded-xl px-3 py-2 text-center transition-opacity motion-reduce:transition-none ${preview ? "bg-black/60 text-white opacity-0 backdrop-blur-sm group-hover:opacity-100 group-focus-visible:opacity-100" : ""}`}>
{busy ? <Spinner size={18} className={preview ? "text-white" : "text-brand"} /> : preview ? null : <ImagePlus size={20} strokeWidth={1.8} aria-hidden="true" className="mb-0.5 text-muted" />}
<p className="text-sm font-semibold">{busy ? "Uploading…" : preview ? "Change banner" : "Upload banner"}</p>
<p className={`text-xs ${preview ? "text-white/80" : "text-muted"}`}>{busy ? `Resizing to ${BANNER_WIDTH}×${BANNER_HEIGHT}` : `Wide, about 3:1 · PNG, JPEG, WebP or GIF · max ${max / 1024 / 1024} MB`}</p>
</div>
{fileInput}
</div>
{preview && !busy ? (
<button type="button" onClick={() => onChange("")} aria-label="Remove banner" title="Remove banner" className="absolute right-2 top-2 grid size-8 place-items-center rounded-full bg-black/60 text-white ring-1 ring-white/15 backdrop-blur-sm transition-colors hover:bg-black/75 motion-reduce:transition-none">
<X size={14} aria-hidden="true" />
</button>
) : null}
</div>
<div className="min-w-0">
<p className="font-semibold text-sm">{busy ? "Uploading…" : preview ? "Change image" : "Upload image"}</p>
<p className={`${helper} mt-0.5`}>{busy ? "Resizing to 512×512" : "PNG, JPEG, WebP or GIF · max 2 MB · drop it here or tap"}</p>
) : (
<div {...zone} className={`flex items-center rounded-2xl border border-dashed cursor-pointer transition-colors select-none ${stacked ? "gap-4 px-4 py-3 sm:flex-1 sm:flex-col sm:justify-center sm:gap-3 sm:px-3 sm:py-4 sm:text-center" : "gap-4 px-4 py-3"} ${edge}`}>
<div className="shrink-0 rounded-2xl overflow-hidden bg-card border border-line flex items-center justify-center" style={{ width: tile, height: tile }}>
{preview ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={preview} alt="" width={tile} height={tile} className="w-full h-full object-cover" referrerPolicy="no-referrer" />
) : busy ? (
<Spinner size={20} className="text-brand" />
) : (
<svg width={26} height={26} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.8} className="text-muted" aria-hidden>
<rect x="3" y="4" width="18" height="16" rx="3" />
<circle cx="9" cy="10" r="2" />
<path d="M21 16l-5-5-8 8" />
</svg>
)}
</div>
<div className="min-w-0">
<p className="font-semibold text-sm">{busy ? "Uploading…" : preview ? "Change image" : "Upload image"}</p>
<p className={`${helper} mt-0.5`}>{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"}</p>
</div>
{fileInput}
</div>
<input
ref={fileRef}
id={id}
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
className="sr-only"
onChange={(e) => {
const f = e.target.files?.[0];
e.target.value = "";
if (f) void upload(f);
}}
/>
</div>
)}
{err ? <p className="text-xs text-red-600 mt-1.5">{err}</p> : null}
<details className="mt-2">
<summary className={`${helper} cursor-pointer select-none`}>or paste an image URL</summary>
<input className={`${inputCls} mt-2`} value={value} onChange={(e) => onChange(e.target.value)} placeholder="https://…/logo.png" inputMode="url" aria-label="Image URL" />
<summary className={`${helper} cursor-pointer select-none`}>{banner ? "or paste a banner URL" : "or paste an image URL"}</summary>
<input className={`${inputCls} mt-2`} value={value} onChange={(e) => onChange(e.target.value)} placeholder={banner ? "https://…/banner.png" : "https://…/logo.png"} inputMode="url" aria-label={banner ? "Banner URL" : "Image URL"} />
</details>
</div>
);
Expand Down
19 changes: 19 additions & 0 deletions app/src/lib/launchpad/creator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Loading
Loading