mirror of
https://github.com/community-scripts/ProxmoxVE-Local.git
synced 2026-03-31 06:23:54 -04:00
* feat: improve button layout and UI organization (#35) - Reorganize control buttons into a structured container with proper spacing - Add responsive design for mobile and desktop layouts - Improve SettingsButton and ResyncButton component structure - Enhance visual hierarchy with better typography and spacing - Add background container with shadow and border for better grouping - Make layout responsive with proper flexbox arrangements * Add category sidebar and filtering to scripts grid (#36) * Add category sidebar and filtering to scripts grid Introduces a CategorySidebar component with icon mapping and category selection. Updates metadata.json to include icons for each category. Enhances ScriptsGrid to support category-based filtering and integrates the sidebar, improving script navigation and discoverability. Also refines ScriptDetailModal layout for better modal presentation. * Add category metadata to scripts and improve filtering Introduces category metadata loading and exposes it via new API endpoints. Script cards are now enhanced with category information, allowing for accurate category-based filtering and counting in the ScriptsGrid component. Removes hardcoded category logic and replaces it with dynamic data from metadata.json. * Add reusable Badge component and refactor badge usage (#37) Introduces a new Badge component with variants for type, updateable, privileged, status, execution mode, and note. Refactors ScriptCard, ScriptDetailModal, and InstalledScriptsTab to use the new Badge components, improving consistency and maintainability. Also updates DarkModeProvider and layout.tsx for better dark mode handling and fallback. * Add advanced filtering and sorting to ScriptsGrid (#38) Introduces a new FilterBar component for ScriptsGrid, enabling filtering by search query, updatable status, script types, and sorting by name or creation date. Updates scripts API to include creation date in card data, improves deduplication and category counting logic, and adds error handling for missing script directories. * refactore installed scipts tab (#41) * feat: Add inline editing and manual script entry functionality - Add inline editing for script names and container IDs in installed scripts table - Add manual script entry form for pre-installed containers - Update database and API to support script_name editing - Improve dark mode hover effects for table rows - Add form validation and error handling - Support both local and SSH execution modes for manual entries * feat: implement installed scripts functionality and clean up test files - Add installed scripts tab with filtering and execution capabilities - Update scripts grid with better type safety and error handling - Remove outdated test files and update test configuration - Fix TypeScript and ESLint issues in components - Update .gitattributes for proper line ending handling * fix: resolve TypeScript error with categoryNames type mismatch - Fixed categoryNames type from (string | undefined)[] to string[] in scripts router - Added proper type filtering and assertion in getScriptCardsWithCategories - Added missing ScriptCard import in scripts router - Ensures type safety for categoryNames property throughout the application --------- Co-authored-by: CanbiZ <47820557+MickLesk@users.noreply.github.com>
301 lines
8.6 KiB
TypeScript
301 lines
8.6 KiB
TypeScript
import { readdir, stat, readFile } from 'fs/promises';
|
|
import { join, resolve, extname } from 'path';
|
|
import { env } from '~/env.js';
|
|
import { spawn, type ChildProcess } from 'child_process';
|
|
import { localScriptsService } from '~/server/services/localScripts';
|
|
|
|
export interface ScriptInfo {
|
|
name: string;
|
|
path: string;
|
|
extension: string;
|
|
size: number;
|
|
lastModified: Date;
|
|
executable: boolean;
|
|
logo?: string;
|
|
slug?: string;
|
|
}
|
|
|
|
export class ScriptManager {
|
|
private scriptsDir: string | null = null;
|
|
private allowedExtensions: string[] | null = null;
|
|
private allowedPaths: string[] | null = null;
|
|
private maxExecutionTime: number | null = null;
|
|
|
|
constructor() {
|
|
// Initialize lazily to avoid accessing env vars during module load
|
|
}
|
|
|
|
private initializeConfig() {
|
|
if (this.scriptsDir === null) {
|
|
// Handle both absolute and relative paths for testing
|
|
this.scriptsDir = env.SCRIPTS_DIRECTORY.startsWith('/')
|
|
? env.SCRIPTS_DIRECTORY
|
|
: join(process.cwd(), env.SCRIPTS_DIRECTORY);
|
|
this.allowedExtensions = env.ALLOWED_SCRIPT_EXTENSIONS.split(',').map(ext => ext.trim());
|
|
this.allowedPaths = env.ALLOWED_SCRIPT_PATHS.split(',').map(path => path.trim());
|
|
this.maxExecutionTime = parseInt(env.MAX_SCRIPT_EXECUTION_TIME, 10);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get all available scripts in the scripts directory
|
|
*/
|
|
async getScripts(): Promise<ScriptInfo[]> {
|
|
this.initializeConfig();
|
|
try {
|
|
const files = await readdir(this.scriptsDir!);
|
|
const scripts: ScriptInfo[] = [];
|
|
|
|
for (const file of files) {
|
|
const filePath = join(this.scriptsDir!, file);
|
|
const stats = await stat(filePath);
|
|
|
|
if (stats.isFile()) {
|
|
const extension = extname(file);
|
|
|
|
// Check if file extension is allowed
|
|
if (this.allowedExtensions!.includes(extension)) {
|
|
// Check if file is executable
|
|
const executable = await this.isExecutable(filePath);
|
|
|
|
scripts.push({
|
|
name: file,
|
|
path: filePath,
|
|
extension,
|
|
size: stats.size,
|
|
lastModified: stats.mtime,
|
|
executable
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return scripts.sort((a, b) => a.name.localeCompare(b.name));
|
|
} catch (error) {
|
|
console.error('Error reading scripts directory:', error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get all available scripts in the ct subdirectory
|
|
*/
|
|
async getCtScripts(): Promise<ScriptInfo[]> {
|
|
this.initializeConfig();
|
|
try {
|
|
const ctDir = join(this.scriptsDir!, 'ct');
|
|
|
|
// Check if ct directory exists
|
|
try {
|
|
await stat(ctDir);
|
|
} catch {
|
|
console.warn(`CT scripts directory not found: ${ctDir}`);
|
|
return [];
|
|
}
|
|
|
|
const files = await readdir(ctDir);
|
|
const scripts: ScriptInfo[] = [];
|
|
|
|
for (const file of files) {
|
|
const filePath = join(ctDir, file);
|
|
const stats = await stat(filePath);
|
|
|
|
if (stats.isFile()) {
|
|
const extension = extname(file);
|
|
|
|
// Check if file extension is allowed
|
|
if (this.allowedExtensions!.includes(extension)) {
|
|
// Check if file is executable
|
|
const executable = await this.isExecutable(filePath);
|
|
|
|
// Extract slug from filename (remove .sh extension)
|
|
const slug = file.replace(/\.sh$/, '');
|
|
|
|
// Try to get logo from JSON data
|
|
let logo: string | undefined;
|
|
try {
|
|
const scriptData = await localScriptsService.getScriptBySlug(slug);
|
|
logo = scriptData?.logo ?? undefined;
|
|
} catch {
|
|
// JSON file might not exist, that's okay
|
|
}
|
|
|
|
scripts.push({
|
|
name: file,
|
|
path: filePath,
|
|
extension,
|
|
size: stats.size,
|
|
lastModified: stats.mtime,
|
|
executable,
|
|
logo,
|
|
slug
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return scripts.sort((a, b) => a.name.localeCompare(b.name));
|
|
} catch (error) {
|
|
console.error('Error reading ct scripts directory:', error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if a file is executable
|
|
*/
|
|
private async isExecutable(filePath: string): Promise<boolean> {
|
|
try {
|
|
const stats = await stat(filePath);
|
|
// Check if file has execute permission for owner, group, or others
|
|
const mode = stats.mode;
|
|
const isExecutable = !!(mode & parseInt('111', 8));
|
|
return isExecutable;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate if a script path is allowed to be executed
|
|
*/
|
|
validateScriptPath(scriptPath: string): { valid: boolean; message?: string } {
|
|
this.initializeConfig();
|
|
const resolvedPath = resolve(scriptPath);
|
|
const scriptsDirResolved = resolve(this.scriptsDir!);
|
|
|
|
// Check if the script is within the allowed directory
|
|
if (!resolvedPath.startsWith(scriptsDirResolved)) {
|
|
return {
|
|
valid: false,
|
|
message: 'Script path is not within the allowed scripts directory'
|
|
};
|
|
}
|
|
|
|
// Check if the script path matches any allowed path pattern
|
|
const relativePath = resolvedPath.replace(scriptsDirResolved, '').replace(/\\/g, '/');
|
|
const normalizedRelativePath = relativePath.startsWith('/') ? relativePath : '/' + relativePath;
|
|
|
|
const isAllowed = this.allowedPaths!.some(allowedPath => {
|
|
const normalizedAllowedPath = allowedPath.startsWith('/') ? allowedPath : '/' + allowedPath;
|
|
// For root path '/', allow files directly in the scripts directory (no subdirectories)
|
|
if (normalizedAllowedPath === '/') {
|
|
return normalizedRelativePath === '/' || (normalizedRelativePath.startsWith('/') && !normalizedRelativePath.substring(1).includes('/'));
|
|
}
|
|
// For other paths like '/ct/', check if the path starts with it
|
|
return normalizedRelativePath.startsWith(normalizedAllowedPath);
|
|
});
|
|
|
|
if (!isAllowed) {
|
|
return {
|
|
valid: false,
|
|
message: 'Script path is not in the allowed paths list'
|
|
};
|
|
}
|
|
|
|
// Check file extension
|
|
const extension = extname(scriptPath);
|
|
if (!this.allowedExtensions!.includes(extension)) {
|
|
return {
|
|
valid: false,
|
|
message: `File extension '${extension}' is not allowed. Allowed extensions: ${this.allowedExtensions!.join(', ')}`
|
|
};
|
|
}
|
|
|
|
return { valid: true };
|
|
}
|
|
|
|
/**
|
|
* Execute a script and return a child process
|
|
*/
|
|
async executeScript(scriptPath: string): Promise<ChildProcess> {
|
|
const validation = this.validateScriptPath(scriptPath);
|
|
if (!validation.valid) {
|
|
throw new Error(validation.message);
|
|
}
|
|
|
|
// Determine the command to run based on file extension
|
|
const extension = extname(scriptPath);
|
|
let command: string;
|
|
let args: string[] = [];
|
|
|
|
switch (extension) {
|
|
case '.sh':
|
|
case '.bash':
|
|
command = 'bash';
|
|
args = [scriptPath];
|
|
break;
|
|
case '.py':
|
|
command = 'python';
|
|
args = [scriptPath];
|
|
break;
|
|
case '.js':
|
|
command = 'node';
|
|
args = [scriptPath];
|
|
break;
|
|
case '.ts':
|
|
command = 'npx';
|
|
args = ['ts-node', scriptPath];
|
|
break;
|
|
default:
|
|
// Try to execute directly (for files with shebang)
|
|
command = scriptPath;
|
|
args = [];
|
|
}
|
|
|
|
// Spawn the process
|
|
const childProcess = spawn(command, args, {
|
|
cwd: this.scriptsDir!,
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
shell: true
|
|
});
|
|
|
|
// Set up timeout
|
|
const timeout = setTimeout(() => {
|
|
if (!childProcess.killed) {
|
|
childProcess.kill('SIGTERM');
|
|
}
|
|
}, this.maxExecutionTime!);
|
|
|
|
// Clean up timeout when process exits
|
|
childProcess.on('exit', () => {
|
|
clearTimeout(timeout);
|
|
});
|
|
|
|
return childProcess;
|
|
}
|
|
|
|
/**
|
|
* Get script content for display
|
|
*/
|
|
async getScriptContent(scriptPath: string): Promise<string> {
|
|
const validation = this.validateScriptPath(scriptPath);
|
|
if (!validation.valid) {
|
|
throw new Error(validation.message);
|
|
}
|
|
|
|
return await readFile(scriptPath, 'utf-8');
|
|
}
|
|
|
|
/**
|
|
* Get scripts directory information
|
|
*/
|
|
getScriptsDirectoryInfo(): {
|
|
path: string;
|
|
allowedExtensions: string[];
|
|
allowedPaths: string[];
|
|
maxExecutionTime: number;
|
|
} {
|
|
this.initializeConfig();
|
|
return {
|
|
path: this.scriptsDir!,
|
|
allowedExtensions: this.allowedExtensions!,
|
|
allowedPaths: this.allowedPaths!,
|
|
maxExecutionTime: this.maxExecutionTime!
|
|
};
|
|
}
|
|
}
|
|
|
|
// Export singleton instance
|
|
export const scriptManager = new ScriptManager();
|