scanDirectory($basePath, $basePath); } /** * Read the content of a theme file. * * @param string $theme The theme slug. * @param string $path The relative path to the file. * @return string The file content. * @throws Exception If the file is not found or is a directory. */ public function readFile(string $theme, string $path): string { $fullPath = $this->getSafePath($theme, $path); if (!File::exists($fullPath)) { throw new Exception('File not found'); } if (File::isDirectory($fullPath)) { throw new Exception('Cannot read a directory'); } return File::get($fullPath); } /** * Save content to a theme file. * * @param string $theme The theme slug. * @param string $path The relative path to the file. * @param string $content The new content to save. * @return bool True if successful. * @throws Exception If the file is not found or has an invalid extension. */ public function saveFile(string $theme, string $path, string $content): bool { $fullPath = $this->getSafePath($theme, $path); if (!File::exists($fullPath)) { throw new Exception('File not found'); } $this->validateExtension($fullPath); // Create .bak on first save if it doesn't exist $bakPath = $fullPath . '.bak'; if (!File::exists($bakPath)) { File::copy($fullPath, $bakPath); } File::put($fullPath, $content); return true; } /** * Create a new file in a theme. * * @param string $theme The theme slug. * @param string|null $path The relative path to the parent directory. * @param string $filename The name of the new file. * @return bool True if successful. * @throws Exception If the path is invalid or file exists. */ public function createFile(string $theme, ?string $path, string $filename): bool { $basePath = realpath(base_path('themes/' . $theme)); if (!$basePath) { throw new Exception('Theme not found'); } $targetDir = $basePath; if ($path) { $targetDir = realpath($basePath . '/' . $path); if (!$targetDir || !str_starts_with($targetDir, $basePath)) { throw new Exception('Invalid path'); } } // Sanitize filename if (strpos($filename, '..') !== false || strpos($filename, '/') !== false || strpos($filename, '\\') !== false) { throw new Exception('Invalid filename'); } $fullPath = $targetDir . '/' . $filename; if (File::exists($fullPath)) { throw new Exception('File already exists'); } $this->validateExtension($fullPath); File::put($fullPath, ''); return true; } /** * Recursively scan a directory to build a file tree. * * @param string $path The directory path to scan. * @param string $basePath The base theme path for calculating relative paths. * @return array The scanned tree node. */ protected function scanDirectory(string $path, string $basePath): array { $results = []; $items = scandir($path); foreach ($items as $item) { if ($item === '.' || $item === '..' || str_starts_with($item, '.')) { continue; } if (str_ends_with($item, '.bak')) { continue; } $fullPath = $path . '/' . $item; $relativePath = str_replace($basePath . DIRECTORY_SEPARATOR, '', $fullPath); $node = [ 'name' => $item, 'path' => $relativePath, 'is_dir' => is_dir($fullPath), 'type' => is_dir($fullPath) ? 'directory' : 'file', ]; if ($node['is_dir']) { $node['children'] = $this->scanDirectory($fullPath, $basePath); } $results[] = $node; } return $results; } /** * Ensure the path is within the theme directory and return the real path. * * @param string $theme The theme slug. * @param string $path The relative path. * @return string The absolute, safe path. * @throws Exception If the path is outside the theme directory. */ protected function getSafePath(string $theme, string $path): string { $basePath = realpath(base_path('themes/' . $theme)); if (!$basePath) { throw new Exception('Theme not found'); } $fullPath = realpath($basePath . '/' . $path); if (!$fullPath || !str_starts_with($fullPath, $basePath)) { throw new Exception('Unauthorized access or invalid path'); } return $fullPath; } /** * Validate the file extension against the allowed list. * * @param string $path The file path to validate. * @return void * @throws Exception If the extension is not allowed. */ protected function validateExtension(string $path): void { $filename = basename($path); // Special case for blade.php if (str_ends_with($filename, '.blade.php')) { return; } $extension = File::extension($path); if (!in_array($extension, $this->allowedExtensions)) { throw new Exception('Invalid file extension'); } } }