From d25b474a921e45f87b36b80b2c180a41b1e85558 Mon Sep 17 00:00:00 2001 From: rossgalloway <58150151+rossgalloway@users.noreply.github.com> Date: Tue, 16 Jun 2026 21:22:38 +0000 Subject: [PATCH 1/5] general improvements, remove solana --- AGENTS.md | 2 +- README.md | 3 +- app/image-tools/AGENTS.md | 2 +- app/image-tools/README.md | 45 +- app/image-tools/api/auth/github/callback.ts | 33 +- app/image-tools/api/upload.ts | 429 ++-- .../src/components/AssetDropzone.tsx | 66 + .../src/components/GithubSignIn.tsx | 155 +- app/image-tools/src/components/Header.tsx | 39 +- .../src/components/PrReviewDialog.tsx | 85 + .../src/components/PreviewGrid.tsx | 61 + .../src/components/SegmentedToggle.tsx | 72 +- .../src/components/StatusBanner.tsx | 42 + app/image-tools/src/hooks/useGithubAuth.ts | 26 + app/image-tools/src/lib/githubAuth.ts | 9 +- app/image-tools/src/lib/imagePreview.ts | 66 + app/image-tools/src/lib/uploadDraft.ts | 74 + app/image-tools/src/routes/upload.tsx | 1949 ++++++++--------- app/image-tools/tsconfig.json | 3 +- bun.lock | 8 +- docs/upload-ingestion-feature-plan.md | 6 +- package.json | 1 + .../logo-128.png | Bin 7286 -> 0 bytes .../logo-32.png | Bin 1199 -> 0 bytes .../11111111111111111111111111111111/logo.svg | 13 - .../logo-128.png | Bin 3595 -> 0 bytes .../logo-32.png | Bin 923 -> 0 bytes .../logo.svg | 60 - .../logo-128.png | Bin 3165 -> 0 bytes .../logo-32.png | Bin 941 -> 0 bytes .../logo.svg | 9 - .../logo-128.png | Bin 1079 -> 0 bytes .../logo-32.png | Bin 496 -> 0 bytes .../logo.svg | 4 - .../logo-128.png | Bin 13980 -> 0 bytes .../logo-32.png | Bin 1547 -> 0 bytes .../logo.svg | 10 - .../logo-128.png | Bin 7286 -> 0 bytes .../logo-32.png | Bin 1199 -> 0 bytes .../logo.svg | 13 - .../logo-128.png | Bin 12875 -> 0 bytes .../logo-32.png | Bin 3071 -> 0 bytes .../logo.svg | 4 - 43 files changed, 1787 insertions(+), 1502 deletions(-) create mode 100644 app/image-tools/src/components/AssetDropzone.tsx create mode 100644 app/image-tools/src/components/PrReviewDialog.tsx create mode 100644 app/image-tools/src/components/PreviewGrid.tsx create mode 100644 app/image-tools/src/components/StatusBanner.tsx create mode 100644 app/image-tools/src/hooks/useGithubAuth.ts create mode 100644 app/image-tools/src/lib/imagePreview.ts create mode 100644 app/image-tools/src/lib/uploadDraft.ts delete mode 100644 tokens/1151111081099710/11111111111111111111111111111111/logo-128.png delete mode 100644 tokens/1151111081099710/11111111111111111111111111111111/logo-32.png delete mode 100644 tokens/1151111081099710/11111111111111111111111111111111/logo.svg delete mode 100644 tokens/1151111081099710/3DK98MXPz8TRuim7rfQnebSLpA7VSoc79Bgiee1m4Zw5/logo-128.png delete mode 100644 tokens/1151111081099710/3DK98MXPz8TRuim7rfQnebSLpA7VSoc79Bgiee1m4Zw5/logo-32.png delete mode 100644 tokens/1151111081099710/3DK98MXPz8TRuim7rfQnebSLpA7VSoc79Bgiee1m4Zw5/logo.svg delete mode 100644 tokens/1151111081099710/93bvs9o8nq88zxnlwwuavfn5pxbhccnv5sfs6aflno8q/logo-128.png delete mode 100644 tokens/1151111081099710/93bvs9o8nq88zxnlwwuavfn5pxbhccnv5sfs6aflno8q/logo-32.png delete mode 100644 tokens/1151111081099710/93bvs9o8nq88zxnlwwuavfn5pxbhccnv5sfs6aflno8q/logo.svg delete mode 100644 tokens/1151111081099710/DV3845GEAVXfwpyVGGgWbqBVCtzHdCXNCGfcdboSEuZz/logo-128.png delete mode 100644 tokens/1151111081099710/DV3845GEAVXfwpyVGGgWbqBVCtzHdCXNCGfcdboSEuZz/logo-32.png delete mode 100644 tokens/1151111081099710/DV3845GEAVXfwpyVGGgWbqBVCtzHdCXNCGfcdboSEuZz/logo.svg delete mode 100644 tokens/1151111081099710/J3umBWqhSjd13sag1E1aUojViWvPYA5dFNyqpKuX3WXj/logo-128.png delete mode 100644 tokens/1151111081099710/J3umBWqhSjd13sag1E1aUojViWvPYA5dFNyqpKuX3WXj/logo-32.png delete mode 100644 tokens/1151111081099710/J3umBWqhSjd13sag1E1aUojViWvPYA5dFNyqpKuX3WXj/logo.svg delete mode 100644 tokens/1151111081099710/So11111111111111111111111111111111111111112/logo-128.png delete mode 100644 tokens/1151111081099710/So11111111111111111111111111111111111111112/logo-32.png delete mode 100644 tokens/1151111081099710/So11111111111111111111111111111111111111112/logo.svg delete mode 100644 tokens/1151111081099710/bioJ9JTqW62MLz7UKHU69gtKhPpGi1BQhccj2kmSvUJ/logo-128.png delete mode 100644 tokens/1151111081099710/bioJ9JTqW62MLz7UKHU69gtKhPpGi1BQhccj2kmSvUJ/logo-32.png delete mode 100644 tokens/1151111081099710/bioJ9JTqW62MLz7UKHU69gtKhPpGi1BQhccj2kmSvUJ/logo.svg diff --git a/AGENTS.md b/AGENTS.md index 42a7faa62e..ab62d8a868 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,7 +26,7 @@ There are multiple different apps and elements in this repo. Please read careful - Indentation: tabs, width 4 (`.editorconfig`). - Prettier: single quotes, semicolons, 120 col width. - Asset files per token: `logo.svg`, `logo-32.png`, `logo-128.png`. -- Addresses: lowercase for EVM chains; case‑sensitive for Solana (`1151111081099710`). +- Addresses: lowercase for EVM chains. - Directory names: numeric `chainId` (or `btcm`). ## Testing Guidelines diff --git a/README.md b/README.md index 4469184fc2..95e2f592dc 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,6 @@ Environment Variables: None - 81457: Blast - 98866: Plume - 1380012617: Rari -- 1151111081099710: Solana - btcm: Bitcoin ## Contributing @@ -89,7 +88,7 @@ $ rsvg-convert -h 32 logo.svg > logo-32.png && rsvg-convert -h 128 logo.svg > lo ``` Once ready, create a new directory with the chain ID, or use the existing one, -and create a new directory for the token address (in lower case for EVM chains, or case sensitive for Solana) you are adding. +and create a new directory for the token address in lowercase. Fill-in the details when creating the pull-request, and we'll merge it shortly! diff --git a/app/image-tools/AGENTS.md b/app/image-tools/AGENTS.md index ab43de7a5d..8d06d80fd7 100644 --- a/app/image-tools/AGENTS.md +++ b/app/image-tools/AGENTS.md @@ -20,7 +20,7 @@ - Indentation: tabs, width 4 (`.editorconfig`). - Prettier: single quotes, semicolons, 120‑column width. - Token asset files: exactly `logo.svg`, `logo-32.png`, `logo-128.png`. -- Addresses: EVM lowercase; Solana case‑sensitive (e.g., `1151111081099710`). +- Addresses: EVM lowercase. - Directories: numeric `chainId`; addresses under the chain folder. ## Testing Guidelines diff --git a/app/image-tools/README.md b/app/image-tools/README.md index 89344552ee..8aaf11c944 100644 --- a/app/image-tools/README.md +++ b/app/image-tools/README.md @@ -4,34 +4,35 @@ A lightweight SPA + Vercel Functions app for uploading token/chain assets and op ## Environment Variables (Dev/Prod) -- Client (exposed to browser) - - `VITE_GITHUB_CLIENT_ID` — GitHub OAuth App client ID. - - `VITE_API_BASE_URL` — optional; default same-origin. Set only if the API lives on another origin. - - `VITE_RPC_URI_FOR_` — optional RPC URLs used by `/api/erc20-name`. -- Server (Vercel Functions) - - `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET` — same GitHub OAuth App used by the client. - - `APP_BASE_URL` — optional; default request origin. Only set if SPA and API are on different origins. - - `REPO_OWNER` (default `yearn`), `REPO_NAME` (default `tokenAssets`). - - `ALLOW_REPO_OVERRIDE` — set to `true` only if you intentionally want to target a non-yearn repo when deploying from a fork. -- GitHub OAuth App callback must match the current domain: `https:///api/auth/github/callback` (or `http://localhost:3000/...` for `vercel dev`). +- Client (exposed to browser) + - `VITE_GITHUB_CLIENT_ID` — GitHub OAuth App client ID. + - `VITE_API_BASE_URL` — optional; default same-origin. Set only if the API lives on another origin. + - `VITE_RPC_URI_FOR_` — optional RPC URLs used by `/api/erc20-name`. +- Server (Vercel Functions) + - `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET` — same GitHub OAuth App used by the client. + - `APP_BASE_URL` — optional; default request origin. Only set if SPA and API are on different origins. For the + public deployment, leave unset or set to `https://token-assets.yearn.fi`; do not point this at a Vercel alias. + - `REPO_OWNER` (default `yearn`), `REPO_NAME` (default `tokenAssets`). + - `ALLOW_REPO_OVERRIDE` — set to `true` only if you intentionally want to target a non-yearn repo when deploying from a fork. +- GitHub OAuth App callback must allow the current domain: `https:///api/auth/github/callback` (or `http://localhost:3000/...` for `vercel dev`). ## Commands -- `bun dev` — Vite dev server for the SPA (http://localhost:5173). -- `vercel dev` — Runs API routes and serves the SPA locally (recommended for full flow). -- `bun build` / `bun preview` — Build and preview the SPA. -- `bun typecheck` — TypeScript type checks (acts as lightweight lint). -- `bun lint` — Alias to type checks. +- `bun dev` — Vite dev server for the SPA (http://localhost:5173). +- `vercel dev` — Runs API routes and serves the SPA locally (recommended for full flow). +- `bun build` / `bun preview` — Build and preview the SPA. +- `bun typecheck` — TypeScript type checks (acts as lightweight lint). +- `bun lint` — Alias to type checks. ## App Flow (What Calls What) -1) Open the site — SPA loads; no API calls by default. -2) Sign in with GitHub — Browser goes to GitHub OAuth; upon approval GitHub redirects to `/api/auth/github/callback` (Edge). The function exchanges the code for a token and redirects to `/auth/github/success` where the token is stored. -3) Enter chain/address — Client may call `POST /api/erc20-name` (Edge) to resolve ERC‑20 name. -4) Drop SVG — Client generates PNG previews (32×32, 128×128) via Canvas. -5) Submit PR — Client posts multipart form to `POST /api/upload` (Edge) with `svg`, `png32`, and `png128`. The function validates sizes and opens a PR via GitHub API. +1. Open the site — SPA loads; no API calls by default. +2. Sign in with GitHub — Browser goes to GitHub OAuth; upon approval GitHub redirects to `/api/auth/github/callback` (Edge). The function exchanges the code for a token and redirects to `/auth/github/success` where the token is stored. +3. Enter chain/address — Client may call `POST /api/erc20-name` (Edge) to resolve ERC‑20 name. +4. Drop SVG — Client generates PNG previews (32×32, 128×128) via Canvas. +5. Submit PR — Client posts multipart form to `POST /api/upload` (Edge) with `svg`, `png32`, and `png128`. The function validates sizes and opens a PR via GitHub API. ## Notes -- PNGs are generated client‑side and validated on the server. -- Keep SVGs simple/optimized; ensure PNGs are exactly 32×32 and 128×128. +- PNGs are generated client‑side and validated on the server. +- Keep SVGs simple/optimized; ensure PNGs are exactly 32×32 and 128×128. diff --git a/app/image-tools/api/auth/github/callback.ts b/app/image-tools/api/auth/github/callback.ts index 36a3c20da6..8515c32d77 100644 --- a/app/image-tools/api/auth/github/callback.ts +++ b/app/image-tools/api/auth/github/callback.ts @@ -1,41 +1,47 @@ -export const config = { runtime: 'edge' }; +export const config = {runtime: 'edge'}; export default async function (req: Request): Promise { try { const url = new URL(req.url); const code = url.searchParams.get('code'); const state = url.searchParams.get('state') || ''; + const redirectUri = new URL('/api/auth/github/callback', url.origin).toString(); if (!code) { - return new Response(JSON.stringify({ error: 'Missing code' }), { + return new Response(JSON.stringify({error: 'Missing code'}), { status: 400, - headers: { 'Content-Type': 'application/json' } + headers: {'Content-Type': 'application/json'} }); } const clientId = process.env.GITHUB_CLIENT_ID || process.env.VITE_GITHUB_CLIENT_ID; const clientSecret = process.env.GITHUB_CLIENT_SECRET; if (!clientId || !clientSecret) { - return new Response(JSON.stringify({ error: 'Missing GitHub OAuth env vars' }), { + return new Response(JSON.stringify({error: 'Missing GitHub OAuth env vars'}), { status: 500, - headers: { 'Content-Type': 'application/json' } + headers: {'Content-Type': 'application/json'} }); } const tokenRes = await fetch('https://github.com/login/oauth/access_token', { method: 'POST', - headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, - body: JSON.stringify({ client_id: clientId, client_secret: clientSecret, code }) + headers: {Accept: 'application/json', 'Content-Type': 'application/json'}, + body: JSON.stringify({ + client_id: clientId, + client_secret: clientSecret, + code, + redirect_uri: redirectUri + }) }); if (!tokenRes.ok) { const text = await tokenRes.text(); - return new Response(text, { status: 502 }); + return new Response(text, {status: 502}); } - const tokenJson = (await tokenRes.json()) as { access_token?: string }; + const tokenJson = (await tokenRes.json()) as {access_token?: string}; const accessToken = tokenJson.access_token; if (!accessToken) { - return new Response(JSON.stringify({ error: 'No access_token in response' }), { + return new Response(JSON.stringify({error: 'No access_token in response'}), { status: 502, - headers: { 'Content-Type': 'application/json' } + headers: {'Content-Type': 'application/json'} }); } @@ -45,10 +51,9 @@ export default async function (req: Request): Promise { redirect.searchParams.set('state', state); return Response.redirect(redirect.toString(), 302); } catch (e: any) { - return new Response(JSON.stringify({ error: e?.message || 'OAuth callback failed' }), { + return new Response(JSON.stringify({error: e?.message || 'OAuth callback failed'}), { status: 500, - headers: { 'Content-Type': 'application/json' } + headers: {'Content-Type': 'application/json'} }); } } - diff --git a/app/image-tools/api/upload.ts b/app/image-tools/api/upload.ts index 086bec2f9b..05255c4212 100644 --- a/app/image-tools/api/upload.ts +++ b/app/image-tools/api/upload.ts @@ -1,190 +1,297 @@ -export const config = { runtime: 'edge' }; +export const config = {runtime: 'edge'}; -import { openPrWithFilesForkAware, getUserLogin } from './github'; +import {openPrWithFilesForkAware, getUserLogin} from './github'; const CANONICAL_OWNER = 'yearn'; const CANONICAL_REPO = 'tokenAssets'; +type TokenUploadItem = { + id: string; + chainId: string; + address: string; +}; + +type ChainUploadItem = { + id: string; + chainId: string; +}; + +class UploadValidationError extends Error { + status = 400; +} + +function validationError(message: string): never { + throw new UploadValidationError(message); +} + // Deploys triggered from personal forks should still open PRs against the // canonical org repo unless an explicit override is opt-in via env flag. -function resolveTargetRepo(): { owner: string; repo: string } { +function resolveTargetRepo(): {owner: string; repo: string} { const envOwner = (process.env.REPO_OWNER as string)?.trim(); const envRepo = (process.env.REPO_NAME as string)?.trim(); const vercelOwner = (process.env.VERCEL_GIT_REPO_OWNER as string)?.trim(); const vercelRepo = (process.env.VERCEL_GIT_REPO_SLUG as string)?.trim(); const allowOverride = (process.env.ALLOW_REPO_OVERRIDE || '').toLowerCase() === 'true'; - const owner = envOwner && (allowOverride || envOwner.toLowerCase() !== (vercelOwner || '').toLowerCase()) ? envOwner : CANONICAL_OWNER; - const repo = envRepo && (allowOverride || envRepo.toLowerCase() !== (vercelRepo || '').toLowerCase()) ? envRepo : CANONICAL_REPO; + const owner = + envOwner && (allowOverride || envOwner.toLowerCase() !== (vercelOwner || '').toLowerCase()) + ? envOwner + : CANONICAL_OWNER; + const repo = + envRepo && (allowOverride || envRepo.toLowerCase() !== (vercelRepo || '').toLowerCase()) + ? envRepo + : CANONICAL_REPO; - return { owner, repo }; + return {owner, repo}; } function isPng(bytes: Uint8Array): boolean { - return ( - bytes.length > 24 && - bytes[0] === 0x89 && - bytes[1] === 0x50 && - bytes[2] === 0x4e && - bytes[3] === 0x47 && - bytes[4] === 0x0d && - bytes[5] === 0x0a && - bytes[6] === 0x1a && - bytes[7] === 0x0a - ); + return ( + bytes.length > 24 && + bytes[0] === 0x89 && + bytes[1] === 0x50 && + bytes[2] === 0x4e && + bytes[3] === 0x47 && + bytes[4] === 0x0d && + bytes[5] === 0x0a && + bytes[6] === 0x1a && + bytes[7] === 0x0a + ); } function readUInt32BE(arr: Uint8Array, offset: number): number { - return ( - ((arr[offset] << 24) >>> 0) + - ((arr[offset + 1] << 16) >>> 0) + - ((arr[offset + 2] << 8) >>> 0) + - (arr[offset + 3] >>> 0) - ); + return ( + ((arr[offset] << 24) >>> 0) + + ((arr[offset + 1] << 16) >>> 0) + + ((arr[offset + 2] << 8) >>> 0) + + (arr[offset + 3] >>> 0) + ); +} + +function pngDimensions(bytes: Uint8Array): {width: number; height: number} | null { + if (!isPng(bytes)) return null; + // PNG IHDR: width/height at offsets 16 and 20 + const width = readUInt32BE(bytes, 16); + const height = readUInt32BE(bytes, 20); + if (!width || !height) return null; + return {width, height}; } -function pngDimensions(bytes: Uint8Array): { width: number; height: number } | null { - if (!isPng(bytes)) return null; - // PNG IHDR: width/height at offsets 16 and 20 - const width = readUInt32BE(bytes, 16); - const height = readUInt32BE(bytes, 20); - if (!width || !height) return null; - return { width, height }; +function isEvmAddress(addr: string): boolean { + return /^0x[a-fA-F0-9]{40}$/.test(String(addr || '').trim()); } function toBase64(bytes: Uint8Array): string { - let binary = ''; - const chunk = 0x8000; - for (let i = 0; i < bytes.length; i += chunk) { - const sub = bytes.subarray(i, i + chunk); - binary += String.fromCharCode(...sub); - } - // btoa is available in Edge runtime - return btoa(binary); + let binary = ''; + const chunk = 0x8000; + for (let i = 0; i < bytes.length; i += chunk) { + const sub = bytes.subarray(i, i + chunk); + binary += String.fromCharCode(...sub); + } + // btoa is available in Edge runtime + return btoa(binary); +} + +function jsonResponse(body: unknown, status: number) { + return new Response(JSON.stringify(body), {status, headers: {'Content-Type': 'application/json'}}); +} + +function parseTokenItems(form: FormData, globalChainId: string): TokenUploadItem[] { + const manifest = String(form.get('items') || '').trim(); + if (manifest) { + let parsed: Array>; + try { + parsed = JSON.parse(manifest) as Array>; + } catch { + validationError('items must be valid JSON'); + } + if (!Array.isArray(parsed)) validationError('items must be an array'); + return parsed.map((item, index) => ({ + id: String(item.id || '').trim() || String(index), + chainId: String(item.chainId || globalChainId || '').trim(), + address: String(item.address || '').trim() + })); + } + + const addresses = (form.getAll('address') as string[]).map(address => String(address || '').trim()).filter(Boolean); + return addresses.map((address, index) => ({ + id: String(index), + chainId: String(form.get(`chainId_${index}`) || globalChainId || '').trim(), + address + })); +} + +function parseChainItems(form: FormData, globalChainId: string): ChainUploadItem[] { + const manifest = String(form.get('items') || '').trim(); + if (manifest) { + let parsed: Array>; + try { + parsed = JSON.parse(manifest) as Array>; + } catch { + validationError('items must be valid JSON'); + } + if (!Array.isArray(parsed)) validationError('items must be an array'); + return parsed.map((item, index) => ({ + id: String(item.id || '').trim() || String(index), + chainId: String(item.chainId || globalChainId || '').trim() + })); + } + + return globalChainId ? [{id: '', chainId: globalChainId}] : []; +} + +async function readRequiredAssetFiles(form: FormData, suffix: string) { + const field = (name: string) => (suffix ? `${name}_${suffix}` : name); + const svgF = form.get(field('svg')) as File | null; + const png32F = form.get(field('png32')) as File | null; + const png128F = form.get(field('png128')) as File | null; + + if (!svgF) validationError(`${field('svg')} required`); + if (!svgF.type.includes('svg')) validationError(`${field('svg')} must be image/svg+xml`); + if (!png32F || !png128F) validationError(`${field('png32')} and ${field('png128')} required`); + if (!png32F.type.includes('png') || !png128F.type.includes('png')) { + validationError(`${field('png32')} and ${field('png128')} must be image/png`); + } + + const svgBytes = new Uint8Array(await svgF.arrayBuffer()); + const png32Bytes = new Uint8Array(await png32F.arrayBuffer()); + const png128Bytes = new Uint8Array(await png128F.arrayBuffer()); + + const d32 = pngDimensions(png32Bytes); + const d128 = pngDimensions(png128Bytes); + if (!d32 || d32.width !== 32 || d32.height !== 32) validationError(`${field('png32')} must be 32x32`); + if (!d128 || d128.width !== 128 || d128.height !== 128) validationError(`${field('png128')} must be 128x128`); + + return {svgBytes, png32Bytes, png128Bytes}; } export default async function (req: Request): Promise { - if (req.method !== 'POST') return new Response('Method Not Allowed', { status: 405 }); - try { - const auth = req.headers.get('authorization') || ''; - const token = auth.startsWith('Bearer ') ? auth.slice(7) : ''; - if (!token) return new Response(JSON.stringify({ error: 'Missing GitHub token' }), { status: 401, headers: { 'Content-Type': 'application/json' } }); - - const form = await req.formData(); - const target = String(form.get('target') || 'token'); - const globalChainId = String(form.get('chainId') || '').trim(); - const prTitleOverride = String(form.get('prTitle') || '').trim(); - const prBodyOverride = String(form.get('prBody') || '').trim(); - - const prFiles: Array<{ path: string; contentBase64: string }> = []; - const { owner, repo } = resolveTargetRepo(); - - if (target === 'token') { - const addressesRaw = form.getAll('address') as string[]; - const addresses = addressesRaw.map(a => String(a || '').trim()).filter(Boolean); - if (!addresses.length) { - return new Response(JSON.stringify({ error: 'At least one address required for token uploads' }), { status: 400, headers: { 'Content-Type': 'application/json' } }); - } - for (let i = 0; i < addresses.length; i++) { - const addr = addresses[i]; - const localChainId = String(form.get(`chainId_${i}`) || globalChainId || '').trim(); - if (!localChainId) return new Response(JSON.stringify({ error: `Missing chainId for token index ${i}` }), { status: 400, headers: { 'Content-Type': 'application/json' } }); - - const svgF = form.get(`svg_${i}`) as File | null; - const png32F = form.get(`png32_${i}`) as File | null; - const png128F = form.get(`png128_${i}`) as File | null; - if (!svgF) return new Response(JSON.stringify({ error: `svg_${i} required` }), { status: 400, headers: { 'Content-Type': 'application/json' } }); - if (!svgF.type.includes('svg')) return new Response(JSON.stringify({ error: `svg_${i} must be image/svg+xml` }), { status: 400, headers: { 'Content-Type': 'application/json' } }); - if (!png32F || !png128F) return new Response(JSON.stringify({ error: `png32_${i} and png128_${i} required` }), { status: 400, headers: { 'Content-Type': 'application/json' } }); - if (!png32F.type.includes('png') || !png128F.type.includes('png')) return new Response(JSON.stringify({ error: `png32_${i} and png128_${i} must be image/png` }), { status: 400, headers: { 'Content-Type': 'application/json' } }); - - const svgBytes = new Uint8Array(await svgF.arrayBuffer()); - const png32Bytes = new Uint8Array(await png32F.arrayBuffer()); - const png128Bytes = new Uint8Array(await png128F.arrayBuffer()); - - const d32 = pngDimensions(png32Bytes); - const d128 = pngDimensions(png128Bytes); - if (!d32 || d32.width !== 32 || d32.height !== 32) return new Response(JSON.stringify({ error: `png32_${i} must be 32x32` }), { status: 400, headers: { 'Content-Type': 'application/json' } }); - if (!d128 || d128.width !== 128 || d128.height !== 128) return new Response(JSON.stringify({ error: `png128_${i} must be 128x128` }), { status: 400, headers: { 'Content-Type': 'application/json' } }); - - const addrLower = addr.toLowerCase(); - prFiles.push( - { path: ['tokens', String(localChainId), addrLower, 'logo.svg'].join('/'), contentBase64: toBase64(svgBytes) }, - { path: ['tokens', String(localChainId), addrLower, 'logo-32.png'].join('/'), contentBase64: toBase64(png32Bytes) }, - { path: ['tokens', String(localChainId), addrLower, 'logo-128.png'].join('/'), contentBase64: toBase64(png128Bytes) }, - ); - } - } else { - // Chain asset mode - if (!globalChainId) return new Response(JSON.stringify({ error: 'chainId required' }), { status: 400, headers: { 'Content-Type': 'application/json' } }); - const svgF = form.get('svg') as File | null; - const png32F = form.get('png32') as File | null; - const png128F = form.get('png128') as File | null; - if (!svgF) return new Response(JSON.stringify({ error: 'svg required' }), { status: 400, headers: { 'Content-Type': 'application/json' } }); - if (!svgF.type.includes('svg')) return new Response(JSON.stringify({ error: 'svg must be image/svg+xml' }), { status: 400, headers: { 'Content-Type': 'application/json' } }); - if (!png32F || !png128F) return new Response(JSON.stringify({ error: 'png32 and png128 required' }), { status: 400, headers: { 'Content-Type': 'application/json' } }); - if (!png32F.type.includes('png') || !png128F.type.includes('png')) return new Response(JSON.stringify({ error: 'png32 and png128 must be image/png' }), { status: 400, headers: { 'Content-Type': 'application/json' } }); - - const svgBytes = new Uint8Array(await svgF.arrayBuffer()); - const png32Bytes = new Uint8Array(await png32F.arrayBuffer()); - const png128Bytes = new Uint8Array(await png128F.arrayBuffer()); - - const d32 = pngDimensions(png32Bytes); - const d128 = pngDimensions(png128Bytes); - if (!d32 || d32.width !== 32 || d32.height !== 32) return new Response(JSON.stringify({ error: 'png32 must be 32x32' }), { status: 400, headers: { 'Content-Type': 'application/json' } }); - if (!d128 || d128.width !== 128 || d128.height !== 128) return new Response(JSON.stringify({ error: 'png128 must be 128x128' }), { status: 400, headers: { 'Content-Type': 'application/json' } }); - - prFiles.push( - { path: ['chains', String(globalChainId), 'logo.svg'].join('/'), contentBase64: toBase64(svgBytes) }, - { path: ['chains', String(globalChainId), 'logo-32.png'].join('/'), contentBase64: toBase64(png32Bytes) }, - { path: ['chains', String(globalChainId), 'logo-128.png'].join('/'), contentBase64: toBase64(png128Bytes) }, - ); - } - - const login = await getUserLogin(token).catch(() => 'user'); - const branchName = `${login}-image-tools-${target}-${Date.now()}`; - - // Build default PR title/body if not provided - let prTitle = prTitleOverride; - let prBody = prBodyOverride; - if (!prTitle || !prBody) { - if (target === 'token') { - const addressesForBody = (form.getAll('address') as string[]).map(a => a?.toLowerCase?.() || a).filter(Boolean); - const chainsForBody: string[] = addressesForBody.map((_, i) => String(form.get(`chainId_${i}`) || globalChainId || '')); - const uniqueChains = Array.from(new Set(chainsForBody.filter(Boolean))); - prTitle ||= `feat: add token assets (${addressesForBody.length})`; - const directoryLocations = addressesForBody.flatMap((addr: string, i: number) => [ - `/token/${chainsForBody[i]}/${addr}/logo.svg`, - `/token/${chainsForBody[i]}/${addr}/logo-32.png`, - `/token/${chainsForBody[i]}/${addr}/logo-128.png`, - ]); - prBody ||= [ - `Chains: ${uniqueChains.join(', ')}`, - `Addresses: ${addressesForBody.join(', ')}`, - '', - 'Uploaded locations:', - ...directoryLocations.map((u) => `- ${u}`), - ].join('\n'); - } else { - prTitle ||= `feat: add chain assets on ${globalChainId}`; - const directoryLocations = [`/chain/${globalChainId}/logo.svg`, `/chain/${globalChainId}/logo-32.png`, `/chain/${globalChainId}/logo-128.png`]; - prBody ||= [`Chain: ${globalChainId}`, '', 'Uploaded locations:', ...directoryLocations.map((u) => `- ${u}`)].join('\n'); - } - } - - const prUrl = await openPrWithFilesForkAware({ - token, - baseOwner: owner, - baseRepo: repo, - branchName, - commitMessage: prTitle, - prTitle, - prBody, - files: prFiles, - }); - - return new Response(JSON.stringify({ ok: true, prUrl }), { status: 200, headers: { 'Content-Type': 'application/json' } }); - } catch (e: any) { - return new Response(JSON.stringify({ error: e?.message || 'Upload failed' }), { status: 500, headers: { 'Content-Type': 'application/json' } }); - } + if (req.method !== 'POST') return new Response('Method Not Allowed', {status: 405}); + try { + const auth = req.headers.get('authorization') || ''; + const token = auth.startsWith('Bearer ') ? auth.slice(7) : ''; + if (!token) return jsonResponse({error: 'Missing GitHub token'}, 401); + + const form = await req.formData(); + const target = String(form.get('target') || 'token'); + const globalChainId = String(form.get('chainId') || '').trim(); + const prTitleOverride = String(form.get('prTitle') || '').trim(); + const prBodyOverride = String(form.get('prBody') || '').trim(); + + const prFiles: Array<{path: string; contentBase64: string}> = []; + const {owner, repo} = resolveTargetRepo(); + + if (target === 'token') { + const items = parseTokenItems(form, globalChainId); + if (!items.length) validationError('At least one address required for token uploads'); + for (const item of items) { + if (!item.chainId) validationError(`Missing chainId for token ${item.id}`); + if (!isEvmAddress(item.address)) validationError(`Invalid EVM address for token ${item.id}`); + + const {svgBytes, png32Bytes, png128Bytes} = await readRequiredAssetFiles(form, item.id); + const addrLower = item.address.toLowerCase(); + prFiles.push( + { + path: ['tokens', String(item.chainId), addrLower, 'logo.svg'].join('/'), + contentBase64: toBase64(svgBytes) + }, + { + path: ['tokens', String(item.chainId), addrLower, 'logo-32.png'].join('/'), + contentBase64: toBase64(png32Bytes) + }, + { + path: ['tokens', String(item.chainId), addrLower, 'logo-128.png'].join('/'), + contentBase64: toBase64(png128Bytes) + } + ); + } + } else if (target === 'chain') { + const items = parseChainItems(form, globalChainId); + if (!items.length) validationError('At least one chainId required for chain uploads'); + + const seenChainIds = new Set(); + for (const item of items) { + if (!item.chainId) validationError(`Missing chainId for chain asset ${item.id}`); + if (seenChainIds.has(item.chainId)) + validationError(`Duplicate chainId for chain asset ${item.chainId}`); + seenChainIds.add(item.chainId); + } + + for (const item of items) { + const {svgBytes, png32Bytes, png128Bytes} = await readRequiredAssetFiles(form, item.id); + + prFiles.push( + {path: ['chains', String(item.chainId), 'logo.svg'].join('/'), contentBase64: toBase64(svgBytes)}, + { + path: ['chains', String(item.chainId), 'logo-32.png'].join('/'), + contentBase64: toBase64(png32Bytes) + }, + { + path: ['chains', String(item.chainId), 'logo-128.png'].join('/'), + contentBase64: toBase64(png128Bytes) + } + ); + } + } else { + validationError('target must be token or chain'); + } + + const login = await getUserLogin(token).catch(() => 'user'); + const branchName = `${login}-image-tools-${target}-${Date.now()}`; + + // Build default PR title/body if not provided + let prTitle = prTitleOverride; + let prBody = prBodyOverride; + if (!prTitle || !prBody) { + if (target === 'token') { + const items = parseTokenItems(form, globalChainId); + const addressesForBody = items.map(item => item.address.toLowerCase()).filter(Boolean); + const chainsForBody: string[] = items.map(item => item.chainId); + const uniqueChains = Array.from(new Set(chainsForBody.filter(Boolean))); + prTitle ||= `feat: add token assets (${addressesForBody.length})`; + const directoryLocations = addressesForBody.flatMap((addr: string, i: number) => [ + `/token/${chainsForBody[i]}/${addr}/logo.svg`, + `/token/${chainsForBody[i]}/${addr}/logo-32.png`, + `/token/${chainsForBody[i]}/${addr}/logo-128.png` + ]); + prBody ||= [ + `Chains: ${uniqueChains.join(', ')}`, + `Addresses: ${addressesForBody.join(', ')}`, + '', + 'Uploaded locations:', + ...directoryLocations.map(u => `- ${u}`) + ].join('\n'); + } else { + const items = parseChainItems(form, globalChainId); + const chainsForBody: string[] = items.map(item => item.chainId); + prTitle ||= `feat: add chain assets (${chainsForBody.length})`; + const directoryLocations = chainsForBody.flatMap(chainId => [ + `/chain/${chainId}/logo.svg`, + `/chain/${chainId}/logo-32.png`, + `/chain/${chainId}/logo-128.png` + ]); + prBody ||= [ + `Chains: ${chainsForBody.join(', ')}`, + '', + 'Uploaded locations:', + ...directoryLocations.map(u => `- ${u}`) + ].join('\n'); + } + } + + const prUrl = await openPrWithFilesForkAware({ + token, + baseOwner: owner, + baseRepo: repo, + branchName, + commitMessage: prTitle, + prTitle, + prBody, + files: prFiles + }); + + return jsonResponse({ok: true, prUrl}, 200); + } catch (e: any) { + return jsonResponse({error: e?.message || 'Upload failed'}, e?.status || 500); + } } diff --git a/app/image-tools/src/components/AssetDropzone.tsx b/app/image-tools/src/components/AssetDropzone.tsx new file mode 100644 index 0000000000..6ddcb61092 --- /dev/null +++ b/app/image-tools/src/components/AssetDropzone.tsx @@ -0,0 +1,66 @@ +import React, {useId, useRef} from 'react'; + +type Props = { + label: string; + accept: string; + previewUrl?: string; + previewAlt: string; + emptyText: string; + onFile: (file: File) => void; +}; + +export const AssetDropzone: React.FC = ({label, accept, previewUrl, previewAlt, emptyText, onFile}) => { + const inputId = useId(); + const inputRef = useRef(null); + + const openPicker = () => inputRef.current?.click(); + const handleFile = (file?: File) => { + if (file) onFile(file); + }; + + return ( +
+ handleFile(event.target.files?.[0])} + /> +
{ + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + openPicker(); + } + }} + onDragOver={event => event.preventDefault()} + onDrop={event => { + event.preventDefault(); + handleFile(event.dataTransfer.files?.[0]); + }} + className="flex h-40 w-full cursor-pointer items-center justify-center rounded-md border-2 border-dashed border-gray-300 bg-gray-50 text-sm text-gray-600 transition hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"> + {previewUrl ? ( + {previewAlt} + ) : ( + {emptyText} + )} +
+ +
+ ); +}; diff --git a/app/image-tools/src/components/GithubSignIn.tsx b/app/image-tools/src/components/GithubSignIn.tsx index ca48ec3b2c..a93af3e5b3 100644 --- a/app/image-tools/src/components/GithubSignIn.tsx +++ b/app/image-tools/src/components/GithubSignIn.tsx @@ -3,11 +3,8 @@ import {Dialog, Transition} from '@headlessui/react'; import { broadcastAuthChange, - readStoredToken, storeAuthState, clearStoredAuth, - TOKEN_STORAGE_KEY, - AUTH_CHANGE_EVENT, buildAuthorizeUrl, markAuthPending, clearAuthPending, @@ -21,31 +18,19 @@ function randomState(len = 20) { return out; } -export const GithubSignIn: React.FC = () => { - const [token, setToken] = useState(null); +type Props = { + token: string | null; +}; + +export const GithubSignIn: React.FC = ({token}) => { const [connecting, setConnecting] = useState(() => readAuthPending()); + const [configError, setConfigError] = useState(''); const [login, setLogin] = useState(null); useEffect(() => { if (typeof window === 'undefined') return; - - const syncState = () => { - setToken(readStoredToken()); - setConnecting(readAuthPending()); - }; - syncState(); - - const onStorage = (event: StorageEvent) => { - if (!event.key || event.key === TOKEN_STORAGE_KEY) syncState(); - }; - const onAuthEvent = () => syncState(); - window.addEventListener('storage', onStorage); - window.addEventListener(AUTH_CHANGE_EVENT, onAuthEvent); - return () => { - window.removeEventListener('storage', onStorage); - window.removeEventListener(AUTH_CHANGE_EVENT, onAuthEvent); - }; - }, []); + setConnecting(readAuthPending()); + }, [token]); useEffect(() => { if (!token) { @@ -71,21 +56,21 @@ export const GithubSignIn: React.FC = () => { const signIn = () => { const state = randomState(); - storeAuthState(state); - markAuthPending(); - setConnecting(true); const clientId = import.meta.env.VITE_GITHUB_CLIENT_ID; if (!clientId) { - alert('Missing VITE_GITHUB_CLIENT_ID'); + setConfigError('Missing GitHub client ID.'); return; } + setConfigError(''); + storeAuthState(state); + markAuthPending(); + setConnecting(true); window.location.href = buildAuthorizeUrl(clientId, state); }; const signOut = () => { clearStoredAuth(); clearAuthPending(); - setToken(null); setLogin(null); setConnecting(false); broadcastAuthChange(); @@ -93,59 +78,73 @@ export const GithubSignIn: React.FC = () => { if (token) { return ( - +
+ +
); } return ( - <> - - - { - clearAuthPending(); - setConnecting(false); - }}> - - - - +
+ + {configError &&

{configError}

} + + { + clearAuthPending(); + setConnecting(false); + }}> + + + +
); }; diff --git a/app/image-tools/src/components/Header.tsx b/app/image-tools/src/components/Header.tsx index e832257e0e..92636dcbea 100644 --- a/app/image-tools/src/components/Header.tsx +++ b/app/image-tools/src/components/Header.tsx @@ -1,31 +1,16 @@ -import React, { useEffect, useState } from 'react'; -import { GithubSignIn } from './GithubSignIn'; -import { AUTH_CHANGE_EVENT, TOKEN_STORAGE_KEY, readStoredToken } from '../lib/githubAuth'; +import React from 'react'; +import {GithubSignIn} from './GithubSignIn'; +import {useGithubAuthToken} from '../hooks/useGithubAuth'; export const Header: React.FC = () => { - const [token, setToken] = useState(null); - useEffect(() => { - if (typeof window === 'undefined') return; - const update = () => setToken(readStoredToken()); - update(); - const onStorage = (event: StorageEvent) => { - if (!event.key || event.key === TOKEN_STORAGE_KEY) update(); - }; - const onAuth = () => update(); - window.addEventListener('storage', onStorage); - window.addEventListener(AUTH_CHANGE_EVENT, onAuth); - return () => { - window.removeEventListener('storage', onStorage); - window.removeEventListener(AUTH_CHANGE_EVENT, onAuth); - }; - }, []); + const token = useGithubAuthToken(); - return ( -
-
-

Yearn Asset Repo Upload

- -
-
- ); + return ( +
+
+

Yearn Asset Repo Upload

+ +
+
+ ); }; diff --git a/app/image-tools/src/components/PrReviewDialog.tsx b/app/image-tools/src/components/PrReviewDialog.tsx new file mode 100644 index 0000000000..85e7bdd70e --- /dev/null +++ b/app/image-tools/src/components/PrReviewDialog.tsx @@ -0,0 +1,85 @@ +import React, {Fragment} from 'react'; +import {Dialog, Transition} from '@headlessui/react'; + +type Props = { + open: boolean; + title: string; + body: string; + submitting: boolean; + onTitleChange: (value: string) => void; + onBodyChange: (value: string) => void; + onCancel: () => void; + onConfirm: () => void; +}; + +export const PrReviewDialog: React.FC = ({ + open, + title, + body, + submitting, + onTitleChange, + onBodyChange, + onCancel, + onConfirm +}) => { + return ( + + { + if (!submitting) onCancel(); + }}> +