Merge branch 'main' into add-example-plugin-2
Some checks failed
Validate Plugin PR / validate (pull_request) Failing after 8s
Some checks failed
Validate Plugin PR / validate (pull_request) Failing after 8s
This commit is contained in:
28
.gitea/scripts/update-index.js
Normal file
28
.gitea/scripts/update-index.js
Normal file
@@ -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);
|
||||||
@@ -10,12 +10,93 @@
|
|||||||
* 4. Plugin ID matches filename
|
* 4. Plugin ID matches filename
|
||||||
* 5. Author matches PR creator (for new plugins)
|
* 5. Author matches PR creator (for new plugins)
|
||||||
* 6. Can only remove own plugins
|
* 6. Can only remove own plugins
|
||||||
|
* 7. Thumbnail requirements: png/gif/jpg, max 512x512, max 2MB
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { readFile, access } from 'fs/promises';
|
import { readFile, access } from 'fs/promises';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
|
import https from 'https';
|
||||||
|
import http from 'http';
|
||||||
|
|
||||||
const REQUIRED_FIELDS = ['id', 'name', 'version', 'description', 'author', 'repository'];
|
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 prAuthor = process.argv[2];
|
||||||
const changedFiles = process.argv[3]?.split('\n').filter(f => f.trim()) || [];
|
const changedFiles = process.argv[3]?.split('\n').filter(f => f.trim()) || [];
|
||||||
@@ -56,12 +137,22 @@ for (const file of changedFiles) {
|
|||||||
|
|
||||||
const pluginId = file.replace('plugins/', '').replace('.json', '');
|
const pluginId = file.replace('plugins/', '').replace('.json', '');
|
||||||
|
|
||||||
|
// Filename must be prefixed with username
|
||||||
|
if (!pluginId.startsWith(`${prAuthor}-`)) {
|
||||||
|
errors.push(`❌ ${file}: Filename must start with your username "${prAuthor}-" (e.g., "${prAuthor}-my-plugin.json")`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adjust file paths based on working directory
|
||||||
|
const prFilePath = process.env.PR_FILES_DIR ? `${process.env.PR_FILES_DIR}/${file.replace('plugins/', '')}` : file;
|
||||||
|
const baseFilePath = file;
|
||||||
|
|
||||||
// Check if file was added or removed
|
// Check if file was added or removed
|
||||||
let isAdded = false;
|
let isAdded = false;
|
||||||
let isRemoved = false;
|
let isRemoved = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await access(file);
|
await access(prFilePath);
|
||||||
isAdded = true;
|
isAdded = true;
|
||||||
} catch {
|
} catch {
|
||||||
isRemoved = true;
|
isRemoved = true;
|
||||||
@@ -70,9 +161,32 @@ for (const file of changedFiles) {
|
|||||||
if (isAdded) {
|
if (isAdded) {
|
||||||
// Validate added/modified plugin
|
// Validate added/modified plugin
|
||||||
try {
|
try {
|
||||||
const content = await readFile(file, 'utf-8');
|
const content = await readFile(prFilePath, 'utf-8');
|
||||||
const plugin = JSON.parse(content);
|
const plugin = JSON.parse(content);
|
||||||
|
|
||||||
|
// Check if this plugin already exists on main
|
||||||
|
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 {
|
||||||
|
// File doesn't exist on main, this is a new plugin
|
||||||
|
isModification = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If modifying existing plugin, MUST match original author
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
|
||||||
// Check required fields
|
// Check required fields
|
||||||
for (const field of REQUIRED_FIELDS) {
|
for (const field of REQUIRED_FIELDS) {
|
||||||
if (!plugin[field]) {
|
if (!plugin[field]) {
|
||||||
@@ -90,7 +204,7 @@ for (const file of changedFiles) {
|
|||||||
errors.push(`❌ ${file}: Invalid version format: ${plugin.version}`);
|
errors.push(`❌ ${file}: Invalid version format: ${plugin.version}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check author matches PR creator (for new files)
|
// Check author matches PR creator
|
||||||
if (plugin.author !== prAuthor) {
|
if (plugin.author !== prAuthor) {
|
||||||
errors.push(`❌ ${file}: Author "${plugin.author}" must match PR creator "${prAuthor}"`);
|
errors.push(`❌ ${file}: Author "${plugin.author}" must match PR creator "${prAuthor}"`);
|
||||||
}
|
}
|
||||||
@@ -104,6 +218,23 @@ for (const file of changedFiles) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (errors.length === 0) {
|
if (errors.length === 0) {
|
||||||
added.push(pluginId);
|
added.push(pluginId);
|
||||||
console.log(`✅ Valid plugin: ${plugin.name}`);
|
console.log(`✅ Valid plugin: ${plugin.name}`);
|
||||||
@@ -114,10 +245,21 @@ for (const file of changedFiles) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
} else if (isRemoved) {
|
} else if (isRemoved) {
|
||||||
// For removed files, we can't validate ownership easily in CI
|
// For removed files, check the original author from main branch
|
||||||
// Just track that it was removed
|
try {
|
||||||
removed.push(pluginId);
|
const originalContent = await readFile(baseFilePath, 'utf-8');
|
||||||
console.log(`🗑️ Removed: ${pluginId}`);
|
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}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,29 +13,13 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: '18'
|
||||||
|
|
||||||
- name: Generate index.json
|
- name: Generate index.json
|
||||||
run: |
|
run: node .gitea/scripts/update-index.js
|
||||||
cd plugins
|
|
||||||
echo '{' > index.json
|
|
||||||
echo ' "version": "1.0.0",' >> index.json
|
|
||||||
echo ' "updatedAt": "'$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")'",' >> index.json
|
|
||||||
echo ' "plugins": [' >> index.json
|
|
||||||
|
|
||||||
first=true
|
|
||||||
for file in *.json; do
|
|
||||||
if [ "$file" != "index.json" ]; then
|
|
||||||
plugin_id="${file%.json}"
|
|
||||||
if [ "$first" = true ]; then
|
|
||||||
echo " \"$plugin_id\"" >> index.json
|
|
||||||
first=false
|
|
||||||
else
|
|
||||||
echo ", \"$plugin_id\"" >> index.json
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
done | sed '$ s/,$//'
|
|
||||||
|
|
||||||
echo ' ]' >> index.json
|
|
||||||
echo '}' >> index.json
|
|
||||||
|
|
||||||
- name: Commit index.json
|
- name: Commit index.json
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -2,15 +2,21 @@ name: Validate Plugin PR
|
|||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ main ]
|
branches: [ main ]
|
||||||
paths:
|
|
||||||
- 'plugins/**'
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
validate:
|
validate:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- name: Checkout base branch (main) for validation scripts
|
||||||
|
uses: actions/checkout@v3
|
||||||
with:
|
with:
|
||||||
|
ref: main
|
||||||
|
path: base
|
||||||
|
|
||||||
|
- name: Checkout PR branch for plugin files
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
path: pr
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
@@ -21,18 +27,72 @@ jobs:
|
|||||||
- name: Get changed files
|
- name: Get changed files
|
||||||
id: changed-files
|
id: changed-files
|
||||||
run: |
|
run: |
|
||||||
|
cd pr
|
||||||
echo "files=$(git diff --name-only origin/${{ github.base_ref }}...HEAD | grep '^plugins/' || echo '')" >> $GITHUB_OUTPUT
|
echo "files=$(git diff --name-only origin/${{ github.base_ref }}...HEAD | grep '^plugins/' || echo '')" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Validate PR
|
- name: Block attempts to modify infrastructure
|
||||||
run: |
|
run: |
|
||||||
|
cd pr
|
||||||
|
INFRA_CHANGES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD | grep -E '^\.gitea/' || echo '')
|
||||||
|
if [ -n "$INFRA_CHANGES" ]; then
|
||||||
|
echo "❌ ERROR: PRs cannot modify .gitea/ 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"
|
||||||
|
|
||||||
|
# Get all commits in this PR
|
||||||
|
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>"
|
||||||
|
|
||||||
|
# This is a warning, not a hard block, since git authors can be configured differently
|
||||||
|
# The real security comes from PR creator authentication
|
||||||
|
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 .gitea/scripts/validate-pr.js "${{ github.event.pull_request.user.login }}" "${{ steps.changed-files.outputs.files }}"
|
node .gitea/scripts/validate-pr.js "${{ github.event.pull_request.user.login }}" "${{ steps.changed-files.outputs.files }}"
|
||||||
|
env:
|
||||||
|
PR_FILES_DIR: ../pr/plugins
|
||||||
|
|
||||||
- name: Auto-merge PR
|
- name: Auto-merge PR
|
||||||
if: success()
|
if: success()
|
||||||
run: |
|
run: |
|
||||||
|
cd pr
|
||||||
echo "✅ Validation passed - Auto-merging PR"
|
echo "✅ Validation passed - Auto-merging PR"
|
||||||
curl -X POST \
|
|
||||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
# Configure git with token authentication
|
||||||
-H "Content-Type: application/json" \
|
git config user.name "gitea-actions[bot]"
|
||||||
"http://synbox.ruv.wtf:8418/api/v1/repos/litruv/Plugin-Directory/pulls/${{ github.event.pull_request.number }}/merge" \
|
git config user.email "gitea-actions[bot]@users.noreply.gitea.io"
|
||||||
-d '{"Do":"squash","MergeTitleField":"${{ github.event.pull_request.title }}","MergeMessageField":"Auto-merged after validation","delete_branch_after_merge":true}'
|
|
||||||
|
# Set up authenticated remote URL using github.token
|
||||||
|
git remote set-url origin "http://oauth2:${{ github.token }}@synbox.ruv.wtf:8418/litruv/Plugin-Directory.git"
|
||||||
|
|
||||||
|
# Fetch and merge
|
||||||
|
git fetch origin ${{ github.event.pull_request.head.ref }}
|
||||||
|
git checkout main
|
||||||
|
git merge --squash origin/${{ github.event.pull_request.head.ref }}
|
||||||
|
git commit -m "${{ github.event.pull_request.title }}"
|
||||||
|
|
||||||
|
# Push changes
|
||||||
|
git push origin main
|
||||||
|
|
||||||
|
# Delete the PR branch
|
||||||
|
git push origin --delete ${{ github.event.pull_request.head.ref }}
|
||||||
|
|||||||
137
SECURITY.md
Normal file
137
SECURITY.md
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
# Security Model
|
||||||
|
|
||||||
|
## Authentication vs Git Configuration
|
||||||
|
|
||||||
|
### How User Identity Works
|
||||||
|
|
||||||
|
**Question:** Can't I just set my git username to anything and bypass validation?
|
||||||
|
|
||||||
|
**Answer:** No. The validation uses **Gitea account authentication**, not git commit authors.
|
||||||
|
|
||||||
|
#### Git Commit Author (NOT used for validation)
|
||||||
|
```bash
|
||||||
|
# This can be set to anything locally
|
||||||
|
git config user.name "anyone"
|
||||||
|
git config user.email "anyone@example.com"
|
||||||
|
git commit -m "My commit"
|
||||||
|
```
|
||||||
|
These values are **NOT** used for plugin ownership validation.
|
||||||
|
|
||||||
|
#### PR Creator (used for validation)
|
||||||
|
When you create a Pull Request:
|
||||||
|
1. You must be logged into your Gitea account
|
||||||
|
2. The PR is created through Gitea's authenticated API
|
||||||
|
3. `github.event.pull_request.user.login` = your authenticated Gitea username
|
||||||
|
|
||||||
|
**This is what validation checks against.**
|
||||||
|
|
||||||
|
### Example Attack Scenario (Prevented)
|
||||||
|
|
||||||
|
❌ **Attacker tries:**
|
||||||
|
```bash
|
||||||
|
# Configure git to impersonate someone
|
||||||
|
git config user.name "victim"
|
||||||
|
git config user.email "victim@example.com"
|
||||||
|
|
||||||
|
# Modify victim's plugin
|
||||||
|
vim plugins/victim-plugin.json
|
||||||
|
# Change author to "attacker"
|
||||||
|
|
||||||
|
git commit -m "Update plugin"
|
||||||
|
git push
|
||||||
|
|
||||||
|
# Create PR (must log into Gitea as "attacker" account)
|
||||||
|
```
|
||||||
|
|
||||||
|
✅ **What happens:**
|
||||||
|
1. PR is created by authenticated Gitea user: `attacker`
|
||||||
|
2. Validation script receives: `prAuthor = "attacker"`
|
||||||
|
3. Validation checks original plugin from main branch: `original.author = "victim"`
|
||||||
|
4. Check fails: `"victim" !== "attacker"`
|
||||||
|
5. **Result:** ❌ `Cannot modify plugin owned by "victim" (you are "attacker")`
|
||||||
|
|
||||||
|
The git commit author is irrelevant - what matters is **who is logged into Gitea when creating the PR**.
|
||||||
|
|
||||||
|
## Security Measures
|
||||||
|
|
||||||
|
### 1. Dual Checkout
|
||||||
|
```yaml
|
||||||
|
- Checkout main branch → base/ (trusted validation scripts)
|
||||||
|
- Checkout PR branch → pr/ (untrusted user submissions)
|
||||||
|
```
|
||||||
|
Validation script always runs from `base/`, never from PR branch.
|
||||||
|
|
||||||
|
### 2. Infrastructure Protection
|
||||||
|
PRs that modify `.gitea/` directory are automatically rejected.
|
||||||
|
|
||||||
|
### 3. Ownership Verification
|
||||||
|
All modifications/deletions check **original ownership from main branch**:
|
||||||
|
```javascript
|
||||||
|
// Check original plugin from main (base/)
|
||||||
|
const originalPlugin = readFile('../base/plugins/example.json');
|
||||||
|
|
||||||
|
// User can only modify if they own the original
|
||||||
|
if (originalPlugin.author !== prAuthor) {
|
||||||
|
reject("Cannot modify plugin owned by someone else");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Author Field Validation
|
||||||
|
New plugins must have `author` field matching the PR creator's authenticated Gitea username.
|
||||||
|
|
||||||
|
## What About Collaboration?
|
||||||
|
|
||||||
|
**Q:** What if I want someone else to update my plugin?
|
||||||
|
|
||||||
|
**A:** They cannot submit PRs directly. Options:
|
||||||
|
1. Transfer ownership by updating the `author` field yourself
|
||||||
|
2. They fork your plugin repo and create their own plugin entry
|
||||||
|
3. Add them as collaborators to your plugin repository (not the directory)
|
||||||
|
|
||||||
|
## Trust Model
|
||||||
|
|
||||||
|
### Trusted Components
|
||||||
|
- Main branch content (validated history)
|
||||||
|
- Gitea authentication system
|
||||||
|
- GitHub Actions runner environment
|
||||||
|
- Validation scripts on main branch
|
||||||
|
|
||||||
|
### Untrusted Components
|
||||||
|
- PR branch content (user submissions)
|
||||||
|
- Git commit metadata (names, emails)
|
||||||
|
- PR descriptions and comments
|
||||||
|
|
||||||
|
### Validation Flow
|
||||||
|
```
|
||||||
|
1. User authenticates to Gitea → Trusted user identity
|
||||||
|
2. User creates PR → github.event.pull_request.user.login
|
||||||
|
3. Validation runs from main branch scripts → Trusted code
|
||||||
|
4. Checks plugin ownership against main → Trusted ownership data
|
||||||
|
5. Validates PR author matches plugin author → Secure check
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reporting Security Issues
|
||||||
|
|
||||||
|
If you find a security vulnerability in the plugin directory system:
|
||||||
|
1. **Do NOT create a public issue or PR**
|
||||||
|
2. Contact the repository maintainer directly
|
||||||
|
3. Provide detailed reproduction steps
|
||||||
|
4. Allow time for a fix before public disclosure
|
||||||
|
|
||||||
|
## Additional Safeguards
|
||||||
|
|
||||||
|
### Rate Limiting
|
||||||
|
Gitea's built-in rate limiting prevents spam PR attacks.
|
||||||
|
|
||||||
|
### Branch Protection
|
||||||
|
The `main` branch is protected:
|
||||||
|
- Direct pushes disabled
|
||||||
|
- All changes via PR
|
||||||
|
- Auto-merge only after validation passes
|
||||||
|
|
||||||
|
### Audit Trail
|
||||||
|
All changes are tracked in git history:
|
||||||
|
- Who created the PR (Gitea account)
|
||||||
|
- What changed (git diff)
|
||||||
|
- When it was merged (git commit timestamp)
|
||||||
|
- Why validation passed (CI logs)
|
||||||
200
VALIDATION_RULES.md
Normal file
200
VALIDATION_RULES.md
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
# Plugin Validation Rules
|
||||||
|
|
||||||
|
All plugin submissions are automatically validated before being merged. PRs must pass all validation checks to be auto-merged.
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
**The validation script runs from the main branch, not from your PR branch.** This prevents malicious PRs from modifying the validation logic. PRs that attempt to modify `.gitea/` directory files will be automatically rejected.
|
||||||
|
|
||||||
|
## File Requirements
|
||||||
|
|
||||||
|
### 1. Location
|
||||||
|
- ✅ Only modify files in `plugins/` directory
|
||||||
|
- ❌ Cannot modify files outside `plugins/`
|
||||||
|
- ❌ Cannot modify `.gitea/` infrastructure files
|
||||||
|
- ⚠️ `plugins/index.json` is auto-generated - don't manually edit it
|
||||||
|
|
||||||
|
### 2. File Naming
|
||||||
|
- **Filename must be prefixed with your username**
|
||||||
|
- Format: `username-plugin-name.json`
|
||||||
|
- Example: `litruv-example-plugin.json`
|
||||||
|
- ❌ Invalid: `example-plugin.json` (missing username prefix)
|
||||||
|
- ❌ Invalid: `otheruser-plugin.json` (wrong username)
|
||||||
|
|
||||||
|
This prevents naming conflicts and makes ownership visible.
|
||||||
|
|
||||||
|
### 3. File Type
|
||||||
|
- ✅ Only `.json` files are allowed in `plugins/` directory
|
||||||
|
- ❌ No other file types (images, scripts, etc. must be in plugin repo)
|
||||||
|
|
||||||
|
## Plugin JSON Schema
|
||||||
|
|
||||||
|
### Required Fields
|
||||||
|
Every plugin JSON must have these fields:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "string",
|
||||||
|
"name": "string",
|
||||||
|
"version": "string",
|
||||||
|
"description": "string",
|
||||||
|
"author": "string",
|
||||||
|
"repository": "string"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Field Validation Rules
|
||||||
|
|
||||||
|
#### `id` (required)
|
||||||
|
- Must match the filename
|
||||||
|
- Example: `litruv-example-plugin.json` → `"id": "litruv-example-plugin"`
|
||||||
|
- Must include username prefix matching filename
|
||||||
|
- Lowercase, hyphens allowed, no spaces
|
||||||
|
|
||||||
|
#### `name` (required)
|
||||||
|
- Display name for the plugin
|
||||||
|
- Human-readable, any format
|
||||||
|
|
||||||
|
#### `version` (required)
|
||||||
|
- Must follow semantic versioning: `x.y.z`
|
||||||
|
- Example: `"1.0.0"`, `"2.1.3"`
|
||||||
|
- ❌ Invalid: `"v1.0"`, `"1.0"`, `"latest"`
|
||||||
|
|
||||||
|
#### `description` (required)
|
||||||
|
- Short description of what the plugin does
|
||||||
|
- Will be shown in the plugin browser
|
||||||
|
|
||||||
|
#### `author` (required)
|
||||||
|
- **MUST MATCH YOUR GITEA USERNAME**
|
||||||
|
- This enforces ownership - you can only submit/remove plugins authored by you
|
||||||
|
- Case-sensitive
|
||||||
|
|
||||||
|
#### `repository` (required)
|
||||||
|
- Git repository URL where the plugin code lives
|
||||||
|
- Must be a valid URL
|
||||||
|
- Example: `"http://synbox.ruv.wtf:8418/username/Plugin-Name.git"`
|
||||||
|
|
||||||
|
#### `thumbnail` (optional)
|
||||||
|
- URL to plugin thumbnail image
|
||||||
|
- **Requirements:**
|
||||||
|
- Format: PNG, JPG, or GIF only
|
||||||
|
- Max dimensions: 512×512 pixels
|
||||||
|
- Max file size: 2MB
|
||||||
|
- Must be accessible via HTTP/HTTPS
|
||||||
|
- Example: `"http://synbox.ruv.wtf:8418/username/Plugin-Name/raw/branch/main/thumbnail.png"`
|
||||||
|
- Validation checks actual image format (magic bytes), dimensions, and size
|
||||||
|
|
||||||
|
### Optional Fields
|
||||||
|
You can include any additional fields for metadata:
|
||||||
|
- `homepage` - Project homepage URL
|
||||||
|
- `downloadUrl` - Direct download link
|
||||||
|
- `tags` - Array of tags
|
||||||
|
- `addedDate` - ISO timestamp
|
||||||
|
|
||||||
|
## Ownership Rules
|
||||||
|
|
||||||
|
### Adding Plugins
|
||||||
|
- The `author` field must match your Gitea username
|
||||||
|
- This is checked automatically
|
||||||
|
|
||||||
|
### Modifying Plugins
|
||||||
|
- **You can only modify plugins where the ORIGINAL author (on main branch) is you**
|
||||||
|
- Even if you change the `author` field in your PR, validation checks against the original
|
||||||
|
- Attempting to modify someone else's plugin will fail validation with:
|
||||||
|
```
|
||||||
|
❌ plugins/their-plugin.json: Cannot modify plugin owned by "them" (you are "you")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Removing Plugins
|
||||||
|
- **You can only remove plugins where the ORIGINAL author (on main branch) is you**
|
||||||
|
- The validation checks the plugin's author from the main branch, not from your PR
|
||||||
|
- Attempting to remove someone else's plugin will fail validation with:
|
||||||
|
```
|
||||||
|
❌ plugins/their-plugin.json: Cannot remove plugin owned by "them" (you are "you")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Security Note:** All ownership checks use the plugin data from the `main` branch, not from your PR. You cannot bypass ownership by modifying the `author` field.
|
||||||
|
|
||||||
|
## Validation Process
|
||||||
|
|
||||||
|
1. **File check**: Ensures only `plugins/*.json` files are modified
|
||||||
|
2. **Infrastructure check**: Blocks any `.gitea/` modifications
|
||||||
|
3. **JSON parsing**: Validates JSON syntax
|
||||||
|
4. **Schema validation**: Checks all required fields exist
|
||||||
|
5. **Field validation**: Validates each field's format and rules
|
||||||
|
6. **Thumbnail validation** (if provided): Downloads and validates image
|
||||||
|
7. **Author matching**: Ensures `author` field matches PR creator
|
||||||
|
8. **Auto-merge**: If all checks pass, PR is automatically merged
|
||||||
|
|
||||||
|
## Testing Locally
|
||||||
|
|
||||||
|
You can test your plugin JSON before submitting:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd Plugin-Directory
|
||||||
|
node .gitea/scripts/validate-pr.js "your-username" "plugins/your-plugin.json"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Errors
|
||||||
|
|
||||||
|
### ❌ Author mismatch
|
||||||
|
```
|
||||||
|
❌ plugins/example-plugin.json: Author "someone" must match PR creator "you"
|
||||||
|
```
|
||||||
|
**Fix**: Change `"author": "someone"` to `"author": "you"`
|
||||||
|
|
||||||
|
### ❌ ID doesn't match filename
|
||||||
|
```litruv-my-plugin.json: Plugin ID "different-name" doesn't match filename "litruv-my-plugin.json"
|
||||||
|
```
|
||||||
|
**Fix**: Change `"id": "different-name"` to `"id": "litruv-my-plugin"`
|
||||||
|
|
||||||
|
### ❌ Filename missing username prefix
|
||||||
|
```
|
||||||
|
❌ plugins/my-plugin.json: Filename must start with your username "litruv-" (e.g., "litruv-my-plugin.json")
|
||||||
|
```
|
||||||
|
**Fix**: Rename file from `my-plugin.json` to `litruv-my-plugin.json` and update the `id` field
|
||||||
|
**Fix**: Change `"id": "different-name"` to `"id": "my-plugin"`
|
||||||
|
|
||||||
|
### ❌ Invalid version
|
||||||
|
```
|
||||||
|
❌ plugins/example-plugin.json: Invalid version format: v1.0
|
||||||
|
```
|
||||||
|
**Fix**: Use semantic versioning like `"version": "1.0.0"`
|
||||||
|
|
||||||
|
### ❌ Thumbnail too large
|
||||||
|
```
|
||||||
|
❌ plugins/example-plugin.json: Thumbnail validation failed: Thumbnail exceeds 2MB limit (3.45MB)
|
||||||
|
```
|
||||||
|
**Fix**: Compress your thumbnail to under 2MB
|
||||||
|
|
||||||
|
### ❌ Thumbnail dimensions too large
|
||||||
|
```
|
||||||
|
❌ plugins/example-plugin.json: Thumbnail validation failed: Thumbnail dimensions 1024x768 exceed 512x512
|
||||||
|
```
|
||||||
|
**Fix**: litruv-example-plugin",
|
||||||
|
"name": "Example Plugin",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"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",
|
||||||
|
"homepage": "http://synbox.ruv.wtf:8418/litruv/Plugin-Example",
|
||||||
|
"tags": ["example", "demo"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Filename: `litruv-example-plugin.json"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",
|
||||||
|
"homepage": "http://synbox.ruv.wtf:8418/litruv/Plugin-Example",
|
||||||
|
"tags": ["example", "demo"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security Note
|
||||||
|
|
||||||
|
**Never include sensitive information in plugin JSON files.** These files are public and will be served to all plugin host users. Do not include:
|
||||||
|
- API keys or tokens
|
||||||
|
- Passwords
|
||||||
|
- Private URLs or endpoints
|
||||||
|
- Personal information
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "example-plugin",
|
|
||||||
"name": "Example Plugin",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "An example plugin demonstrating the plugin system capabilities",
|
|
||||||
"author": "litruv",
|
|
||||||
"repository": "http://synbox.ruv.wtf:8418/litruv/Plugin-Example.git",
|
|
||||||
"downloadUrl": "http://synbox.ruv.wtf:8418/litruv/Plugin-Example/archive/main.zip",
|
|
||||||
"homepage": "http://synbox.ruv.wtf:8418/litruv/Plugin-Example",
|
|
||||||
"tags": ["example", "demo"],
|
|
||||||
"addedDate": "2026-04-17T00:00:00.000Z"
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"updatedAt": "2026-04-17T00:00:00.000Z",
|
"updatedAt": "2026-04-16T17:14:35.921Z",
|
||||||
"plugins": [
|
"plugins": []
|
||||||
"example-plugin"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user