Skip to content

Commit 59e82ea

Browse files
committed
[Added] Server Side Theme Builder
1 parent f2a2c13 commit 59e82ea

14 files changed

+947
-647
lines changed

src/Commands/BuildTheme.php

+52
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
<?php
2+
3+
namespace Coderstm\Commands;
4+
5+
use Coderstm\Services\Helpers;
6+
use Illuminate\Console\Command;
7+
8+
class BuildTheme extends Command
9+
{
10+
// The name and signature of the console command
11+
protected $signature = 'coderstm:theme-build {name}';
12+
13+
// The console command description
14+
protected $description = 'Build a theme using npm run theme:build --name={theme-name}';
15+
16+
/**
17+
* Execute the console command.
18+
*
19+
* @return int
20+
*/
21+
public function handle()
22+
{
23+
// Check if npm is installed and the test command can be run
24+
Helpers::checkNpmInstallation();
25+
26+
// Get the theme name from the command argument
27+
$themeName = $this->argument('name');
28+
29+
// Run the npm command to build the theme
30+
$npmBuildCommand = "npm run theme:build --name={$themeName}";
31+
32+
$output = null;
33+
$resultCode = null;
34+
35+
// Execute the npm build command
36+
exec($npmBuildCommand, $output, $resultCode);
37+
38+
// Output npm response
39+
foreach ($output as $line) {
40+
$this->line($line);
41+
}
42+
43+
// Check if the command was successful
44+
if ($resultCode === 0) {
45+
$this->info("Theme '{$themeName}' built successfully!");
46+
} else {
47+
$this->error("Failed to build the theme '{$themeName}'. Please check the npm output.");
48+
}
49+
50+
return $resultCode;
51+
}
52+
}
+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?php
2+
3+
namespace Coderstm\Exceptions;
4+
5+
use Exception;
6+
7+
class NpmNotFoundException extends Exception
8+
{
9+
public function __construct($message = null)
10+
{
11+
parent::__construct($message ?? 'Npm is not installed on the server. Please install npm and try again.');
12+
}
13+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?php
2+
3+
namespace Coderstm\Exceptions;
4+
5+
use Exception;
6+
7+
class NpmNotInstalledException extends Exception
8+
{
9+
public function __construct($message = null)
10+
{
11+
parent::__construct($message ?? 'Npm is installed, but the test command failed. Make sure to run "npm install" in the project root.');
12+
}
13+
}

src/Http/Controllers/ThemeController.php

+101-63
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,30 @@
22

33
namespace Coderstm\Http\Controllers;
44

5-
use Coderstm\Models\AppSetting;
65
use Illuminate\Support\Str;
76
use Coderstm\Services\Theme;
87
use Illuminate\Http\Request;
9-
use Spatie\Browsershot\Browsershot;
8+
use Coderstm\Jobs\BuildTheme;
9+
use Coderstm\Services\Helpers;
10+
use Coderstm\Models\AppSetting;
1011
use Illuminate\Support\Facades\File;
12+
use Coderstm\Services\Theme\FileMeta;
1113
use Illuminate\Support\Facades\Blade;
12-
use Illuminate\Support\Facades\Cache;
13-
use Illuminate\Support\Facades\Response;
1414

1515
class ThemeController extends Controller
1616
{
17+
protected $basePath;
18+
19+
public function __construct()
20+
{
21+
try {
22+
Helpers::checkNpmInstallation();
23+
$this->basePath = null;
24+
} catch (\Exception $e) {
25+
$this->basePath = '/views';
26+
}
27+
}
28+
1729
// List all themes
1830
public function index()
1931
{
@@ -73,6 +85,9 @@ public function destroy($theme)
7385
// Clone a theme
7486
public function clone($theme)
7587
{
88+
// Check if npm is installed and the test command can be run
89+
Helpers::checkNpmInstallation();
90+
7691
$config = Theme::config($theme);
7792
$newThemeName = $config['name'] . ' (Copy)';
7893
$newThemeKey = Str::slug($newThemeName);
@@ -91,7 +106,10 @@ public function clone($theme)
91106

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

94-
return response()->json(['message' => 'Theme cloned successfully'], 200);
109+
// Dispatch the theme build job to the queue
110+
BuildTheme::dispatch($newThemeKey);
111+
112+
return response()->json(['message' => 'Theme cloned successfully, theme build queued.'], 200);
95113
}
96114

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

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

109-
$fileTree = $this->getDirectoryStructure($themePath, $themePath);
127+
$fileTree = $this->getDirectoryStructure($themePath);
110128
$themeInfo = $this->info($theme);
111129

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

118136
// Recursive function to build file and folder structure
119-
private function getDirectoryStructure($directory, $basepath)
137+
private function getDirectoryStructure($directory, $basepath = null)
120138
{
121139
$items = [];
140+
$basepath = $basepath ?? $directory;
122141

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

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

147164
// Add files to the structure
148165
foreach ($files as $file) {
149-
$fileName = basename($file->getPathname());
150-
151-
// Exclude the 'preview.png' file
152-
if ($fileName === 'preview.png') {
153-
continue; // Skip 'preview.png'
154-
}
155-
156-
$items[] = [
157-
'name' => $fileName,
158-
'basepath' => str_replace($basepath . '/', '', $file->getPathname()),
159-
'icon' => 'fas fa-code',
160-
'modified_at' => date('Y-m-d H:i:s', filemtime($file)),
161-
'header' => 'file'
162-
];
166+
$items[] = (new FileMeta($file, $basepath))->toArray();
163167
}
164168

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

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

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

209+
if (Str::startsWith($filePath, 'assets')) {
210+
BuildTheme::dispatch($theme);
211+
}
212+
205213
return response()->json(['message' => 'File saved successfully'], 200);
206214
}
207215

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

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

221229

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

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

228236
// Check if the file already exists
229237
if (File::exists($filePath)) {
@@ -251,7 +259,7 @@ public function createFile(Request $request, $theme)
251259
'message' => 'File created successfully',
252260
'file' => [
253261
'name' => $fileName,
254-
'basepath' => str_replace(Theme::basePath('views', $theme) . '/', '', $filePath),
262+
'basepath' => str_replace(Theme::basePath($this->basePath, $theme) . '/', '', $filePath),
255263
'icon' => 'fas fa-code',
256264
'header' => 'file'
257265
]
@@ -265,15 +273,15 @@ public function destroyThemeFile(Request $request, $theme)
265273
]);
266274

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

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

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

@@ -286,6 +294,65 @@ public function destroyThemeFile(Request $request, $theme)
286294
}
287295
}
288296

