feat: private/custom git repos - GitHub, GitLab, Bitbucket, custom

- Add repository URL validation for GitHub, GitLab, Bitbucket, and custom hosts
- Add git provider layer (listDirectory, downloadRawFile) for all providers
- Wire githubJsonService and scriptDownloader to use provider; sync/download from any supported source
- Update GeneralSettingsModal placeholder and help text; .env.example and env schema for GITLAB_TOKEN, BITBUCKET_APP_PASSWORD

Closes #406
This commit is contained in:
Michel Rögl-Brunner
2026-01-29 13:08:28 +01:00
parent 54b2187f98
commit a56c625b4f
17 changed files with 408 additions and 220 deletions

View File

@@ -2,6 +2,7 @@
import { writeFile, mkdir, readdir, readFile } from 'fs/promises';
import { join } from 'path';
import { repositoryService } from './repositoryService.js';
import { listDirectory, downloadRawFile } from '../lib/gitProvider/index.js';
// Get environment variables
const getEnv = () => ({
@@ -28,76 +29,9 @@ class GitHubJsonService {
}
}
getBaseUrl(repoUrl) {
const urlMatch = /github\.com\/([^\/]+)\/([^\/]+)/.exec(repoUrl);
if (!urlMatch) {
throw new Error(`Invalid GitHub repository URL: ${repoUrl}`);
}
const [, owner, repo] = urlMatch;
return `https://api.github.com/repos/${owner}/${repo}`;
}
extractRepoPath(repoUrl) {
const match = /github\.com\/([^\/]+)\/([^\/]+)/.exec(repoUrl);
if (!match) {
throw new Error('Invalid GitHub repository URL');
}
return `${match[1]}/${match[2]}`;
}
async fetchFromGitHub(repoUrl, endpoint) {
const baseUrl = this.getBaseUrl(repoUrl);
const env = getEnv();
const headers = {
'Accept': 'application/vnd.github.v3+json',
'User-Agent': 'PVEScripts-Local/1.0',
};
if (env.GITHUB_TOKEN) {
headers.Authorization = `token ${env.GITHUB_TOKEN}`;
}
const response = await fetch(`${baseUrl}${endpoint}`, { headers });
if (!response.ok) {
if (response.status === 403) {
const error = new Error(`GitHub API rate limit exceeded. Consider setting GITHUB_TOKEN for higher limits. Status: ${response.status} ${response.statusText}`);
error.name = 'RateLimitError';
throw error;
}
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
}
return response.json();
}
async downloadJsonFile(repoUrl, filePath) {
this.initializeConfig();
const repoPath = this.extractRepoPath(repoUrl);
const rawUrl = `https://raw.githubusercontent.com/${repoPath}/${this.branch}/${filePath}`;
const env = getEnv();
const headers = {
'User-Agent': 'PVEScripts-Local/1.0',
};
if (env.GITHUB_TOKEN) {
headers.Authorization = `token ${env.GITHUB_TOKEN}`;
}
const response = await fetch(rawUrl, { headers });
if (!response.ok) {
if (response.status === 403) {
const error = new Error(`GitHub rate limit exceeded while downloading ${filePath}. Consider setting GITHUB_TOKEN for higher limits.`);
error.name = 'RateLimitError';
throw error;
}
throw new Error(`Failed to download ${filePath}: ${response.status} ${response.statusText}`);
}
const content = await response.text();
const content = await downloadRawFile(repoUrl, filePath, this.branch);
const script = JSON.parse(content);
script.repository_url = repoUrl;
return script;
@@ -105,16 +39,13 @@ class GitHubJsonService {
async getJsonFiles(repoUrl) {
this.initializeConfig();
try {
const files = await this.fetchFromGitHub(
repoUrl,
`/contents/${this.jsonFolder}?ref=${this.branch}`
);
return files.filter(file => file.name.endsWith('.json'));
const entries = await listDirectory(repoUrl, this.jsonFolder, this.branch);
return entries
.filter((e) => e.type === 'file' && e.name.endsWith('.json'))
.map((e) => ({ name: e.name, path: e.path }));
} catch (error) {
console.error(`Error fetching JSON files from GitHub (${repoUrl}):`, error);
console.error(`Error fetching JSON files from repository (${repoUrl}):`, error);
throw new Error(`Failed to fetch script files from repository: ${repoUrl}`);
}
}