218 lines
6.2 KiB
PHP
218 lines
6.2 KiB
PHP
|
|
<?php
|
||
|
|
|
||
|
|
namespace App\Services;
|
||
|
|
|
||
|
|
use Illuminate\Support\Facades\File;
|
||
|
|
use Exception;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Service to handle Theme Editor operations.
|
||
|
|
*/
|
||
|
|
class ThemeEditorService
|
||
|
|
{
|
||
|
|
/**
|
||
|
|
* @var array List of allowed file extensions for editing.
|
||
|
|
*/
|
||
|
|
protected array $allowedExtensions = ['php', 'css', 'js', 'md', 'json', 'txt', 'blade.php'];
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get the file tree for a theme.
|
||
|
|
*
|
||
|
|
* @param string $theme The theme slug.
|
||
|
|
* @return array The hierarchical file tree.
|
||
|
|
* @throws Exception If the theme is not found.
|
||
|
|
*/
|
||
|
|
public function getFileTree(string $theme): array
|
||
|
|
{
|
||
|
|
$basePath = base_path('themes/' . $theme);
|
||
|
|
|
||
|
|
if (!File::exists($basePath)) {
|
||
|
|
throw new Exception('Theme not found');
|
||
|
|
}
|
||
|
|
|
||
|
|
return $this->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');
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|