Passed
Push — master ( 86b06e...5cc10c )
by Caen
05:25 queued 14s
created

DashboardController::getProjectInformation()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 7
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 1
eloc 5
nc 1
nop 0
dl 0
loc 7
rs 10
c 2
b 0
f 0
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Hyde\RealtimeCompiler\Http;
6
7
use Hyde\Hyde;
8
use OutOfBoundsException;
9
use Hyde\Pages\BladePage;
10
use Illuminate\Support\Str;
11
use Illuminate\Support\Arr;
12
use Hyde\Pages\MarkdownPage;
13
use Hyde\Pages\MarkdownPost;
14
use Hyde\Pages\Concerns\HydePage;
15
use Hyde\Pages\DocumentationPage;
16
use Hyde\Support\Models\RouteKey;
17
use Illuminate\Support\HtmlString;
18
use Hyde\Foundation\Facades\Routes;
19
use Desilva\Microserve\JsonResponse;
20
use Hyde\Support\Filesystem\MediaFile;
21
use Illuminate\Support\Facades\Process;
22
use Hyde\Framework\Actions\StaticPageBuilder;
23
use Hyde\Framework\Actions\AnonymousViewCompiler;
24
use Desilva\Microserve\Request;
25
use Composer\InstalledVersions;
26
use Hyde\Framework\Actions\CreatesNewPageSourceFile;
27
use Hyde\Framework\Exceptions\FileConflictException;
28
use Hyde\Framework\Actions\CreatesNewMarkdownPostFile;
29
use Symfony\Component\HttpKernel\Exception\HttpException;
30
31
use function e;
32
use function str;
33
use function time;
34
use function trim;
35
use function round;
36
use function rtrim;
37
use function strlen;
38
use function substr;
39
use function basename;
40
use function in_array;
41
use function json_decode;
42
use function json_encode;
43
use function substr_count;
44
use function array_combine;
45
use function escapeshellarg;
46
use function file_get_contents;
47
use function str_starts_with;
48
use function str_replace;
49
use function array_merge;
50
use function sprintf;
51
use function config;
52
use function app;
53
54
/**
55
 * @internal This class is not intended to be edited outside the Hyde Realtime Compiler.
56
 */
