Files
Plugin-Directory/src/ValidatorEnhanced.js

233 lines
6.7 KiB
JavaScript

import { WebhookHandler } from './WebhookHandler.js';
import fetch from 'node-fetch';
/**
* Enhanced validator that fetches actual file content from PR
*/
export class Validator {
constructor(webhookHandler) {
this.handler = webhookHandler;
this.requiredFields = ['id', 'name', 'version', 'description', 'author', 'repository'];
this.optionalFields = ['downloadUrl', 'homepage', 'tags', 'addedDate', 'dependencies'];
}
/**
* Validate a PR's file changes
*/
async validatePR(files, prAuthor, prNumber) {
const errors = [];
const added = [];
const removed = [];
// Check that all files are in the plugins/ directory
const invalidPaths = files.filter(f => !f.filename.startsWith('plugins/'));
if (invalidPaths.length > 0) {
errors.push(`Files outside plugins/ directory: ${invalidPaths.map(f => f.filename).join(', ')}`);
}
// Check that all files are .json files
const nonJsonFiles = files.filter(f =>
f.filename.startsWith('plugins/') && !f.filename.endsWith('.json')
);
if (nonJsonFiles.length > 0) {
errors.push(`Non-JSON files in plugins/: ${nonJsonFiles.map(f => f.filename).join(', ')}`);
}
// Process each changed file
for (const file of files) {
if (!file.filename.startsWith('plugins/') || !file.filename.endsWith('.json')) {
continue;
}
const pluginId = file.filename.replace('plugins/', '').replace('.json', '');
if (file.status === 'added' || file.status === 'modified') {
// Fetch and validate the file content
try {
const content = await this.fetchPRFileContent(prNumber, file.filename);
const validation = await this.validatePluginContent(content, pluginId, prAuthor, file.status);
if (!validation.valid) {
errors.push(...validation.errors.map(e => `${file.filename}: ${e}`));
} else {
added.push(pluginId);
}
} catch (error) {
errors.push(`${file.filename}: ${error.message}`);
}
} else if (file.status === 'removed') {
// Validate removed plugins (check ownership)
try {
const canRemove = await this.validateRemoval(pluginId, prAuthor);
if (!canRemove.valid) {
errors.push(...canRemove.errors.map(e => `${file.filename}: ${e}`));
} else {
removed.push(pluginId);
}
} catch (error) {
errors.push(`${file.filename}: ${error.message}`);
}
}
}
// Must have at least one change
if (files.length === 0) {
errors.push('PR has no file changes');
}
return {
valid: errors.length === 0,
errors,
added,
removed
};
}
/**
* Fetch file content from a PR
*/
async fetchPRFileContent(prNumber, filename) {
const pr = await this.getPRDetails(prNumber);
const headSha = pr.head.sha;
const url = `${this.handler.giteaUrl}/api/v1/repos/${this.handler.repoOwner}/${this.handler.repoName}/contents/${filename}?ref=${headSha}`;
const response = await fetch(url, {
headers: { 'Authorization': `token ${this.handler.giteaToken}` }
});
if (!response.ok) {
throw new Error(`Failed to fetch file: ${response.statusText}`);
}
const data = await response.json();
const content = Buffer.from(data.content, 'base64').toString('utf-8');
return content;
}
/**
* Get PR details
*/
async getPRDetails(prNumber) {
const url = `${this.handler.giteaUrl}/api/v1/repos/${this.handler.repoOwner}/${this.handler.repoName}/pulls/${prNumber}`;
const response = await fetch(url, {
headers: { 'Authorization': `token ${this.handler.giteaToken}` }
});
if (!response.ok) {
throw new Error(`Failed to fetch PR: ${response.statusText}`);
}
return await response.json();
}
/**
* Validate plugin JSON content
*/
async validatePluginContent(content, pluginId, prAuthor, status) {
const errors = [];
try {
const plugin = JSON.parse(content);
// Check required fields
for (const field of this.requiredFields) {
if (!plugin[field]) {
errors.push(`Missing required field: ${field}`);
}
}
// Check that ID matches filename
if (plugin.id !== pluginId) {
errors.push(`Plugin ID "${plugin.id}" does not match filename "${pluginId}.json"`);
}
// Validate version format (semver-ish)
if (plugin.version && !this.isValidVersion(plugin.version)) {
errors.push(`Invalid version format: ${plugin.version} (expected: X.Y.Z)`);
}
// Validate repository URL
if (plugin.repository && !this.isValidUrl(plugin.repository)) {
errors.push(`Invalid repository URL: ${plugin.repository}`);
}
// Validate author matches PR author (for new plugins)
if (status === 'added' && plugin.author !== prAuthor) {
errors.push(`Author "${plugin.author}" must match PR author "${prAuthor}"`);
}
// Validate tags if present
if (plugin.tags && !Array.isArray(plugin.tags)) {
errors.push('Tags must be an array');
}
} catch (error) {
errors.push(`Invalid JSON: ${error.message}`);
}
return {
valid: errors.length === 0,
errors
};
}
/**
* Validate that a user can remove a plugin
*/
async validateRemoval(pluginId, prAuthor) {
const errors = [];
try {
// Read the existing plugin file from main branch
const url = `${this.handler.giteaUrl}/api/v1/repos/${this.handler.repoOwner}/${this.handler.repoName}/contents/plugins/${pluginId}.json`;
const response = await fetch(url, {
headers: { 'Authorization': `token ${this.handler.giteaToken}` }
});
if (!response.ok) {
// If file doesn't exist on main, removal is fine
return { valid: true, errors: [] };
}
const data = await response.json();
const content = Buffer.from(data.content, 'base64').toString('utf-8');
const plugin = JSON.parse(content);
// Check if the PR author is the plugin author
if (plugin.author !== prAuthor) {
errors.push(`Cannot remove plugin: you are not the author (author: ${plugin.author})`);
}
} catch (error) {
console.warn(`Could not validate removal of ${pluginId}:`, error.message);
}
return {
valid: errors.length === 0,
errors
};
}
/**
* Validate semantic version format
*/
isValidVersion(version) {
return /^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$/.test(version);
}
/**
* Validate URL format
*/
isValidUrl(url) {
try {
new URL(url);
return true;
} catch {
return false;
}
}
}