From edea39e558eefb6804663ff714d7ddff533312cb Mon Sep 17 00:00:00 2001 From: Max Litruv Boonzaayer Date: Fri, 17 Apr 2026 02:47:56 +1000 Subject: [PATCH] Add thumbnail support and validation --- .gitea/scripts/validate-pr.js | 103 +++++++++++++++++++++++++++++++++- plugins/example-plugin.json | 1 + 2 files changed, 101 insertions(+), 3 deletions(-) diff --git a/.gitea/scripts/validate-pr.js b/.gitea/scripts/validate-pr.js index 55038f5..b32e044 100644 --- a/.gitea/scripts/validate-pr.js +++ b/.gitea/scripts/validate-pr.js @@ -10,12 +10,93 @@ * 4. Plugin ID matches filename * 5. Author matches PR creator (for new plugins) * 6. Can only remove own plugins + * 7. Thumbnail requirements: png/gif/jpg, max 512x512, max 2MB */ import { readFile, access } from 'fs/promises'; import { join } from 'path'; +import https from 'https'; +import http from 'http'; const REQUIRED_FIELDS = ['id', 'name', 'version', 'description', 'author', 'repository']; +const MAX_THUMBNAIL_SIZE = 2 * 1024 * 1024; // 2MB +const MAX_THUMBNAIL_DIMENSIONS = 512; +const ALLOWED_IMAGE_FORMATS = ['png', 'gif', 'jpg', 'jpeg']; + +async function validateThumbnail(url) { + return new Promise((resolve, reject) => { + const protocol = url.startsWith('https') ? https : http; + + protocol.get(url, (res) => { + if (res.statusCode !== 200) { + return reject(new Error(`Thumbnail URL returned ${res.statusCode}`)); + } + + const chunks = []; + let size = 0; + + res.on('data', (chunk) => { + chunks.push(chunk); + size += chunk.length; + + if (size > MAX_THUMBNAIL_SIZE) { + res.destroy(); + reject(new Error(`Thumbnail exceeds 2MB limit (${(size / 1024 / 1024).toFixed(2)}MB)`)); + } + }); + + res.on('end', () => { + const buffer = Buffer.concat(chunks); + + // Check image format by magic bytes + const isPNG = buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4E && buffer[3] === 0x47; + const isJPG = buffer[0] === 0xFF && buffer[1] === 0xD8 && buffer[2] === 0xFF; + const isGIF = buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46; + + if (!isPNG && !isJPG && !isGIF) { + return reject(new Error('Thumbnail must be PNG, JPG, or GIF format')); + } + + // Check dimensions (simplified check - reads PNG/JPG headers) + let width, height; + + if (isPNG) { + width = buffer.readUInt32BE(16); + height = buffer.readUInt32BE(20); + } else if (isJPG) { + // Simplified JPEG dimension reading + let offset = 2; + while (offset < buffer.length) { + if (buffer[offset] !== 0xFF) break; + offset++; + const marker = buffer[offset]; + offset++; + + if (marker === 0xC0 || marker === 0xC2) { + height = buffer.readUInt16BE(offset + 3); + width = buffer.readUInt16BE(offset + 5); + break; + } + + const segmentLength = buffer.readUInt16BE(offset); + offset += segmentLength; + } + } else if (isGIF) { + width = buffer.readUInt16LE(6); + height = buffer.readUInt16LE(8); + } + + if (width > MAX_THUMBNAIL_DIMENSIONS || height > MAX_THUMBNAIL_DIMENSIONS) { + return reject(new Error(`Thumbnail dimensions ${width}x${height} exceed ${MAX_THUMBNAIL_DIMENSIONS}x${MAX_THUMBNAIL_DIMENSIONS}`)); + } + + resolve({ width, height, size, format: isPNG ? 'PNG' : isJPG ? 'JPG' : 'GIF' }); + }); + + res.on('error', reject); + }).on('error', reject); + }); +} const prAuthor = process.argv[2]; const changedFiles = process.argv[3]?.split('\n').filter(f => f.trim()) || []; @@ -23,7 +104,24 @@ const changedFiles = process.argv[3]?.split('\n').filter(f => f.trim()) || []; if (!prAuthor) { console.error('āŒ Error: PR author not provided'); process.exit(1); -} +}// Check thumbnail if provided (optional) + if (plugin.thumbnail) { + try { + new URL(plugin.thumbnail); + + // Validate thumbnail meets requirements + try { + const thumbInfo = await validateThumbnail(plugin.thumbnail); + console.log(` āœ“ Thumbnail: ${thumbInfo.format} ${thumbInfo.width}x${thumbInfo.height} (${(thumbInfo.size / 1024).toFixed(1)}KB)`); + } catch (thumbErr) { + errors.push(`āŒ ${file}: Thumbnail validation failed: ${thumbErr.message}`); + } + } catch { + errors.push(`āŒ ${file}: Invalid thumbnail URL: ${plugin.thumbnail}`); + } + } + + console.log(`\nšŸ” Validating PR from: ${prAuthor}`); console.log(`šŸ“ Changed files: ${changedFiles.length}`); @@ -115,8 +213,7 @@ for (const file of changedFiles) { } else if (isRemoved) { // For removed files, we can't validate ownership easily in CI - // Just track that it was removed - removed.push(pluginId); + // Just track that it was removed console.log(' 7. Thumbnail (optional): PNG/JPG/GIF, max 512x512, max 2MB'); removed.push(pluginId); console.log(`šŸ—‘ļø Removed: ${pluginId}`); } } diff --git a/plugins/example-plugin.json b/plugins/example-plugin.json index ee403f5..0fd5867 100644 --- a/plugins/example-plugin.json +++ b/plugins/example-plugin.json @@ -5,6 +5,7 @@ "description": "An example plugin demonstrating the plugin system capabilities", "author": "litruv", "repository": "http://synbox.ruv.wtf:8418/litruv/Plugin-Example.git", + "thumbnail": "http://synbox.ruv.wtf:8418/litruv/Plugin-Example/raw/branch/main/thumbnail.png", "downloadUrl": "http://synbox.ruv.wtf:8418/litruv/Plugin-Example/archive/main.zip", "homepage": "http://synbox.ruv.wtf:8418/litruv/Plugin-Example", "tags": ["example", "demo"],