Skip to content

Commit

Permalink
[Added] Server Side Theme Builder
Browse files Browse the repository at this point in the history
  • Loading branch information
dipaksarkar committed Oct 5, 2024
1 parent f2a2c13 commit 59e82ea
Show file tree
Hide file tree
Showing 14 changed files with 947 additions and 647 deletions.
52 changes: 52 additions & 0 deletions src/Commands/BuildTheme.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<?php

namespace Coderstm\Commands;

use Coderstm\Services\Helpers;
use Illuminate\Console\Command;

class BuildTheme extends Command
{
// The name and signature of the console command
protected $signature = 'coderstm:theme-build {name}';

// The console command description
protected $description = 'Build a theme using npm run theme:build --name={theme-name}';

/**
* Execute the console command.
*
* @return int
*/
public function handle()
{
// Check if npm is installed and the test command can be run
Helpers::checkNpmInstallation();

// Get the theme name from the command argument
$themeName = $this->argument('name');

// Run the npm command to build the theme
$npmBuildCommand = "npm run theme:build --name={$themeName}";

$output = null;
$resultCode = null;

// Execute the npm build command
exec($npmBuildCommand, $output, $resultCode);

// Output npm response
foreach ($output as $line) {
$this->line($line);
}

// Check if the command was successful
if ($resultCode === 0) {
$this->info("Theme '{$themeName}' built successfully!");
} else {
$this->error("Failed to build the theme '{$themeName}'. Please check the npm output.");
}

return $resultCode;
}
}
13 changes: 13 additions & 0 deletions src/Exceptions/NpmNotFoundException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

namespace Coderstm\Exceptions;

use Exception;

class NpmNotFoundException extends Exception
{
public function __construct($message = null)
{
parent::__construct($message ?? 'Npm is not installed on the server. Please install npm and try again.');
}
}
13 changes: 13 additions & 0 deletions src/Exceptions/NpmNotInstalledException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

namespace Coderstm\Exceptions;

use Exception;

class NpmNotInstalledException extends Exception
{
public function __construct($message = null)
{
parent::__construct($message ?? 'Npm is installed, but the test command failed. Make sure to run "npm install" in the project root.');
}
}
164 changes: 101 additions & 63 deletions src/Http/Controllers/ThemeController.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,30 @@

namespace Coderstm\Http\Controllers;

use Coderstm\Models\AppSetting;
use Illuminate\Support\Str;
use Coderstm\Services\Theme;
use Illuminate\Http\Request;
use Spatie\Browsershot\Browsershot;
use Coderstm\Jobs\BuildTheme;
use Coderstm\Services\Helpers;
use Coderstm\Models\AppSetting;
use Illuminate\Support\Facades\File;
use Coderstm\Services\Theme\FileMeta;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Response;