297+
public function assetsUpload(Request $request, $theme)
298+
{
299+
$request->validate([
300+
'media' => [
301+
'required',
302+
'mimetypes:image/jpeg,image/png,image/gif',
303+
'max:300', // 300 KB size limit
304+
],
305+
], [
306+
'media.required' => 'Please select an image to upload.',
307+
'media.mimetypes' => 'The file must be an image (JPEG, PNG, GIF).',
308+
'media.max' => 'The file must not be larger than 300 KB.',
309+
]);
310+
311+
// Define the directory path for the theme assets
312+
$fileName = $request->file('media')->getClientOriginalName();
313+
$filePath = Theme::basePath("{$this->basePath}assets/img/$fileName", $theme);
314+
315+
// Check if the file already exists
316+
if (File::exists($filePath)) {
317+
return response()->json(['message' => 'File already exists'], 422);
318+
}
319+
320+
// Move the uploaded file to the designated path
321+
$request->file('media')->move(dirname($filePath), $fileName);
322+
323+
return response()->json([
324+
'name' => $fileName,
325+
'basepath' => str_replace(Theme::basePath($this->basePath, $theme) . '/', '', $filePath),
326+
'icon' => 'fas fa-image',
327+
'header' => 'file'
328+
], 201);
329+
}
330+
331+
public function assets(Request $request, $theme)
332+
{
333+
// Sanitize the path to prevent directory traversal
334+
$path = str_replace(['..', './', '\\'], '', $request->input('path')); // Remove directory traversal sequences
335+
336+
if (Str::endsWith($path, '.php')) {
337+
abort(404);
338+
}
339+
340+
// Generate the full file path for the theme
341+
$filePath = Theme::basePath($path, $theme);
342+
343+
// Use realpath to get the absolute path and ensure it's within the allowed directories
344+
$realFilePath = realpath($filePath);
345+
346+
// Check if the real path is valid and within the intended theme directories
347+
if ($realFilePath && File::exists($realFilePath)) {
348+
// Return the file with headers
349+
return response()->file($realFilePath);
350+
}
351+
352+
// Abort with a 404 if the file is not found or invalid
353+
abort(404);
354+
}
355+
289356
// Show selected theme details
290357
protected function info($theme)
291358
{
@@ -304,33 +371,4 @@ protected function info($theme)
304371

305372
return null;
306373
}
307-
308-
public function preview($theme)
309-
{
310-
// Check if the preview image is cached
311-
$cacheKey = 'theme_preview_' . $theme;
312-
if (Cache::has($cacheKey)) {
313-
$cachedImagePath = Cache::get($cacheKey);
314-
return Response::file($cachedImagePath);
315-
}
316-
317-
// Generate the preview image
318-
$imagePath = Theme::basePath('preview.png', $theme);
319-
320-
try {
321-
// Capture homepage as a PNG using Browsershot
322-
Browsershot::url(route('home', ['theme' => $theme]))
323-
->setScreenshotType('jpeg', 50)
324-
->windowSize(1440, 792)
325-
->save($imagePath);
326-
327-
// Cache the image path for 12 hours
328-
Cache::put($cacheKey, $imagePath, 180 * 60 * 4); // Cache for 12 hours
329-
} catch (\Exception $e) {
330-
return response('Error generating preview', 404);
331-
}
332-
333-
// Return the generated image
334-
return Response::file($imagePath);
335-
}
336374
}

0 commit comments

Comments
 (0)