Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Realtime compiler dashboard improvements #1443

Merged
merged 25 commits into from
Nov 9, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
df7ffad
Increase minified length if there is only one file
caendesilva Nov 7, 2023
c4e436d
Remove function imports
caendesilva Nov 8, 2023
1ef0268
Move up method in class
caendesilva Nov 8, 2023
9e0283b
Move response creation from router to controller
caendesilva Nov 8, 2023
4560530
Protect the show method
caendesilva Nov 8, 2023
d11fffe
Shift API handling from constructor to handle method
caendesilva Nov 8, 2023
f046d8e
Apply fixes from StyleCI
StyleCIBot Nov 8, 2023
ca4db1e
Refactor void method to return control state
caendesilva Nov 8, 2023
d659601
Shift helper method parameters
caendesilva Nov 8, 2023
2d3f67f
Invert faulty boolean logic
caendesilva Nov 8, 2023
3a7eca1
Refactor to return response instead of sending it
caendesilva Nov 8, 2023
3c32000
Extract helper for shared code
caendesilva Nov 8, 2023
cd4a23a
Formatting
caendesilva Nov 8, 2023
706922a
Improve internal Json response handling
caendesilva Nov 8, 2023
973bde9
Change error message
caendesilva Nov 8, 2023
3306e60
Update error message
caendesilva Nov 8, 2023
c6fd089
Use helper instead of throwing directly
caendesilva Nov 8, 2023
f1b8461
Formatting
caendesilva Nov 8, 2023
296977d
Unwrap conditions already evaluated by caller
caendesilva Nov 8, 2023
482e1ac
Replace if blocks with switch block
caendesilva Nov 8, 2023
9f4a3cb
Move data access and validation to handler methods
caendesilva Nov 8, 2023
ce68146
Replace 'switch' with 'match' expression
caendesilva Nov 8, 2023
9a62155
Replace validation array with default match handler
caendesilva Nov 8, 2023
ec74dda
Mark local variable as deprecated
caendesilva Nov 8, 2023
ef474e7
Merge branch 'master' into realtime-compiler-dashboard-improvements
caendesilva Nov 9, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
262 changes: 113 additions & 149 deletions packages/realtime-compiler/src/Http/DashboardController.php
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Loading