class ThemeController extends Controller
{
protected $basePath;

public function __construct()
{
try {
Helpers::checkNpmInstallation();
$this->basePath = null;
} catch (\Exception $e) {
$this->basePath = '/views';
}
}

// List all themes
public function index()
{
Expand Down Expand Up @@ -73,6 +85,9 @@ public function destroy($theme)
// Clone a theme
public function clone($theme)
{
// Check if npm is installed and the test command can be run
Helpers::checkNpmInstallation();

$config = Theme::config($theme);
$newThemeName = $config['name'] . ' (Copy)';
$newThemeKey = Str::slug($newThemeName);
Expand All @@ -91,7 +106,10 @@ public function clone($theme)

File::put(Theme::basePath('config.json', $newThemeKey), json_encode($config, JSON_PRETTY_PRINT));

return response()->json(['message' => 'Theme cloned successfully'], 200);
// Dispatch the theme build job to the queue
BuildTheme::dispatch($newThemeKey);

return response()->json(['message' => 'Theme cloned successfully, theme build queued.'], 200);
}

return response()->json(['message' => 'Theme not found or new theme already exists'], 404);
Expand All @@ -100,13 +118,13 @@ public function clone($theme)
// Get the list of files and directories in a theme with `basepath` for the editor
public function getFiles($theme)
{
$themePath = Theme::basePath('views', $theme);
$themePath = Theme::basePath($this->basePath, $theme);

if (!File::exists($themePath)) {
return response()->json(['message' => 'Theme not found'], 404);
}

$fileTree = $this->getDirectoryStructure($themePath, $themePath);
$fileTree = $this->getDirectoryStructure($themePath);
$themeInfo = $this->info($theme);

return response()->json([
Expand All @@ -116,9 +134,10 @@ public function getFiles($theme)
}

// Recursive function to build file and folder structure
private function getDirectoryStructure($directory, $basepath)
private function getDirectoryStructure($directory, $basepath = null)
{
$items = [];
$basepath = $basepath ?? $directory;

// Get all directories and files in the current directory
$directories = File::directories($directory);
Expand All @@ -127,16 +146,14 @@ private function getDirectoryStructure($directory, $basepath)
// Add directories to the structure
foreach ($directories as $dir) {
$dirName = basename($dir);
$relativePath = str_replace($basepath . '/', '', $dir); // Get relative path

// Exclude the public directory
if (!in_array($dirName, ['public'])) {
// Recursively get directory structure while skipping 'public' and its children
$singular = Str::singular($dirName);
$singular = Helpers::singularizeDirectoryName($dirName);
$items[] = [
'name' => $dirName,
'ext' => '.blade.php',
'addLabel' => "Add a new $singular",
'basepath' => str_replace($basepath . '/', '', $dir),
'basepath' => $relativePath,
'header' => 'directory',
'modified_at' => date('Y-m-d H:i:s', filemtime($dir)),
'children' => $this->getDirectoryStructure($dir, $basepath)
Expand All @@ -146,20 +163,7 @@ private function getDirectoryStructure($directory, $basepath)

// Add files to the structure
foreach ($files as $file) {
$fileName = basename($file->getPathname());

// Exclude the 'preview.png' file
if ($fileName === 'preview.png') {
continue; // Skip 'preview.png'
}

$items[] = [
'name' => $fileName,
'basepath' => str_replace($basepath . '/', '', $file->getPathname()),
'icon' => 'fas fa-code',
'modified_at' => date('Y-m-d H:i:s', filemtime($file)),
'header' => 'file'
];
$items[] = (new FileMeta($file, $basepath))->toArray();
}

return $items;
Expand All @@ -168,7 +172,7 @@ private function getDirectoryStructure($directory, $basepath)
public function getFileContent($theme, Request $request)
{
$filePath = $request->input('key'); // The relative path of the selected file
$fullPath = realpath(Theme::basePath("views/$filePath", $theme));
$fullPath = realpath(Theme::basePath("{$this->basePath}/$filePath", $theme));

if (!$fullPath || !File::exists($fullPath)) {
return response()->json(['message' => 'File not found or invalid path'], 404);
Expand All @@ -188,7 +192,7 @@ public function saveFile(Request $request, $theme)
{
$filePath = $request->input('key');
$content = $request->input('content');
$themePath = Theme::basePath("views/$filePath", $theme);
$themePath = Theme::basePath("{$this->basePath}/$filePath", $theme);

// Validate Blade syntax (if it's a .blade.php file)
if (File::extension($filePath) === 'php') {
Expand All @@ -202,6 +206,10 @@ public function saveFile(Request $request, $theme)
// Save file content
File::put($themePath, $content);

if (Str::startsWith($filePath, 'assets')) {
BuildTheme::dispatch($theme);
}

return response()->json(['message' => 'File saved successfully'], 200);
}

Expand All @@ -216,14 +224,14 @@ public function createFile(Request $request, $theme)

$fileName = $request->input('name') . $request->ext;
$basepath = rtrim($request->input('basepath'), '/');
$themePath = Theme::basePath("views/$basepath", $theme);
$themePath = Theme::basePath("{$this->basePath}/$basepath", $theme);


if (!File::exists($themePath)) {
return response()->json(['message' => 'Theme not found'], 404);
}

$filePath = Theme::basePath("views/$basepath/$fileName", $theme);
$filePath = Theme::basePath("{$this->basePath}/$basepath/$fileName", $theme);

// Check if the file already exists
if (File::exists($filePath)) {
Expand Down Expand Up @@ -251,7 +259,7 @@ public function createFile(Request $request, $theme)
'message' => 'File created successfully',
'file' => [
'name' => $fileName,
'basepath' => str_replace(Theme::basePath('views', $theme) . '/', '', $filePath),
'basepath' => str_replace(Theme::basePath($this->basePath, $theme) . '/', '', $filePath),
'icon' => 'fas fa-code',
'header' => 'file'
]
Expand All @@ -265,15 +273,15 @@ public function destroyThemeFile(Request $request, $theme)
]);

$filePath = $request->input('key'); // The relative path of the selected file
$fullPath = realpath(Theme::basePath('views/' . $filePath, $theme));
$fullPath = realpath(Theme::basePath($this->basePath . '/' . $filePath, $theme));

// Check if the file exists
if (!$fullPath || !File::exists($fullPath)) {
return response()->json(['message' => 'File not found or invalid path'], 404);
}

// Prevent deletion of specific directories or files (like 'public' or 'preview.png')
if (str_contains($filePath, 'public') || str_contains($filePath, 'preview.png')) {
if (str_contains($filePath, 'public') || str_contains($filePath, 'preview.png') || str_contains($filePath, 'config.json')) {
return response()->json(['message' => 'This file or directory cannot be deleted'], 403);
}

Expand All @@ -286,6 +294,65 @@ public function destroyThemeFile(Request $request, $theme)
}
}

public function assetsUpload(Request $request, $theme)
{
$request->validate([
'media' => [
'required',
'mimetypes:image/jpeg,image/png,image/gif',
'max:300', // 300 KB size limit
],
], [
'media.required' => 'Please select an image to upload.',
'media.mimetypes' => 'The file must be an image (JPEG, PNG, GIF).',
'media.max' => 'The file must not be larger than 300 KB.',
]);

// Define the directory path for the theme assets
$fileName = $request->file('media')->getClientOriginalName();
$filePath = Theme::basePath("{$this->basePath}assets/img/$fileName", $theme);

// Check if the file already exists
if (File::exists($filePath)) {
return response()->json(['message' => 'File already exists'], 422);
}

// Move the uploaded file to the designated path
$request->file('media')->move(dirname($filePath), $fileName);

return response()->json([
'name' => $fileName,
'basepath' => str_replace(Theme::basePath($this->basePath, $theme) . '/', '', $filePath),
'icon' => 'fas fa-image',
'header' => 'file'
], 201);
}

public function assets(Request $request, $theme)
{
// Sanitize the path to prevent directory traversal
$path = str_replace(['..', './', '\\'], '', $request->input('path')); // Remove directory traversal sequences

if (Str::endsWith($path, '.php')) {
abort(404);
}

// Generate the full file path for the theme
$filePath = Theme::basePath($path, $theme);

// Use realpath to get the absolute path and ensure it's within the allowed directories
$realFilePath = realpath($filePath);

// Check if the real path is valid and within the intended theme directories
if ($realFilePath && File::exists($realFilePath)) {
// Return the file with headers
return response()->file($realFilePath);
}

// Abort with a 404 if the file is not found or invalid
abort(404);
}

// Show selected theme details
protected function info($theme)
{
Expand All @@ -304,33 +371,4 @@ protected function info($theme)

return null;
}

public function preview($theme)
{
// Check if the preview image is cached
$cacheKey = 'theme_preview_' . $theme;
if (Cache::has($cacheKey)) {
$cachedImagePath = Cache::get($cacheKey);
return Response::file($cachedImagePath);
}

// Generate the preview image
$imagePath = Theme::basePath('preview.png', $theme);

try {
// Capture homepage as a PNG using Browsershot
Browsershot::url(route('home', ['theme' => $theme]))
->setScreenshotType('jpeg', 50)
->windowSize(1440, 792)
->save($imagePath);

// Cache the image path for 12 hours
Cache::put($cacheKey, $imagePath, 180 * 60 * 4); // Cache for 12 hours
} catch (\Exception $e) {
return response('Error generating preview', 404);
}

// Return the generated image
return Response::file($imagePath);
}
}
Loading

0 comments on commit 59e82ea

Please sign in to comment.