#!/usr/bin/env node /** * Logo URL Validator * Validates that all logoURI URLs are accessible and return image content */ import { readFileSync, existsSync, statSync } from 'fs'; import { fileURLToPath } from 'url'; import { dirname, resolve } from 'path'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const PROJECT_ROOT = resolve(__dirname, '../..'); const MAX_LOGO_SIZE = 500 * 1024; // 500KB const IMAGE_MIME_TYPES = ['image/png', 'image/jpeg', 'image/jpg', 'image/svg+xml', 'image/webp', 'image/gif']; function inferContentTypeFromPath(filePath) { if (filePath.endsWith('.svg')) return 'image/svg+xml'; if (filePath.endsWith('.png')) return 'image/png'; if (filePath.endsWith('.jpg') || filePath.endsWith('.jpeg')) return 'image/jpeg'; if (filePath.endsWith('.webp')) return 'image/webp'; if (filePath.endsWith('.gif')) return 'image/gif'; return null; } function resolveLocalRepoAsset(logoURI) { try { const url = new URL(logoURI); if (url.protocol !== 'https:') return null; if (url.hostname === 'raw.githubusercontent.com') { const segments = url.pathname.split('/').filter(Boolean); if (segments.length < 4) return null; const relativePath = segments.slice(3).join('/'); const candidate = resolve(PROJECT_ROOT, relativePath); return existsSync(candidate) ? candidate : null; } if (url.hostname === 'gitea.d-bis.org') { const marker = '/raw/branch/'; const markerIndex = url.pathname.indexOf(marker); if (markerIndex === -1) return null; const afterMarker = url.pathname.slice(markerIndex + marker.length); const pathSegments = afterMarker.split('/').filter(Boolean); if (pathSegments.length < 2) return null; const relativePath = pathSegments.slice(1).join('/'); const candidate = resolve(PROJECT_ROOT, relativePath); return existsSync(candidate) ? candidate : null; } } catch { return null; } return null; } async function validateLogo(logoURI, tokenInfo) { const issues = []; // Check protocol if (!logoURI.startsWith('https://') && !logoURI.startsWith('ipfs://')) { issues.push(`URL should use HTTPS or IPFS (got: ${logoURI.substring(0, 20)}...)`); } // For HTTPS URLs, validate accessibility if (logoURI.startsWith('https://')) { const localAsset = resolveLocalRepoAsset(logoURI); if (localAsset) { try { const size = statSync(localAsset).size; const contentType = inferContentTypeFromPath(localAsset); if (!contentType || !IMAGE_MIME_TYPES.includes(contentType)) { issues.push(`Local repo asset has unsupported extension: ${localAsset}`); } if (size > MAX_LOGO_SIZE) { issues.push(`Local repo asset too large: ${(size / 1024).toFixed(2)}KB (max ${MAX_LOGO_SIZE / 1024}KB)`); } return issues; } catch (error) { issues.push(`Failed to stat local repo asset: ${error.message}`); return issues; } } try { const response = await fetch(logoURI, { method: 'HEAD' }); if (!response.ok) { issues.push(`HTTP ${response.status}: ${response.statusText}`); } else { const contentType = response.headers.get('content-type'); const contentLength = response.headers.get('content-length'); if (contentType && !IMAGE_MIME_TYPES.some(mime => contentType.includes(mime))) { issues.push(`Invalid Content-Type: ${contentType} (expected image/*)`); } if (contentLength && parseInt(contentLength) > MAX_LOGO_SIZE) { issues.push(`Logo too large: ${(parseInt(contentLength) / 1024).toFixed(2)}KB (max ${MAX_LOGO_SIZE / 1024}KB)`); } } } catch (error) { issues.push(`Failed to fetch: ${error.message}`); } } else if (logoURI.startsWith('ipfs://')) { // IPFS URLs are valid but we can't easily validate them // Just check format if (!logoURI.match(/^ipfs:\/\/[a-zA-Z0-9]+/)) { issues.push('Invalid IPFS URL format'); } } return issues; } async function validateLogos(filePath) { console.log(`\nšŸ–¼ļø Validating logos in: ${filePath}\n`); // Read token list file let tokenList; try { const fileContent = readFileSync(filePath, 'utf-8'); tokenList = JSON.parse(fileContent); } catch (error) { console.error('āŒ Error reading or parsing token list file:'); console.error(` ${error.message}`); process.exit(1); } const results = []; let totalIssues = 0; // Validate top-level logoURI if (tokenList.logoURI) { console.log('Validating list logoURI...'); const issues = await validateLogo(tokenList.logoURI, 'List'); if (issues.length > 0) { results.push({ type: 'list', uri: tokenList.logoURI, issues }); totalIssues += issues.length; } } // Validate token logos if (tokenList.tokens && Array.isArray(tokenList.tokens)) { for (const [index, token] of tokenList.tokens.entries()) { if (token.logoURI) { const tokenInfo = `${token.symbol || token.name} (Token[${index}])`; const issues = await validateLogo(token.logoURI, tokenInfo); if (issues.length > 0) { results.push({ type: 'token', token: tokenInfo, uri: token.logoURI, issues }); totalIssues += issues.length; } } } } // Report results if (totalIssues === 0) { console.log('āœ… All logos are valid!\n'); return 0; } console.log(`Found ${totalIssues} logo issue(s):\n`); results.forEach(result => { if (result.type === 'list') { console.log(`āŒ List logoURI: ${result.uri}`); } else { console.log(`āŒ ${result.token}: ${result.uri}`); } result.issues.forEach(issue => { console.log(` - ${issue}`); }); console.log(''); }); return 1; } // Main const filePath = process.argv[2] || resolve(__dirname, '../lists/dbis-138.tokenlist.json'); if (!filePath) { console.error('Usage: node validate-logos.js [path/to/token-list.json]'); process.exit(1); } validateLogos(filePath).then(exitCode => { process.exit(exitCode); }).catch(error => { console.error('Unexpected error:', error); process.exit(1); });