57
class DashboardController
58
{
59
    public string $title;
60
61
    protected Request $request;
62
    protected bool $isAsync = false;
63
64
    protected array $flashes = [];
65
66
    protected static array $tips = [
67
        'This dashboard won\'t be saved to your static site.',
68
        'Got stuck? Ask for help on [GitHub](https://github.com/hydephp/hyde)!',
69
        'Found a bug? Please report it on [GitHub](https://github.com/hydephp/hyde)!',
70
        'You can disable tips using by setting `server.dashboard_tips` to `false` in `config/hyde.php`.',
71
        'The dashboard update your project files. You can disable this by setting `server.dashboard_editor` to `false` in `config/hyde.php`.',
72
    ];
73
74
    public function __construct()
75
    {
76
        $this->title = config('hyde.name').' - Dashboard';
77
        $this->request = Request::capture();
78
79
        $this->loadFlashData();
80
81
        if ($this->request->method === 'POST') {
82
            $this->isAsync = (getallheaders()['X-RC-Handler'] ?? getallheaders()['x-rc-handler'] ?? null) === 'Async';
83
84
            if (! $this->isInteractive()) {
85
                $this->abort(403, 'Enable `server.editor` in `config/hyde.php` to use interactive dashboard features.');
86
            }
87
88
            try {
89
                $this->handlePostRequest();
90
            } catch (HttpException $exception) {
91
                if (! $this->isAsync) {
92
                    throw $exception;
93
                }
94
95
                $this->sendJsonErrorResponse($exception);
96
            }
97
        }
98
    }
99
100
    protected function handlePostRequest(): void
101
    {
102
        $actions = array_combine($actions = [
103
            'openInExplorer',
104
            'openPageInEditor',
105
            'openMediaFileInEditor',
106
            'createPage',
107
        ], $actions);
108
109
        $action = $this->request->data['action'] ?? $this->abort(400, 'Must provide action');
110
        $action = $actions[$action] ?? $this->abort(403, "Invalid action '$action'");
111
112
        if ($action === 'openInExplorer') {
113
            $this->openInExplorer();
114
        }
115
116
        if ($action === 'openPageInEditor') {
117
            $routeKey = $this->request->data['routeKey'] ?? $this->abort(400, 'Must provide routeKey');
118
            $page = Routes::getOrFail($routeKey)->getPage();
119
            $this->openPageInEditor($page);
120
        }
121
122
        if ($action === 'openMediaFileInEditor') {
123
            $identifier = $this->request->data['identifier'] ?? $this->abort(400, 'Must provide identifier');
124
            $asset = @MediaFile::all()[$identifier] ?? $this->abort(404, "Invalid media identifier '$identifier'");
125
            $this->openMediaFileInEditor($asset);
126
        }
127
128
        if ($action === 'createPage') {
129
            $this->createPage();
130
        }
131
    }
132
133
    public function show(): string
134
    {
135
        return AnonymousViewCompiler::handle(__DIR__.'/../../resources/dashboard.blade.php', array_merge(
136
            (array) $this, ['dashboard' => $this, 'request' => $this->request],
137
        ));
138
    }
139
140
    public function getVersion(): string
141
    {
142
        $version = InstalledVersions::getPrettyVersion('hyde/realtime-compiler');
143
144
        return str_starts_with($version, 'dev-') ? $version : "v$version";
0 ignored issues
show
Bug Best Practice introduced by
The expression return str_starts_with($...$version : 'v'.$version could return the type null which is incompatible with the type-hinted return string. Consider adding an additional type-check to rule them out.
Loading history...
Bug introduced by
It seems like $version can also be of type null; however, parameter $haystack of str_starts_with() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

144
        return str_starts_with(/** @scrutinizer ignore-type */ $version, 'dev-') ? $version : "v$version";
Loading history...
145
    }
146
147
    public function getProjectInformation(): array
148
    {
149
        return [
150
            'Git Version' => app('git.version'),
151
            'Hyde Version' => self::getPackageVersion('hyde/hyde'),
152
            'Framework Version' => self::getPackageVersion('hyde/framework'),
153
            'Project Path' => Hyde::path(),
154
        ];
155
    }
156
157
    /** @return array<string, \Hyde\Support\Models\Route> */
158
    public function getPageList(): array
159
    {
160
        return Hyde::routes()->all();
161
    }
162
163
    /** @internal */
164
    public static function bytesToHuman(int $bytes, int $precision = 2): string
165
    {
166
        for ($i = 0; $bytes > 1024; $i++) {
167
            $bytes /= 1024;
168
        }
169
170
        return round($bytes, $precision).' '.['B', 'KB', 'MB', 'GB', 'TB'][$i];
171
    }
172
173
    /** @internal */
174
    public static function isMediaFileProbablyMinified(string $contents): bool
175
    {
176
        return substr_count(trim($contents), "\n") < 3 && strlen($contents) > 200;
177
    }
178
179
    /** @internal */
180
    public static function highlightMediaLibraryCode(string $contents): HtmlString
181
    {
182
        $contents = e($contents);
183
        $contents = str_replace(['&#039;', '&quot;'], ['%SQT%', '%DQT%'], $contents); // Temporarily replace escaped quotes
184
185
        if (static::isMediaFileProbablyMinified($contents)) {
186
            return new HtmlString(substr($contents, 0, 800));
187
        }
188
189
        $highlighted = str($contents)->explode("\n")->slice(0, 25)->map(function (string $line): string {
190
            $line = rtrim($line);
191
192
            if (str_starts_with($line, '//')) {
193
                return "<span style='font-size: 80%; color: gray'>$line</span>";
194
            }
195
196
            if (str_starts_with($line, '/*') && str_ends_with($line, '*/')) {
197
                // Commented code should not be additionally formatted, though we always want to comment multiline blocks
198
                $quickReturn = true;
199
            }
200
201
            $line = str_replace('/*', "<span style='font-size: 80%; color: gray'>/*", $line);
202
            $line = str_replace('*/', '*/</span>', $line);
203
204
            if ($quickReturn ?? false) {
205
                return rtrim($line);
206
            }
207
208
            $line = strtr($line, [
209
                '{' => "<span style='color: #0f6674'>{</span>",
210
                '}' => "<span style='color: #0f6674'>}</span>",
211
                '(' => "<span style='color: #0f6674'>(</span><span style=\"color: #f77243;\">",
212
                ')' => "</span><span style='color: #0f6674'>)</span>",
213
                ':' => "<span style='color: #0f6674'>:</span>",
214
                ';' => "<span style='color: #0f6674'>;</span>",
215
                '+' => "<span style='color: #0f6674'>+</span>",
216
                'return' => "<span style='color: #8e44ad'>return</span>",
217
                'function' => "<span style='color: #8e44ad'>function</span>",
218
            ]);
219
220
            return rtrim($line);
221
        })->implode("\n");
222
223
        $highlighted = str_replace(['%SQT%', '%DQT%'], ['&#039;', '&quot;'], $highlighted);
224
225
        return new HtmlString($highlighted);
226
    }
227
228
    public function showTips(): bool
229
    {
230
        return config('hyde.server.dashboard_tips', true);
231
    }
232
233
    public function getTip(): HtmlString
234
    {
235
        return new HtmlString(Str::inlineMarkdown(Arr::random(static::$tips)));
236
    }
237
238
    public static function enabled(): bool
239
    {
240
        return config('hyde.server.dashboard', true);
241
    }
242
243
    // This method is called from the PageRouter and allows us to serve a dynamic welcome page
244
    public static function renderIndexPage(HydePage $page): string
245
    {
246
        if (config('hyde.server.save_preview')) {
247
            $contents = file_get_contents(StaticPageBuilder::handle($page));
248
        } else {
249
            Hyde::shareViewData($page);
250
251
            $contents = $page->compile();
252
        }
253
254
        // If the page is the default welcome page we inject dashboard components
255
        if (str_contains($contents, 'This is the default homepage')) {
256
            if (config('hyde.server.dashboard.welcome-banner', true)) {
257
                $contents = str_replace("</div>\n            <!-- End Main Hero Content -->",
258
                    sprintf("%s\n</div>\n<!-- End Main Hero Content -->", self::welcomeComponent()),
259
                    $contents);
260
            }
261
262
            if (config('hyde.server.dashboard.welcome-dashboard', true)) {
263
                $contents = str_replace('</body>', sprintf("%s\n</body>", self::welcomeFrame()), $contents);
264
            }
265
266
            if (config('hyde.server.dashboard.button', true)) {
267
                $contents = self::injectDashboardButton($contents);
268
            }
269
        }
270
271
        return $contents;
272
    }
273
274
    public function isInteractive(): bool
275
    {
276
        return config('hyde.server.dashboard_editor', true);
277
    }
278
279
    public function getScripts(): string
280
    {
281
        return file_get_contents(__DIR__.'/../../resources/dashboard.js');
282
    }
283
284
    public function getFlash(string $key, $default = null): ?string
285
    {
286
        return $this->flashes[$key] ?? $default;
287
    }
288
289
    protected function flash(string $string, string $value): void
290
    {
291
        setcookie('hyde-rc-flash', json_encode([$string => $value]), time() + 180, '/') ?: $this->abort(500, 'Failed to flash session cookie');
292
    }
293
294
    protected function loadFlashData(): void
295
    {
296
        if ($flashData = $_COOKIE['hyde-rc-flash'] ?? null) {
297
            $this->flashes = json_decode($flashData, true);
298
            setcookie('hyde-rc-flash', ''); // Clear cookie
299
        }
300
    }
301
302
    protected function openInExplorer(): void
303
    {
304
        if ($this->isInteractive()) {
305
            $binary = $this->findGeneralOpenBinary();
306
            $path = Hyde::path();
307
308
            Process::run(sprintf('%s %s', $binary, escapeshellarg($path)))->throw();
309
        }
310
    }
311
312
    protected function openPageInEditor(HydePage $page): void
313
    {
314
        if ($this->isInteractive()) {
315
            $binary = $this->findGeneralOpenBinary();
316
            $path = Hyde::path($page->getSourcePath());
317
318
            if (! (str_ends_with($path, '.md') || str_ends_with($path, '.blade.php'))) {
319
                $this->abort(403, sprintf("Refusing to open unsafe file '%s'", basename($path)));
320
            }
321
322
            Process::run(sprintf('%s %s', $binary, escapeshellarg($path)))->throw();
323
        }
324
    }
325
326
    protected function openMediaFileInEditor(MediaFile $file): void
327
    {
328
        if ($this->isInteractive()) {
329
            $binary = $this->findGeneralOpenBinary();
330
            $path = $file->getAbsolutePath();
331
332
            if (! in_array($file->getExtension(), ['png', 'svg', 'jpg', 'jpeg', 'gif', 'ico', 'css', 'js'])) {
333
                $this->abort(403, sprintf("Refusing to open unsafe file '%s'", basename($path)));
334
            }
335
336
            Process::run(sprintf('%s %s', $binary, escapeshellarg($path)))->throw();
337
        }
338
    }
339
340
    protected function createPage(): void
341
    {
342
        if ($this->isInteractive()) {
343
            // Required data
344
            $title = $this->request->data['titleInput'] ?? $this->abort(400, 'Must provide title');
345
            $content = $this->request->data['contentInput'] ?? $this->abort(400, 'Must provide content');
346
            $pageType = $this->request->data['pageTypeSelection'] ?? $this->abort(400, 'Must provide page type');
347
348
            // Optional data
349
            $postDescription = $this->request->data['postDescription'] ?? null;
350
            $postCategory = $this->request->data['postCategory'] ?? null;
351
            $postAuthor = $this->request->data['postAuthor'] ?? null;
352
            $postDate = $this->request->data['postDate'] ?? null;
353
354
            // Match page class
355
            $pageClass = match ($pageType) {
356
                'blade-page' => BladePage::class,
357
                'markdown-page' => MarkdownPage::class,
358
                'markdown-post' => MarkdownPost::class,
359
                'documentation-page' => DocumentationPage::class,
360
                default => throw new HttpException(400, "Invalid page type '$pageType'"),
361
            };
362
363
            if ($pageClass === MarkdownPost::class) {
364
                $creator = new CreatesNewMarkdownPostFile($title, $postDescription, $postCategory, $postAuthor, $postDate, $content);
365
            } else {
366
                $creator = new CreatesNewPageSourceFile($title, $pageClass, false, $content);
367
            }
368
            try {
369
                $path = $creator->save();
370
            } catch (FileConflictException $exception) {
371
                $this->abort($exception->getCode(), $exception->getMessage());
372
            }
373
374
            $this->flash('justCreatedPage', RouteKey::fromPage($pageClass, $pageClass::pathToIdentifier($path))->get());
375
            $this->sendJsonResponse(201, "Created file '$path'!");
376
        }
377
    }
378
379
    protected static function injectDashboardButton(string $contents): string
380
    {
381
        return str_replace('</body>', sprintf('%s</body>', self::button()), $contents);
382
    }
383
384
    protected static function button(): string
385
    {
386
        return <<<'HTML'
387
            <style>
388
                 .dashboard-btn {
389
                    background-image: linear-gradient(to right, #1FA2FF 0%, #12D8FA  51%, #1FA2FF  100%);
390
                    margin: 10px;
391
                    padding: .5rem 1rem;
392
                    text-align: center;
393
                    transition: 0.5s;
394
                    background-size: 200% auto;
395
                    background-position: right center;
396
                    color: white;
397
                    box-shadow: 0 0 20px #162134;
398
                    border-radius: 10px;
399
                    display: block;
400
                    position: absolute;
401
                    right: 1rem;
402
                    top: 1rem
403
                 }
404
405
                 .dashboard-btn:hover {
406
                    background-position: left center;
407
                    color: #fff;
408
                    text-decoration: none;
409
                }
410
            </style>
411
            <a href="/dashboard" class="dashboard-btn">Dashboard</a>
412
        HTML;
413
    }
414
415
    protected static function welcomeComponent(): string
416
    {
417
        $dashboardMessage = config('hyde.server.dashboard.welcome-dashboard', true)
418
            ? '<br>Scroll down to see it, or visit <a href="/dashboard" style="color: #1FA2FF;">/dashboard</a> at any time!' : '';
419
420
        return <<<HTML
421
            <!-- Dashboard Component -->
422
            <section class="text-white">
423
                <hr style="border-width: 1px; max-width: 240px; opacity: .75; margin-top: 30px; margin-bottom: 24px">
424
                <p style="margin-bottom: 8px;">
425
                    <span style="
426
                        background: #1FA2FF;
427
                        background: -webkit-linear-gradient(to right, #1FA2FF, #12D8FA, #1FA2FF);
428
                        background: linear-gradient(to right, #1FA2FF, #12D8FA, #1FA2FF);
429
                        padding: 3px 8px;
430
                        border-radius: 25px;
431
                        font-size: 12px;
432
                        text-transform: uppercase;
433
                        font-weight: 600;
434
                    ">New</span> When using the Realtime Compiler, you now have a content dashboard!
435
                    $dashboardMessage
436
                </p>
437
438
                <a href="#dashboard" onclick="document.getElementById('dashboard').scrollIntoView({behavior: 'smooth'}); return false;">
439
                    <svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#ffffff"><path d="M0 0h24v24H0z" fill="none"/><path d="M16.59 8.59L12 13.17 7.41 8.59 6 10l6 6 6-6z"/></svg>
440
                </a>
441
            </section>
442
            <!-- End Dashboard Component -->
443
        HTML;
444
    }
445
446
    protected static function welcomeFrame(): string
447
    {
448
        return <<<'HTML'
449
            <aside>
450
                <iframe id="dashboard" src="/dashboard?embedded=true" frameborder="0" style="width: 100vw; height: 100vh; position: absolute;"></iframe>
451
            </aside>
452
        HTML;
453
    }
454
455
    protected static function getPackageVersion(string $packageName): string
456
    {
457
        try {
458
            $prettyVersion = InstalledVersions::getPrettyVersion($packageName);
459
        } catch (OutOfBoundsException) {
460
            //
461
        }
462
463
        return $prettyVersion ?? 'unreleased';
464
    }
465
466
    protected function sendJsonResponse(int $statusCode, string $body): never
467
    {
468
        $statusMessage = match ($statusCode) {
469
            200 => 'OK',
470
            201 => 'Created',
471
            default => 'Internal Server Error',
472
        };
473
474
        (new JsonResponse($statusCode, $statusMessage, [
475
            'body' => $body,
476
        ]))->send();
477
478
        exit;
0 ignored issues
show
Best Practice introduced by
Using exit here is not recommended.

In general, usage of exit should be done with care and only when running in a scripting context like a CLI script.

Loading history...
479
    }
480
481
    protected function sendJsonErrorResponse(HttpException $exception): never
482
    {
483
        $statusMessage = match ($exception->getStatusCode()) {
484
            400 => 'Bad Request',
485
            403 => 'Forbidden',
486
            404 => 'Not Found',
487
            409 => 'Conflict',
488
            default => 'Internal Server Error',
489
        };
490
491
        (new JsonResponse($exception->getStatusCode(), $statusMessage, [
492
            'error' => $exception->getMessage(),
493
        ]))->send();
494
495
        exit;
0 ignored issues
show
Best Practice introduced by
Using exit here is not recommended.

In general, usage of exit should be done with care and only when running in a scripting context like a CLI script.

Loading history...
496
    }
497
498
    protected function abort(int $code, string $message): never
499
    {
500
        throw new HttpException($code, $message);
501
    }
502
503
    protected function findGeneralOpenBinary(): string
504
    {
505
        return match (PHP_OS_FAMILY) {
506
            // Using PowerShell allows us to open the file in the background
507
            'Windows' => 'powershell Start-Process',
508
            'Darwin' => 'open',
509
            'Linux' => 'xdg-open',
510
            default => throw new HttpException(500,
511
                sprintf("Unable to find a matching binary for OS family '%s'", PHP_OS_FAMILY)
512
            )
513
        };
514
    }
515
}
516