commit 7033b9cb481564625908b6b88b2dabc7b78d4f66 Author: Max Litruv Boonzaayer Date: Sun Apr 19 03:31:23 2026 +1000 Initial plugin directory setup diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..314766e --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +* text=auto eol=lf +*.{cmd,[cC][mM][dD]} text eol=crlf +*.{bat,[bB][aA][tT]} text eol=crlf diff --git a/.github/scripts/package.json b/.github/scripts/package.json new file mode 100644 index 0000000..3dbc1ca --- /dev/null +++ b/.github/scripts/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/.github/scripts/update-index.js b/.github/scripts/update-index.js new file mode 100644 index 0000000..23e5dcb --- /dev/null +++ b/.github/scripts/update-index.js @@ -0,0 +1,28 @@ +import { readdir, readFile, writeFile } from 'fs/promises'; +import { join } from 'path'; + +const pluginsDir = './plugins'; + +async function updateIndex() { + const files = await readdir(pluginsDir); + const pluginIds = files + .filter(f => f.endsWith('.json') && f !== 'index.json') + .map(f => f.replace('.json', '')) + .sort(); + + const index = { + version: '1.0.0', + updatedAt: new Date().toISOString(), + plugins: pluginIds + }; + + await writeFile( + join(pluginsDir, 'index.json'), + JSON.stringify(index, null, 2) + '\n' + ); + + console.log(`āœ… Updated index.json with ${pluginIds.length} plugins`); + console.log('Plugins:', pluginIds.join(', ')); +} + +updateIndex().catch(console.error); diff --git a/.github/scripts/validate-pr.js b/.github/scripts/validate-pr.js new file mode 100644 index 0000000..579a397 --- /dev/null +++ b/.github/scripts/validate-pr.js @@ -0,0 +1,298 @@ +#!/usr/bin/env node + +/** + * Plugin PR Validator for GitHub Actions + * + * Validates plugin submissions according to the rules: + * 1. Only modify files in plugins/ directory + * 2. Only JSON files + * 3. Valid JSON matching schema + * 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); + + 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')); + } + + let width, height; + + if (isPNG) { + width = buffer.readUInt32BE(16); + height = buffer.readUInt32BE(20); + } else if (isJPG) { + 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()) || []; + +if (!prAuthor) { + console.error('āŒ Error: PR author not provided'); + process.exit(1); +} + +console.log(`\nšŸ” Validating PR from: ${prAuthor}`); +console.log(`šŸ“ Changed files: ${changedFiles.length}`); + +const errors = []; +const added = []; +const removed = []; + +for (const file of changedFiles) { + console.log(`\nšŸ“„ Checking: ${file}`); + + if (!file.startsWith('plugins/')) { + errors.push(`āŒ File outside plugins/ directory: ${file}`); + continue; + } + + if (!file.endsWith('.json')) { + errors.push(`āŒ Non-JSON file in plugins/: ${file}`); + continue; + } + + if (file === 'plugins/index.json') { + console.log(`āš ļø Note: index.json is auto-generated, should not be manually edited`); + continue; + } + + const pluginId = file.replace('plugins/', '').replace('.json', ''); + + if (!pluginId.startsWith(`${prAuthor}-`)) { + errors.push(`āŒ ${file}: Filename must start with your username "${prAuthor}-" (e.g., "${prAuthor}-my-plugin.json")`); + continue; + } + + const prFilePath = process.env.PR_FILES_DIR ? `${process.env.PR_FILES_DIR}/${file.replace('plugins/', '')}` : file; + const baseFilePath = file; + + let isAdded = false; + let isRemoved = false; + + try { + await access(prFilePath); + isAdded = true; + } catch { + isRemoved = true; + } + + if (isAdded) { + try { + const content = await readFile(prFilePath, 'utf-8'); + const plugin = JSON.parse(content); + + let isModification = false; + let originalAuthor = null; + + try { + const originalContent = await readFile(baseFilePath, 'utf-8'); + const originalPlugin = JSON.parse(originalContent); + isModification = true; + originalAuthor = originalPlugin.author; + } catch { + isModification = false; + } + + if (isModification) { + if (originalAuthor !== prAuthor) { + errors.push(`āŒ ${file}: Cannot modify plugin owned by "${originalAuthor}" (you are "${prAuthor}")`); + continue; + } + console.log(` ā„¹ļø Modifying existing plugin by ${originalAuthor}`); + } + + for (const field of REQUIRED_FIELDS) { + if (!plugin[field]) { + errors.push(`āŒ ${file}: Missing required field: ${field}`); + } + } + + if (plugin.id !== pluginId) { + errors.push(`āŒ ${file}: Plugin ID "${plugin.id}" doesn't match filename "${pluginId}.json"`); + } + + if (plugin.version && !/^\d+\.\d+\.\d+/.test(plugin.version)) { + errors.push(`āŒ ${file}: Invalid version format: ${plugin.version}`); + } + + if (plugin.author !== prAuthor) { + errors.push(`āŒ ${file}: Author "${plugin.author}" must match PR creator "${prAuthor}"`); + } + + if (plugin.repository) { + try { + new URL(plugin.repository); + + if (plugin.repository.endsWith('.git')) { + errors.push(`āŒ ${file}: Repository URL should not end with .git: ${plugin.repository}`); + } + } catch { + errors.push(`āŒ ${file}: Invalid repository URL: ${plugin.repository}`); + } + } + + if (plugin.homepage) { + try { + new URL(plugin.homepage); + + if (plugin.homepage.endsWith('.git')) { + errors.push(`āŒ ${file}: Homepage URL should not end with .git: ${plugin.homepage}`); + } + } catch { + errors.push(`āŒ ${file}: Invalid homepage URL: ${plugin.homepage}`); + } + } + + if (plugin.downloadUrl) { + try { + new URL(plugin.downloadUrl); + + if (plugin.downloadUrl.endsWith('.git')) { + errors.push(`āŒ ${file}: Download URL should not end with .git: ${plugin.downloadUrl}`); + } + } catch { + errors.push(`āŒ ${file}: Invalid download URL: ${plugin.downloadUrl}`); + } + } + + if (plugin.thumbnail) { + try { + new URL(plugin.thumbnail); + + if (!/\/thumbnail\.(png|jpg|gif)$/i.test(plugin.thumbnail)) { + errors.push(`āŒ ${file}: Thumbnail must be named thumbnail.png, thumbnail.jpg, or thumbnail.gif`); + } + + 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}`); + } + } + + if (errors.length === 0) { + added.push(pluginId); + console.log(`āœ… Valid plugin: ${plugin.name}`); + } + + } catch (err) { + errors.push(`āŒ ${file}: ${err.message}`); + } + + } else if (isRemoved) { + try { + const originalContent = await readFile(baseFilePath, 'utf-8'); + const originalPlugin = JSON.parse(originalContent); + + if (originalPlugin.author !== prAuthor) { + errors.push(`āŒ ${file}: Cannot remove plugin owned by "${originalPlugin.author}" (you are "${prAuthor}")`); + continue; + } + + removed.push(pluginId); + console.log(`šŸ—‘ļø Removed: ${pluginId} (owned by ${prAuthor})`); + } catch (err) { + errors.push(`āŒ ${file}: Could not verify ownership for removal: ${err.message}`); + } + } +} + +console.log('\n' + '='.repeat(50)); +if (errors.length > 0) { + console.log('āŒ VALIDATION FAILED\n'); + errors.forEach(err => console.log(err)); + console.log('\nšŸ“‹ Rules:'); + console.log(' 1. Only modify files in plugins/ directory'); + console.log(' 2. Only JSON files allowed'); + console.log(' 3. Must match plugin schema'); + console.log(' 4. Plugin ID must match filename'); + console.log(' 5. Author field must be your username'); + console.log(' 6. Must be valid JSON'); + process.exit(1); +} + +console.log('āœ… VALIDATION PASSED\n'); +if (added.length > 0) { + console.log(`šŸ“¦ Added plugins: ${added.join(', ')}`); +} +if (removed.length > 0) { + console.log(`šŸ—‘ļø Removed plugins: ${removed.join(', ')}`); +} +console.log('\n✨ This PR is ready to merge!'); +process.exit(0); diff --git a/.github/workflows/update-index.yml b/.github/workflows/update-index.yml new file mode 100644 index 0000000..9c7098d --- /dev/null +++ b/.github/workflows/update-index.yml @@ -0,0 +1,37 @@ +name: Update Plugin Index +on: + push: + branches: [ main ] + paths: + - 'plugins/*.json' + workflow_dispatch: + +jobs: + update-index: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + + - name: Generate index.json + run: node .github/scripts/update-index.js + + - name: Commit index.json + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add plugins/index.json + if git diff --staged --quiet; then + echo "No changes to index.json" + else + git commit -m "Auto-update plugin index [skip ci]" + git push + fi diff --git a/.github/workflows/validate-pr.yml b/.github/workflows/validate-pr.yml new file mode 100644 index 0000000..af1fc9e --- /dev/null +++ b/.github/workflows/validate-pr.yml @@ -0,0 +1,93 @@ +name: Validate Plugin PR +on: + pull_request: + branches: [ main ] + +jobs: + validate: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - name: Checkout base branch (main) for validation scripts + uses: actions/checkout@v4 + with: + ref: main + path: base + + - name: Checkout PR branch for plugin files + uses: actions/checkout@v4 + with: + path: pr + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + + - name: Get changed files + id: changed-files + run: | + cd pr + echo "files=$(git diff --name-only origin/${{ github.base_ref }}...HEAD | grep '^plugins/' || echo '')" >> $GITHUB_OUTPUT + + - name: Block attempts to modify infrastructure + run: | + cd pr + INFRA_CHANGES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD | grep -E '^\.github/' || echo '') + if [ -n "$INFRA_CHANGES" ]; then + echo "āŒ ERROR: PRs cannot modify .github/ directory files" + echo "Changed infrastructure files:" + echo "$INFRA_CHANGES" + echo "" + echo "Only plugins/ directory changes are allowed." + exit 1 + fi + + - name: Verify commit authors match PR creator + run: | + cd pr + echo "šŸ” Verifying commit authors..." + PR_USER="${{ github.event.pull_request.user.login }}" + echo "PR created by: $PR_USER" + + COMMITS=$(git log --format="%H" origin/${{ github.base_ref }}..HEAD) + + for commit in $COMMITS; do + AUTHOR=$(git show -s --format='%an' $commit) + EMAIL=$(git show -s --format='%ae' $commit) + echo " Commit $commit by $AUTHOR <$EMAIL>" + done + + echo "āœ“ PR created by authenticated user: $PR_USER" + echo "Note: Validation will check plugin ownership against this authenticated user, not git commit authors" + + - name: Validate PR (using trusted validation script from main) + run: | + cd base + node .github/scripts/validate-pr.js "${{ github.event.pull_request.user.login }}" "${{ steps.changed-files.outputs.files }}" + env: + PR_FILES_DIR: ../pr/plugins + + - name: Post approval comment + if: success() + uses: actions/github-script@v7 + with: + script: | + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: 'āœ… **Validation passed!** Auto-merging this pull request.\n\nAll plugin requirements have been met:\n- āœ“ Valid plugin schema\n- āœ“ Correct filename format\n- āœ“ Author verification\n- āœ“ Repository URL valid\n\nYour plugin will be available in the directory shortly!' + }); + + - name: Auto-merge PR + if: success() + env: + GH_TOKEN: ${{ github.token }} + run: | + cd pr + echo "āœ… Validation passed - Auto-merging PR" + gh pr merge ${{ github.event.pull_request.number }} --squash --auto --repo ${{ github.repository }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..daf1dd0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +.env +*.log +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..de9f7cc --- /dev/null +++ b/README.md @@ -0,0 +1,66 @@ +# JollyRipper Plugin Directory + +Git-based plugin registry for JollyRipper. No server needed - just JSON files. + +## How to Submit a Plugin + +### 1. Create your plugin JSON file + +Filename format: `plugins/{username}-{plugin-name}.json` + +Example: `plugins/yourname-example-plugin.json` + +```json +{ + "id": "yourname-example-plugin", + "name": "Example Plugin", + "version": "1.0.0", + "description": "What your plugin does", + "author": "yourname", + "repository": "https://github.com/yourname/JollyRipper-Plugin-Example", + "homepage": "https://github.com/yourname/JollyRipper-Plugin-Example", + "thumbnail": "https://raw.githubusercontent.com/yourname/JollyRipper-Plugin-Example/main/thumbnail.png", + "tags": ["example", "utility"] +} +``` + +**Required fields:** `id`, `name`, `version`, `description`, `author`, `repository` + +**Optional fields:** `homepage`, `thumbnail`, `tags`, `downloadUrl` + +**Rules:** +- All URLs must NOT end with `.git` +- `thumbnail` must be `thumbnail.png`, `thumbnail.jpg`, or `thumbnail.gif` (max 512x512, max 2MB) + +**Important:** +- Filename must start with your username: `{username}-` +- Plugin `id` must match filename (without .json) +- `author` field must match your GitHub username + +### 2. Create a Pull Request + +Push your branch and create a PR. GitHub Actions will automatically: +- Validate your submission +- Post approval comment if valid +- Auto-merge and update the plugin index + +That's it! Your plugin will be available immediately after merge. + +## Updating or Removing + +- **Update:** Create PR with modified JSON (version bump required) +- **Remove:** Create PR deleting your plugin JSON file + +You can only modify/remove plugins you authored. + +## Plugin Discovery + +JollyRipper fetches plugins from: + +``` +https://raw.githubusercontent.com/MatesMediaDev/JollyRipper-PluginsDirectory/main/plugins/index.json +``` + +## Plugin Development + +See the [JollyRipper Plugin Documentation](https://github.com/MatesMediaDev/JollyRipper) for creating plugins. diff --git a/plugins/index.json b/plugins/index.json new file mode 100644 index 0000000..d0144bc --- /dev/null +++ b/plugins/index.json @@ -0,0 +1,5 @@ +{ + "version": "1.0.0", + "updatedAt": "2026-04-19T00:00:00.000Z", + "plugins": [] +}