Skip to content

Commit

Permalink
Merge pull request #1443 from hydephp/realtime-compiler-dashboard-imp…
Browse files Browse the repository at this point in the history
…rovements

Realtime compiler dashboard improvements
  • Loading branch information
caendesilva committed Nov 9, 2023
2 parents a60c42d + ef474e7 commit 85c0fe6
Show file tree
Hide file tree
Showing 2 changed files with 114 additions and 152 deletions.
262 changes: 113 additions & 149 deletions packages/realtime-compiler/src/Http/DashboardController.php
Expand Up @@ -11,6 +11,7 @@
use Illuminate\Support\Arr;
use Hyde\Pages\MarkdownPage;
use Hyde\Pages\MarkdownPost;
use Desilva\Microserve\Response;
use Hyde\Pages\Concerns\HydePage;
use Hyde\Pages\DocumentationPage;
use Hyde\Support\Models\RouteKey;
Expand All @@ -28,31 +29,6 @@
use Hyde\Framework\Actions\CreatesNewMarkdownPostFile;
use Symfony\Component\HttpKernel\Exception\HttpException;

use function e;
use function str;
use function time;
use function trim;
use function round;
use function rtrim;
use function strlen;
use function substr;
use function is_bool;
use function basename;
use function in_array;
use function json_decode;
use function json_encode;
use function substr_count;
use function array_combine;
use function trigger_error;
use function escapeshellarg;
use function file_get_contents;
use function str_starts_with;
use function str_replace;
use function array_merge;
use function sprintf;
use function config;
use function app;

/**
* @internal This class is not intended to be edited outside the Hyde Realtime Compiler.
*/
Expand All @@ -73,6 +49,8 @@ class DashboardController
'The dashboard update your project files. You can disable this by setting `server.dashboard.interactive` to `false` in `config/hyde.php`.',
];

protected JsonResponse $response;

public function __construct()
{
$this->title = config('hyde.name').' - Dashboard';
Expand All @@ -82,64 +60,60 @@ public function __construct()

if ($this->request->method === 'POST') {
$this->isAsync = (getallheaders()['X-RC-Handler'] ?? getallheaders()['x-rc-handler'] ?? null) === 'Async';
}
}

public function handle(): Response
{
if ($this->request->method === 'POST') {
if (! $this->isInteractive()) {
$this->abort(403, 'Enable `server.editor` in `config/hyde.php` to use interactive dashboard features.');
return $this->sendJsonErrorResponse(403, 'Enable `server.editor` in `config/hyde.php` to use interactive dashboard features.');
}

if ($this->shouldUnsafeRequestBeBlocked()) {
return $this->sendJsonErrorResponse(403, "Refusing to serve request from address {$_SERVER['REMOTE_ADDR']} (must be on localhost)");
}

try {
$this->blockUnsafeRequests();
$this->handlePostRequest();
return $this->handlePostRequest();
} catch (HttpException $exception) {
if (! $this->isAsync) {
throw $exception;
}

$this->sendJsonErrorResponse($exception);
return $this->sendJsonErrorResponse($exception->getStatusCode(), $exception->getMessage());
}
}
}

protected function handlePostRequest(): void
{
$actions = array_combine($actions = [
'openInExplorer',
'openPageInEditor',
'openMediaFileInEditor',
'createPage',
], $actions);

$action = $this->request->data['action'] ?? $this->abort(400, 'Must provide action');
$action = $actions[$action] ?? $this->abort(403, "Invalid action '$action'");

if ($action === 'openInExplorer') {
$this->openInExplorer();
}

if ($action === 'openPageInEditor') {
$routeKey = $this->request->data['routeKey'] ?? $this->abort(400, 'Must provide routeKey');
$page = Routes::getOrFail($routeKey)->getPage();
$this->openPageInEditor($page);
}

if ($action === 'openMediaFileInEditor') {
$identifier = $this->request->data['identifier'] ?? $this->abort(400, 'Must provide identifier');
$asset = @MediaFile::all()[$identifier] ?? $this->abort(404, "Invalid media identifier '$identifier'");
$this->openMediaFileInEditor($asset);
}

if ($action === 'createPage') {
$this->createPage();
}
return new HtmlResponse(200, 'OK', [
'body' => $this->show(),
]);
}

public function show(): string
protected function show(): string
{
return AnonymousViewCompiler::handle(__DIR__.'/../../resources/dashboard.blade.php', array_merge(
(array) $this, ['dashboard' => $this, 'request' => $this->request],
));
}

protected function handlePostRequest(): JsonResponse
{
$action = $this->request->data['action'] ?? $this->abort(400, 'Must provide action');

match ($action) {
'openInExplorer' => $this->openInExplorer(),
'openPageInEditor' => $this->openPageInEditor(),
'openMediaFileInEditor' => $this->openMediaFileInEditor(),
'createPage' => $this->createPage(),
default => $this->abort(403, "Invalid action '$action'"),
};

return $this->response ?? new JsonResponse(200, 'OK', [
'message' => 'Action completed successfully',
]);
}

