Update websocket.json #108
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
name: Track Repository Stats | |
on: | |
pull_request: | |
types: | |
- closed | |
jobs: | |
track-stats: | |
runs-on: ubuntu-latest | |
permissions: | |
contents: write | |
pull-requests: read | |
steps: | |
- name: Checkout repository | |
uses: actions/checkout@v4 | |
with: | |
fetch-depth: 0 | |
token: ${{ secrets.PAT_TOKEN }} | |
- name: Get changed files | |
id: changed-files | |
run: | | |
echo "Getting changed files..." | |
BASE_SHA=${{ github.event.pull_request.base.sha }} | |
HEAD_SHA=${{ github.event.pull_request.head.sha }} | |
echo "base_sha=$BASE_SHA" >> $GITHUB_ENV | |
echo "head_sha=$HEAD_SHA" >> $GITHUB_ENV | |
CHANGED_FILES=$(git diff --name-only $BASE_SHA $HEAD_SHA) | |
echo "Changed files: $CHANGED_FILES" | |
if ! echo "$CHANGED_FILES" | grep -q "^infos/"; then | |
echo "No changes in infos/ directory. Skipping workflow." | |
exit 0 | |
fi | |
# Save changed files for later use | |
echo "$CHANGED_FILES" > changed_files.txt | |
- name: Setup Node.js | |
uses: actions/setup-node@v3 | |
with: | |
node-version: '18' | |
- name: Install dependencies | |
run: | | |
npm install axios fs-extra crypto child_process moment-timezone | |
- name: Verify plugins | |
run: | | |
cat > verify_plugin.js <<'EOL' | |
const fs = require('fs-extra'); | |
const path = require('path'); | |
const axios = require('axios'); | |
const crypto = require('crypto'); | |
const { execSync } = require('child_process'); | |
class Colors { | |
static HEADER = '\x1b[95m'; | |
static BLUE = '\x1b[94m'; | |
static GREEN = '\x1b[92m'; | |
static YELLOW = '\x1b[93m'; | |
static RED = '\x1b[91m'; | |
static RESET = '\x1b[0m'; | |
} | |
class PluginVerifier { | |
constructor(officialKey) { | |
this.officialKey = officialKey; | |
} | |
normalizeContent(content) { | |
return content.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); | |
} | |
readFileWithDetection(filePath) { | |
try { | |
const content = fs.readFileSync(filePath, 'utf8'); | |
return this.normalizeContent(content); | |
} catch (err) { | |
console.error(`Error reading file ${filePath}: ${err}`); | |
return ''; | |
} | |
} | |
getAllFiles(directory, baseDir = null) { | |
if (!baseDir) baseDir = directory; | |
const results = {}; | |
fs.readdirSync(directory).forEach(item => { | |
if (item.startsWith('.') || item === 'signature.json') return; | |
const fullPath = path.join(directory, item); | |
if (fs.statSync(fullPath).isDirectory()) { | |
Object.assign(results, this.getAllFiles(fullPath, baseDir)); | |
} else { | |
const relPath = path.relative(baseDir, fullPath).replace(/\\/g, '/'); | |
results[relPath] = this.readFileWithDetection(fullPath); | |
} | |
}); | |
return results; | |
} | |
verify(pluginDir) { | |
try { | |
const signaturePath = path.join(pluginDir, 'signature.json'); | |
if (!fs.existsSync(signaturePath)) { | |
return { valid: false, error: 'Missing signature.json' }; | |
} | |
const signatureData = JSON.parse(fs.readFileSync(signaturePath, 'utf8')); | |
console.log(`${Colors.YELLOW}Signature data loaded:${Colors.RESET}`); | |
console.log(JSON.stringify(signatureData, null, 2)); | |
const { fileHashes, signature, keyId } = signatureData; | |
if (!fileHashes || !signature) { | |
return { valid: false, error: 'Invalid signature data format' }; | |
} | |
console.log(`\n${Colors.BLUE}Verifying files...${Colors.RESET}`); | |
const allFiles = this.getAllFiles(pluginDir); | |
console.log(`Found ${Object.keys(allFiles).length} files to verify:`); | |
Object.keys(allFiles).sort().forEach(file => console.log(` ${file}`)); | |
console.log(`\n${Colors.BLUE}Checking file hashes...${Colors.RESET}`); | |
for (const [filePath, content] of Object.entries(allFiles)) { | |
if (filePath === 'trem.json') continue; | |
if (!(filePath in fileHashes)) { | |
return { valid: false, error: `Extra file: ${filePath}` }; | |
} | |
const actualHash = crypto.createHash('sha256') | |
.update(content) | |
.digest('hex'); | |
console.log(`File: ${filePath}`); | |
console.log(` Expected: ${fileHashes[filePath]}`); | |
console.log(` Actual: ${actualHash}`); | |
if (actualHash !== fileHashes[filePath]) { | |
return { valid: false, error: `Modified file: ${filePath}` }; | |
} | |
} | |
console.log(`\n${Colors.BLUE}Verifying signature...${Colors.RESET}`); | |
console.log(`Using key ID: ${keyId || 'official'}`); | |
const message = Buffer.from(JSON.stringify(fileHashes, Object.keys(fileHashes).sort())); | |
const signatureBuffer = Buffer.from(signature, 'base64'); | |
const verify = crypto.createVerify('SHA256'); | |
verify.update(message); | |
const isValid = verify.verify(this.officialKey, signatureBuffer); | |
if (isValid) { | |
return { valid: true, error: null, keyId: keyId || 'official' }; | |
} | |
return { valid: false, error: 'Invalid signature', keyId: keyId || 'official' }; | |
} catch (err) { | |
console.error(`${Colors.RED}Verification error: ${err}${Colors.RESET}`); | |
return { valid: false, error: err.message }; | |
} | |
} | |
} | |
async function downloadAndVerifyPlugin(org, repo, pluginName, verifier) { | |
console.log(`${Colors.BLUE}Downloading latest release from ${org}/${repo}...${Colors.RESET}`); | |
try { | |
const apiUrl = `https://api.github.com/repos/${org}/${repo}/releases/latest`; | |
const { data: releaseData } = await axios.get(apiUrl); | |
const tremFile = `${pluginName}.trem`; | |
const asset = releaseData.assets.find(a => a.name === tremFile); | |
if (!asset) { | |
throw new Error(`Could not find ${tremFile} in latest release`); | |
} | |
const response = await axios.get(asset.browser_download_url, { | |
responseType: 'arraybuffer' | |
}); | |
const tempDir = fs.mkdtempSync('plugin-'); | |
const tremPath = path.join(tempDir, tremFile); | |
const pluginDir = path.join(tempDir, 'plugin'); | |
fs.writeFileSync(tremPath, response.data); | |
console.log(`${Colors.GREEN}Downloaded ${tremFile}${Colors.RESET}`); | |
fs.mkdirSync(pluginDir, { recursive: true }); | |
execSync(`unzip ${tremPath} -d ${pluginDir}`); | |
const result = verifier.verify(pluginDir); | |
fs.removeSync(tempDir); | |
if (result.valid) { | |
console.log(`${Colors.GREEN}Plugin verification successful!${Colors.RESET}`); | |
return true; | |
} else { | |
console.log(`${Colors.RED}Plugin verification failed: ${result.error}${Colors.RESET}`); | |
return false; | |
} | |
} catch (err) { | |
console.error(`${Colors.RED}Error: ${err}${Colors.RESET}`); | |
return false; | |
} | |
} | |
const publicKey = `-----BEGIN PUBLIC KEY----- | |
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzQn1ouv0mfzVKJevJiq+ | |
6rV9mwCEvQpauQ2QNjy4TiwhqzqNiOPwpM3qo+8+3Ld+DUhzZzSzyx894dmJGlWQ | |
wNss9Vs5/gnuvn6PurNXC42wkxY6Dmsnp/M6g08iqGXVcM6ZWmvCZ3BzBvwExxRR | |
09KxHZVhwoMcF5Kp9l/hNZqXRgYMn3GLt+m78Hr+ZUjHiF8K9UH2TPxKRa/4ttPX | |
6nDBZxZUCwFD7Zh6RePg07JDbO5fI/UYrqZYyDPK8w9xdXtke9LbdXmMuuk/x57h | |
foRArUkhPvUk/77mxo4++3EFnTUxYMnQVuMkDaYNRu7w83abUuhsjNlL/es24HSm | |
lwIDAQAB | |
-----END PUBLIC KEY-----`; | |
if (process.argv.length !== 5) { | |
console.log(`Usage: ${process.argv[1]} <org> <repo> <plugin_name>`); | |
process.exit(1); | |
} | |
const verifier = new PluginVerifier(publicKey); | |
downloadAndVerifyPlugin(process.argv[2], process.argv[3], process.argv[4], verifier) | |
.then(success => process.exit(success ? 0 : 1)); | |
EOL | |
# Read and process each changed JSON file | |
while IFS= read -r file; do | |
if [[ $file == infos/* ]] && [[ $file == *.json ]]; then | |
echo "Processing $file..." | |
# Extract plugin info from JSON | |
PLUGIN_NAME=$(jq -r '.name' "$file") | |
REPO_LINK=$(jq -r '.link' "$file") | |
# Parse GitHub org and repo from link | |
if [[ $REPO_LINK =~ github.com/([^/]+)/([^/]+)$ ]]; then | |
ORG="${BASH_REMATCH[1]}" | |
REPO="${BASH_REMATCH[2]}" | |
echo "Verifying plugin: $PLUGIN_NAME from $ORG/$REPO" | |
node verify_plugin.js "$ORG" "$REPO" "$PLUGIN_NAME" | |
if [ $? -ne 0 ]; then | |
echo "Plugin verification failed for $PLUGIN_NAME" | |
exit 1 | |
fi | |
else | |
echo "Invalid repository link format in $file" | |
exit 1 | |
fi | |
fi | |
done < changed_files.txt | |
- name: Fetch repository stats | |
env: | |
GITHUB_TOKEN: ${{ secrets.PAT_TOKEN }} | |
run: | | |
cat > fetch_stats.js <<'EOL' | |
const fs = require('fs-extra'); | |
const axios = require('axios'); | |
const { execSync } = require('child_process'); | |
const moment = require('moment-timezone'); | |
async function getChangedFiles() { | |
const baseSha = `${{ github.event.pull_request.base.sha }}`; | |
const headSha = `${{ github.event.pull_request.head.sha }}`; | |
const cmd = `git diff --name-only ${baseSha} ${headSha}`; | |
const result = execSync(cmd).toString(); | |
return result.trim().split('\n').filter(f => f.startsWith('infos/') && f.endsWith('.json')); | |
} | |
async function getRepoStats(owner, repo, pluginInfo) { | |
const headers = { | |
Authorization: `token ${process.env.GITHUB_TOKEN}`, | |
Accept: 'application/vnd.github.v3+json' | |
}; | |
try { | |
const baseUrl = `https://api.github.com/repos/${owner}/${repo}`; | |
const [repoResponse, releasesResponse] = await Promise.all([ | |
axios.get(baseUrl, { headers }), | |
axios.get(`${baseUrl}/releases`, { headers }) | |
]); | |
const releases = releasesResponse.data; | |
let totalDownloads = 0; | |
const releaseStats = releases.map(release => { | |
const downloads = release.assets.reduce((sum, asset) => sum + asset.download_count, 0); | |
totalDownloads += downloads; | |
return { | |
tag_name: release.tag_name, | |
name: release.name, | |
downloads, | |
published_at: release.published_at | |
}; | |
}); | |
return { | |
name: pluginInfo.name, | |
version: pluginInfo.version, | |
description: pluginInfo.description, | |
author: pluginInfo.author, | |
dependencies: pluginInfo.dependencies || {}, | |
resources: pluginInfo.resources || [], | |
link: pluginInfo.link, | |
repository: { | |
full_name: repoResponse.data.full_name, | |
releases: { | |
total_count: releases.length, | |
total_downloads: totalDownloads, | |
releases: releaseStats | |
} | |
} | |
}; | |
} catch (err) { | |
console.error(`無法取得倉庫訊息: ${err.message}`); | |
return null; | |
} | |
} | |
async function processPluginFiles(repositoryStats, changedInfoFiles) { | |
const now = moment().tz('Asia/Taipei').format('YYYY-MM-DD HH:mm:ss'); | |
let oldestIndex = null; | |
let oldestTime = null; | |
repositoryStats.forEach((obj, i) => { | |
const currentTime = obj.updated_at || ''; | |
if (!oldestTime || currentTime < oldestTime) { | |
oldestTime = currentTime; | |
oldestIndex = i; | |
} | |
}); | |
for (const filePath of changedInfoFiles) { | |
try { | |
const pluginInfo = JSON.parse(fs.readFileSync(filePath, 'utf8')); | |
if (pluginInfo.link && pluginInfo.link.includes('github.com') && pluginInfo.name) { | |
const parts = pluginInfo.link.trim('/').split('/'); | |
if (parts.length >= 2) { | |
const [owner, repo] = parts.slice(-2); | |
console.log(`處理倉庫: ${owner}/${repo} (${pluginInfo.name})`); | |
const stats = await getRepoStats(owner, repo, pluginInfo); | |
if (stats) { | |
stats.updated_at = now; | |
const existingIndex = repositoryStats.findIndex(obj => obj.name === stats.name); | |
if (existingIndex >= 0) { | |
repositoryStats[existingIndex] = stats; | |
console.log(`更新倉庫數據: ${stats.name}`); | |
} else { | |
repositoryStats.push(stats); | |
console.log(`新增倉庫數據: ${stats.name}`); | |
} | |
} | |
} | |
} | |
} catch (err) { | |
console.error(`處理 ${filePath} 時發生錯誤: ${err.message}`); | |
} | |
} | |
if (oldestIndex !== null) { | |
const oldestRecord = repositoryStats[oldestIndex]; | |
if (oldestRecord.link && oldestRecord.link.includes('github.com')) { | |
const parts = oldestRecord.link.trim('/').split('/'); | |
if (parts.length >= 2) { | |
const [owner, repo] = parts.slice(-2); | |
const stats = await getRepoStats(owner, repo, oldestRecord); | |
if (stats) { | |
oldestRecord.repository = stats.repository; | |
oldestRecord.updated_at = now; | |
console.log(`更新最舊的倉庫狀態: ${oldestRecord.name}`); | |
} | |
} | |
} | |
} | |
return repositoryStats; | |
} | |
async function main() { | |
try { | |
console.log('初始化資料...'); | |
fs.mkdirpSync('data'); | |
const changedInfoFiles = await getChangedFiles(); | |
if (changedInfoFiles.length === 0) { | |
console.log('No plugin info files were changed in this PR'); | |
process.exit(0); | |
} | |
const statsFile = 'data/repository_stats.json'; | |
let repositoryStats = []; | |
if (fs.existsSync(statsFile)) { | |
repositoryStats = JSON.parse(fs.readFileSync(statsFile, 'utf8')); | |
} | |
repositoryStats = await processPluginFiles(repositoryStats, changedInfoFiles); | |
repositoryStats.sort((a, b) => (b.updated_at || '').localeCompare(a.updated_at || '')); | |
fs.writeFileSync(statsFile, JSON.stringify(repositoryStats, null, 0)); | |
console.log('數據更新完成!'); | |
} catch (err) { | |
console.error(`發生錯誤: ${err.message}`); | |
process.exit(1); | |
} | |
} | |
main(); | |
EOL | |
node fetch_stats.js | |
- name: Commit and push changes | |
run: | | |
git config --local user.email "41898282+github-actions[bot]@users.noreply.github.com" | |
git config --local user.name "github-actions[bot]" | |
git add data/repository_stats.json | |
git diff --quiet && git diff --staged --quiet || (git commit -m "Update repository statistics [skip ci]" && git push) |