cms/app/Services/ThemeEditorService.php
Funky Waddle 37ed997989 feat(cms): initialize Laravel project structure and core CMS files
- Added standard Laravel directory structure and configuration.

- Included Svelte and Tailwind configuration for the admin interface.

- Added core PHPUnit and testing scripts.
2026-04-13 12:48:06 -05:00

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');
}
}
}