public function getVersion(): string
{
$version = InstalledVersions::getPrettyVersion('hyde/realtime-compiler');
Expand Down Expand Up @@ -186,7 +160,7 @@ public static function highlightMediaLibraryCode(string $contents): HtmlString
$contents = str_replace([''', '"'], ['%SQT%', '%DQT%'], $contents); // Temporarily replace escaped quotes

if (static::isMediaFileProbablyMinified($contents)) {
return new HtmlString(substr($contents, 0, 800));
return new HtmlString(substr($contents, 0, count(MediaFile::files()) === 1 ? 2000 : 800));
}

$highlighted = str($contents)->explode("\n")->slice(0, 25)->map(function (string $line): string {
Expand Down Expand Up @@ -240,7 +214,7 @@ public function getTip(): HtmlString

public static function enabled(): bool
{
// Previously, the setting was hyde.server.dashboard, so for backwards compatability we need this
/** @deprecated Previously, the setting was hyde.server.dashboard, so for backwards compatability we need this */
if (is_bool($oldConfig = config('hyde.server.dashboard'))) {
trigger_error('Using `hyde.server.dashboard` as boolean is deprecated. Please use `hyde.server.dashboard.enabled` instead.', E_USER_DEPRECATED);

Expand Down Expand Up @@ -311,79 +285,76 @@ protected function loadFlashData(): void

protected function openInExplorer(): void
{
if ($this->isInteractive()) {
$binary = $this->findGeneralOpenBinary();
$path = Hyde::path();
$binary = $this->findGeneralOpenBinary();
$path = Hyde::path();

Process::run(sprintf('%s %s', $binary, escapeshellarg($path)))->throw();
}
Process::run(sprintf('%s %s', $binary, escapeshellarg($path)))->throw();
}

protected function openPageInEditor(HydePage $page): void
protected function openPageInEditor(): void
{
if ($this->isInteractive()) {
$binary = $this->findGeneralOpenBinary();
$path = Hyde::path($page->getSourcePath());
$routeKey = $this->request->data['routeKey'] ?? $this->abort(400, 'Must provide routeKey');
$page = Routes::getOrFail($routeKey)->getPage();

if (! (str_ends_with($path, '.md') || str_ends_with($path, '.blade.php'))) {
$this->abort(403, sprintf("Refusing to open unsafe file '%s'", basename($path)));
}
$binary = $this->findGeneralOpenBinary();
$path = Hyde::path($page->getSourcePath());

Process::run(sprintf('%s %s', $binary, escapeshellarg($path)))->throw();
if (! (str_ends_with($path, '.md') || str_ends_with($path, '.blade.php'))) {
$this->abort(403, sprintf("Refusing to open unsafe file '%s'", basename($path)));
}

Process::run(sprintf('%s %s', $binary, escapeshellarg($path)))->throw();
}

protected function openMediaFileInEditor(MediaFile $file): void
protected function openMediaFileInEditor(): void
{
if ($this->isInteractive()) {
$binary = $this->findGeneralOpenBinary();
$path = $file->getAbsolutePath();
$identifier = $this->request->data['identifier'] ?? $this->abort(400, 'Must provide identifier');
$file = @MediaFile::all()[$identifier] ?? $this->abort(404, "Invalid media identifier '$identifier'");

if (! in_array($file->getExtension(), ['png', 'svg', 'jpg', 'jpeg', 'gif', 'ico', 'css', 'js'])) {
$this->abort(403, sprintf("Refusing to open unsafe file '%s'", basename($path)));
}
$binary = $this->findGeneralOpenBinary();
$path = $file->getAbsolutePath();

Process::run(sprintf('%s %s', $binary, escapeshellarg($path)))->throw();
if (! in_array($file->getExtension(), ['png', 'svg', 'jpg', 'jpeg', 'gif', 'ico', 'css', 'js'])) {
$this->abort(403, sprintf("Refusing to open unsafe file '%s'", basename($path)));
}

Process::run(sprintf('%s %s', $binary, escapeshellarg($path)))->throw();
}

protected function createPage(): void
{
if ($this->isInteractive()) {
// Required data
$title = $this->request->data['titleInput'] ?? $this->abort(400, 'Must provide title');
$content = $this->request->data['contentInput'] ?? $this->abort(400, 'Must provide content');
$pageType = $this->request->data['pageTypeSelection'] ?? $this->abort(400, 'Must provide page type');

// Optional data
$postDescription = $this->request->data['postDescription'] ?? null;
$postCategory = $this->request->data['postCategory'] ?? null;
$postAuthor = $this->request->data['postAuthor'] ?? null;
$postDate = $this->request->data['postDate'] ?? null;

// Match page class
$pageClass = match ($pageType) {
'blade-page' => BladePage::class,
'markdown-page' => MarkdownPage::class,
'markdown-post' => MarkdownPost::class,
'documentation-page' => DocumentationPage::class,
default => throw new HttpException(400, "Invalid page type '$pageType'"),
};

if ($pageClass === MarkdownPost::class) {
$creator = new CreatesNewMarkdownPostFile($title, $postDescription, $postCategory, $postAuthor, $postDate, $content);
} else {
$creator = new CreatesNewPageSourceFile($title, $pageClass, false, $content);
}
try {
$path = $creator->save();
} catch (FileConflictException $exception) {
$this->abort($exception->getCode(), $exception->getMessage());
}
// Required data
$title = $this->request->data['titleInput'] ?? $this->abort(400, 'Must provide title');
$content = $this->request->data['contentInput'] ?? $this->abort(400, 'Must provide content');
$pageType = $this->request->data['pageTypeSelection'] ?? $this->abort(400, 'Must provide page type');

// Optional data
$postDescription = $this->request->data['postDescription'] ?? null;
$postCategory = $this->request->data['postCategory'] ?? null;
$postAuthor = $this->request->data['postAuthor'] ?? null;
$postDate = $this->request->data['postDate'] ?? null;

// Match page class
$pageClass = match ($pageType) {
'blade-page' => BladePage::class,
'markdown-page' => MarkdownPage::class,
'markdown-post' => MarkdownPost::class,
'documentation-page' => DocumentationPage::class,
default => $this->abort(400, "Unsupported page type '$pageType'"),
};

$creator = $pageClass === MarkdownPost::class
? new CreatesNewMarkdownPostFile($title, $postDescription, $postCategory, $postAuthor, $postDate, $content)
: new CreatesNewPageSourceFile($title, $pageClass, false, $content);

$this->flash('justCreatedPage', RouteKey::fromPage($pageClass, $pageClass::pathToIdentifier($path))->get());
$this->sendJsonResponse(201, "Created file '$path'!");
try {
$path = $creator->save();
} catch (FileConflictException $exception) {
$this->abort($exception->getCode(), $exception->getMessage());
}

$this->flash('justCreatedPage', RouteKey::fromPage($pageClass, $pageClass::pathToIdentifier($path))->get());
$this->setJsonResponse(201, "Created file '$path'!");
}

protected static function injectDashboardButton(string $contents): string
Expand Down Expand Up @@ -473,7 +444,7 @@ protected static function getPackageVersion(string $packageName): string
return $prettyVersion ?? 'unreleased';
}

protected function blockUnsafeRequests(): void
protected function shouldUnsafeRequestBeBlocked(): bool
{
// As the dashboard is not password-protected, and it can make changes to the file system,
// we block any requests that are not coming from the host machine. While we are clear
Expand All @@ -483,41 +454,21 @@ protected function blockUnsafeRequests(): void
$requestIp = $_SERVER['REMOTE_ADDR'];
$allowedIps = ['::1', '127.0.0.1', 'localhost'];

if (! in_array($requestIp, $allowedIps, true)) {
$this->abort(403, "Refusing to serve request from address '$requestIp' (must be on localhost)");
}
return ! in_array($requestIp, $allowedIps, true);
}

protected function sendJsonResponse(int $statusCode, string $body): never
protected function setJsonResponse(int $statusCode, string $body): void
{
$statusMessage = match ($statusCode) {
200 => 'OK',
201 => 'Created',
default => 'Internal Server Error',
};

(new JsonResponse($statusCode, $statusMessage, [
$this->response = new JsonResponse($statusCode, $this->matchStatusCode($statusCode), [
'body' => $body,
]))->send();

exit;
]);
}

protected function sendJsonErrorResponse(HttpException $exception): never
protected function sendJsonErrorResponse(int $statusCode, string $message): JsonResponse
{
$statusMessage = match ($exception->getStatusCode()) {
400 => 'Bad Request',
403 => 'Forbidden',
404 => 'Not Found',
409 => 'Conflict',
default => 'Internal Server Error',
};

(new JsonResponse($exception->getStatusCode(), $statusMessage, [
'error' => $exception->getMessage(),
]))->send();

exit;
return new JsonResponse($statusCode, $this->matchStatusCode($statusCode), [
'error' => $message,
]);
}

protected function abort(int $code, string $message): never
Expand All @@ -532,9 +483,22 @@ protected function findGeneralOpenBinary(): string
'Windows' => 'powershell Start-Process',
'Darwin' => 'open',
'Linux' => 'xdg-open',
default => throw new HttpException(500,
sprintf("Unable to find a matching binary for OS family '%s'", PHP_OS_FAMILY)
default => $this->abort(500,
sprintf("Unable to find a matching 'open' binary for OS family '%s'", PHP_OS_FAMILY)
)
};
}

protected function matchStatusCode(int $statusCode): string
{
return match ($statusCode) {
200 => 'OK',
201 => 'Created',
400 => 'Bad Request',
403 => 'Forbidden',
404 => 'Not Found',
409 => 'Conflict',
default => 'Internal Server Error',
};
}
}
4 changes: 1 addition & 3 deletions packages/realtime-compiler/src/Routing/PageRouter.php
Expand Up @@ -33,9 +33,7 @@ public function __construct(Request $request)
protected function handlePageRequest(): Response
{
if ($this->request->path === '/dashboard' && DashboardController::enabled()) {
return new HtmlResponse(200, 'OK', [
'body' => (new DashboardController())->show(),
]);
return (new DashboardController())->handle();
}

return new HtmlResponse(200, 'OK', [
Expand Down

0 comments on commit 85c0fe6

Please sign in to comment.