Passed
Push — master ( 28b18a...b563dc )
by Caen
03:52 queued 13s
created

DashboardController::highlightMediaLibraryCode()   B

Complexity

Conditions 6
Paths 2

Size

Total Lines 46
Code Lines 28

Duplication

Lines 0
Ratio 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 6
eloc 28
nc 2
nop 1
dl 0
loc 46
rs 8.8497
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 is_bool;
40
use function basename;
41
use function in_array;
42
use function json_decode;
43
use function json_encode;
44
use function substr_count;
45
use function array_combine;
46
use function trigger_error;
47
use function escapeshellarg;
48
use function file_get_contents;
49
use function str_starts_with;
50
use function str_replace;
51
use function array_merge;
52
use function sprintf;
53
use function config;
54
use function app;
55
56
/**
57
 * @internal This class is not intended to be edited outside the Hyde Realtime Compiler.
58
 */
59
class DashboardController
60
{
61
    public string $title;
62
63
    protected Request $request;
64
    protected bool $isAsync = false;
65
66
    protected array $flashes = [];
67
68
    protected static array $tips = [
69
        'This dashboard won\'t be saved to your static site.',
70
        'Got stuck? Ask for help on [GitHub](https://github.com/hydephp/hyde)!',
71
        'Found a bug? Please report it on [GitHub](https://github.com/hydephp/hyde)!',
72
        'You can disable tips using by setting `server.dashboard.tips` to `false` in `config/hyde.php`.',
73
        'The dashboard update your project files. You can disable this by setting `server.dashboard.interactive` to `false` in `config/hyde.php`.',
74
    ];
75
76
    public function __construct()
77
    {
78
        $this->title = config('hyde.name').' - Dashboard';
79
        $this->request = Request::capture();
80
81
        $this->loadFlashData();
82
83
        if ($this->request->method === 'POST') {
84
            $this->isAsync = (getallheaders()['X-RC-Handler'] ?? getallheaders()['x-rc-handler'] ?? null) === 'Async';
85
86
            if (! $this->isInteractive()) {
87
                $this->abort(403, 'Enable `server.editor` in `config/hyde.php` to use interactive dashboard features.');
88
            }
89
90
            try {
91
                $this->handlePostRequest();
92
            } catch (HttpException $exception) {
93
                if (! $this->isAsync) {
94
                    throw $exception;
95
                }
96
97
                $this->sendJsonErrorResponse($exception);
98
            }
99
        }
100
    }
101
102
    protected function handlePostRequest(): void
103
    {
104
        $actions = array_combine($actions = [
105
            'openInExplorer',
106
            'openPageInEditor',
107
            'openMediaFileInEditor',
108
            'createPage',
109
        ], $actions);
110
111
        $action = $this->request->data['action'] ?? $this->abort(400, 'Must provide action');
112
        $action = $actions[$action] ?? $this->abort(403, "Invalid action '$action'");
113
114
        if ($action === 'openInExplorer') {
115
            $this->openInExplorer();
116
        }
117
118
        if ($action === 'openPageInEditor') {
119
            $routeKey = $this->request->data['routeKey'] ?? $this->abort(400, 'Must provide routeKey');
120
            $page = Routes::getOrFail($routeKey)->getPage();
121
            $this->openPageInEditor($page);
122
        }
123
124
        if ($action === 'openMediaFileInEditor') {
125
            $identifier = $this->request->data['identifier'] ?? $this->abort(400, 'Must provide identifier');
126
            $asset = @MediaFile::all()[$identifier] ?? $this->abort(404, "Invalid media identifier '$identifier'");
127
            $this->openMediaFileInEditor($asset);
128
        }
129
130
        if ($action === 'createPage') {
131
            $this->createPage();
132
        }
133
    }
134
135
    public function show(): string
136
    {
137
        return AnonymousViewCompiler::handle(__DIR__.'/../../resources/dashboard.blade.php', array_merge(
138
            (array) $this, ['dashboard' => $this, 'request' => $this->request],
139
        ));
140
    }
141
142
    public function getVersion(): string
143
    {
144
        $version = InstalledVersions::getPrettyVersion('hyde/realtime-compiler');
145
146
        return str_starts_with($version, 'dev-') ? $version : "v$version";
147
    }
148
149
    public function getProjectInformation(): array
150
    {
151
        return [
152
            'Git Version' => app('git.version'),
153
            'Hyde Version' => self::getPackageVersion('hyde/hyde'),
154
            'Framework Version' => self::getPackageVersion('hyde/framework'),
155
            'Project Path' => Hyde::path(),
156
        ];
157
    }
158
159
    /** @return array<string, \Hyde\Support\Models\Route> */
160
    public function getPageList(): array
161
    {
162
        return Hyde::routes()->all();
163
    }
164
165
    /** @internal */
166
    public static function bytesToHuman(int $bytes, int $precision = 2): string
167
    {
168
        for ($i = 0; $bytes > 1024; $i++) {
169
            $bytes /= 1024;
170
        }
171
172
        return round($bytes, $precision).' '.['B', 'KB', 'MB', 'GB', 'TB'][$i];
173
    }
174
175
    /** @internal */
176
    public static function isMediaFileProbablyMinified(string $contents): bool
177
    {
178
        return substr_count(trim($contents), "\n") < 3 && strlen($contents) > 200;
179
    }
180
181
    /** @internal */
182
    public static function highlightMediaLibraryCode(string $contents): HtmlString
183
    {
184
        $contents = e($contents);
185
        $contents = str_replace(['&#039;', '&quot;'], ['%SQT%', '%DQT%'], $contents); // Temporarily replace escaped quotes
186
187
        if (static::isMediaFileProbablyMinified($contents)) {
188
            return new HtmlString(substr($contents, 0, 800));
189
        }
190
191
        $highlighted = str($contents)->explode("\n")->slice(0, 25)->map(function (string $line): string {
192
            $line = rtrim($line);
193
194
            if (str_starts_with($line, '//')) {
195
                return "<span style='font-size: 80%; color: gray'>$line</span>";
196
            }
197
198
            if (str_starts_with($line, '/*') && str_ends_with($line, '*/')) {
199
                // Commented code should not be additionally formatted, though we always want to comment multiline blocks
200
                $quickReturn = true;
201
            }
202
203
            $line = str_replace('/*', "<span style='font-size: 80%; color: gray'>/*", $line);
204
            $line = str_replace('*/', '*/</span>', $line);
205
206
            if ($quickReturn ?? false) {
207
                return rtrim($line);
208
            }
209
210
            $line = strtr($line, [
211
                '{' => "<span style='color: #0f6674'>{</span>",
212
                '}' => "<span style='color: #0f6674'>}</span>",
213
                '(' => "<span style='color: #0f6674'>(</span><span style=\"color: #f77243;\">",
214
                ')' => "</span><span style='color: #0f6674'>)</span>",
215
                ':' => "<span style='color: #0f6674'>:</span>",
216
                ';' => "<span style='color: #0f6674'>;</span>",
217
                '+' => "<span style='color: #0f6674'>+</span>",
218
                'return' => "<span style='color: #8e44ad'>return</span>",
219
                'function' => "<span style='color: #8e44ad'>function</span>",
220
            ]);
221
222
            return rtrim($line);
223
        })->implode("\n");
224
225
        $highlighted = str_replace(['%SQT%', '%DQT%'], ['&#039;', '&quot;'], $highlighted);
226
227
        return new HtmlString($highlighted);
228
    }
229
230
    public function showTips(): bool
231
    {
232
        return config('hyde.server.dashboard.tips', true);
233
    }
234
235
    public function getTip(): HtmlString
236
    {
237
        return new HtmlString(Str::inlineMarkdown(Arr::random(static::$tips)));
238
    }
239
240
    public static function enabled(): bool
241
    {
242
        // Previously, the setting was hyde.server.dashboard, so for backwards compatability we need this
243
        if (is_bool($oldConfig = config('hyde.server.dashboard'))) {
244
            trigger_error('Using `hyde.server.dashboard` as boolean is deprecated. Please use `hyde.server.dashboard.enabled` instead.', E_USER_DEPRECATED);
245
246
            return $oldConfig;
247
        }
248
249
        return config('hyde.server.dashboard.enabled', true);
250
    }
251
252
    // This method is called from the PageRouter and allows us to serve a dynamic welcome page
253
    public static function renderIndexPage(HydePage $page): string
254
    {
255
        if (config('hyde.server.save_preview')) {
256
            $contents = file_get_contents(StaticPageBuilder::handle($page));
257
        } else {
258
            Hyde::shareViewData($page);
259
260
            $contents = $page->compile();
261
        }
262
263
        // If the page is the default welcome page we inject dashboard components
264
        if (str_contains($contents, 'This is the default homepage')) {
265
            if (config('hyde.server.dashboard.welcome-banner', true)) {
266
                $contents = str_replace("</div>\n            <!-- End Main Hero Content -->",
267
                    sprintf("%s\n</div>\n<!-- End Main Hero Content -->", self::welcomeComponent()),
268
                    $contents);
269
            }
270
271
            if (config('hyde.server.dashboard.welcome-dashboard', true)) {
272
                $contents = str_replace('</body>', sprintf("%s\n</body>", self::welcomeFrame()), $contents);
273
            }
274
275
            if (config('hyde.server.dashboard.button', true)) {
276
                $contents = self::injectDashboardButton($contents);
277
            }
278
        }
279
280
        return $contents;
281
    }
282
283
    public function isInteractive(): bool
284
    {
285
        return config('hyde.server.dashboard.interactive', true);
286
    }
287
288
    public function getScripts(): string
289
    {
290
        return file_get_contents(__DIR__.'/../../resources/dashboard.js');
291
    }
292
293
    public function getFlash(string $key, $default = null): ?string
294
    {
295
        return $this->flashes[$key] ?? $default;
296
    }
297
298
    protected function flash(string $string, string $value): void
299
    {
300
        setcookie('hyde-rc-flash', json_encode([$string => $value]), time() + 180, '/') ?: $this->abort(500, 'Failed to flash session cookie');
301
    }
302
303
    protected function loadFlashData(): void
304
    {
305
        if ($flashData = $_COOKIE['hyde-rc-flash'] ?? null) {
306
            $this->flashes = json_decode($flashData, true);
307
            setcookie('hyde-rc-flash', ''); // Clear cookie
308
        }
309
    }
310
311
    protected function openInExplorer(): void
312
    {
313
        if ($this->isInteractive()) {
314
            $binary = $this->findGeneralOpenBinary();
315
            $path = Hyde::path();
316
317
            Process::run(sprintf('%s %s', $binary, escapeshellarg($path)))->throw();
318
        }
319
    }
320
321
    protected function openPageInEditor(HydePage $page): void
322
    {
323
        if ($this->isInteractive()) {
324
            $binary = $this->findGeneralOpenBinary();
325
            $path = Hyde::path($page->getSourcePath());
326
327
            if (! (str_ends_with($path, '.md') || str_ends_with($path, '.blade.php'))) {
328
                $this->abort(403, sprintf("Refusing to open unsafe file '%s'", basename($path)));
329
            }
330
331
            Process::run(sprintf('%s %s', $binary, escapeshellarg($path)))->throw();
332
        }
333
    }
334
335
    protected function openMediaFileInEditor(MediaFile $file): void
336
    {
337
        if ($this->isInteractive()) {
338
            $binary = $this->findGeneralOpenBinary();
339
            $path = $file->getAbsolutePath();
340
341
            if (! in_array($file->getExtension(), ['png', 'svg', 'jpg', 'jpeg', 'gif', 'ico', 'css', 'js'])) {
342
                $this->abort(403, sprintf("Refusing to open unsafe file '%s'", basename($path)));
343
            }
344
345
            Process::run(sprintf('%s %s', $binary, escapeshellarg($path)))->throw();
346
        }
347
    }
348
349
    protected function createPage(): void
350
    {
351
        if ($this->isInteractive()) {
352
            // Required data
353
            $title = $this->request->data['titleInput'] ?? $this->abort(400, 'Must provide title');
354
            $content = $this->request->data['contentInput'] ?? $this->abort(400, 'Must provide content');
355
            $pageType = $this->request->data['pageTypeSelection'] ?? $this->abort(400, 'Must provide page type');
356
357
            // Optional data
358
            $postDescription = $this->request->data['postDescription'] ?? null;
359
            $postCategory = $this->request->data['postCategory'] ?? null;
360
            $postAuthor = $this->request->data['postAuthor'] ?? null;
361
            $postDate = $this->request->data['postDate'] ?? null;
362
363
            // Match page class
364
            $pageClass = match ($pageType) {
365
                'blade-page' => BladePage::class,
366
                'markdown-page' => MarkdownPage::class,
367
                'markdown-post' => MarkdownPost::class,
368
                'documentation-page' => DocumentationPage::class,
369
                default => throw new HttpException(400, "Invalid page type '$pageType'"),
370
            };
371
372
            if ($pageClass === MarkdownPost::class) {
373
                $creator = new CreatesNewMarkdownPostFile($title, $postDescription, $postCategory, $postAuthor, $postDate, $content);
374
            } else {
375
                $creator = new CreatesNewPageSourceFile($title, $pageClass, false, $content);
376
            }
377
            try {
378
                $path = $creator->save();
379
            } catch (FileConflictException $exception) {
380
                $this->abort($exception->getCode(), $exception->getMessage());
381
            }
382
383
            $this->flash('justCreatedPage', RouteKey::fromPage($pageClass, $pageClass::pathToIdentifier($path))->get());
384
            $this->sendJsonResponse(201, "Created file '$path'!");
385
        }
386
    }
387
388
    protected static function injectDashboardButton(string $contents): string
389
    {
390
        return str_replace('</body>', sprintf('%s</body>', self::button()), $contents);
391
    }
392
393
    protected static function button(): string
394
    {
395
        return <<<'HTML'
396
            <style>
397
                 .dashboard-btn {
398
                    background-image: linear-gradient(to right, #1FA2FF 0%, #12D8FA  51%, #1FA2FF  100%);
399
                    margin: 10px;
400
                    padding: .5rem 1rem;
401
                    text-align: center;
402
                    transition: 0.5s;
403
                    background-size: 200% auto;
404
                    background-position: right center;
405
                    color: white;
406
                    box-shadow: 0 0 20px #162134;
407
                    border-radius: 10px;
408
                    display: block;
409
                    position: absolute;
410
                    right: 1rem;
411
                    top: 1rem
412
                 }
413
414
                 .dashboard-btn:hover {
415
                    background-position: left center;
416
                    color: #fff;
417
                    text-decoration: none;
418
                }
419
            </style>
420
            <a href="/dashboard" class="dashboard-btn">Dashboard</a>
421
        HTML;
422
    }
423
424
    protected static function welcomeComponent(): string
425
    {
426
        $dashboardMessage = config('hyde.server.dashboard.welcome-dashboard', true)
427
            ? '<br>Scroll down to see it, or visit <a href="/dashboard" style="color: #1FA2FF;">/dashboard</a> at any time!' : '';
428
429
        return <<<HTML
430
            <!-- Dashboard Component -->
431
            <section class="text-white">
432
                <hr style="border-width: 1px; max-width: 240px; opacity: .75; margin-top: 30px; margin-bottom: 24px">
433
                <p style="margin-bottom: 8px;">
434
                    <span style="
435
                        background: #1FA2FF;
436
                        background: -webkit-linear-gradient(to right, #1FA2FF, #12D8FA, #1FA2FF);
437
                        background: linear-gradient(to right, #1FA2FF, #12D8FA, #1FA2FF);
438
                        padding: 3px 8px;
439
                        border-radius: 25px;
440
                        font-size: 12px;
441
                        text-transform: uppercase;
442
                        font-weight: 600;
443
                    ">New</span> When using the Realtime Compiler, you now have a content dashboard!
444
                    $dashboardMessage
445
                </p>
446
447
                <a href="#dashboard" onclick="document.getElementById('dashboard').scrollIntoView({behavior: 'smooth'}); return false;">
448
                    <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>
449
                </a>
450
            </section>
451
            <!-- End Dashboard Component -->
452
        HTML;
453
    }
454
455
    protected static function welcomeFrame(): string
456
    {
457
        return <<<'HTML'
458
            <aside>
459
                <iframe id="dashboard" src="/dashboard?embedded=true" frameborder="0" style="width: 100vw; height: 100vh; position: absolute;"></iframe>
460
            </aside>
461
        HTML;
462
    }
463
464
    protected static function getPackageVersion(string $packageName): string
465
    {
466
        try {
467
            $prettyVersion = InstalledVersions::getPrettyVersion($packageName);
468
        } catch (OutOfBoundsException) {
469
            //
470
        }
471
472
        return $prettyVersion ?? 'unreleased';
473
    }
474
475
    protected function sendJsonResponse(int $statusCode, string $body): never
476
    {
477
        $statusMessage = match ($statusCode) {
478
            200 => 'OK',
479
            201 => 'Created',
480
            default => 'Internal Server Error',
481
        };
482
483
        (new JsonResponse($statusCode, $statusMessage, [
484
            'body' => $body,
485
        ]))->send();
486
487
        exit;
488
    }
489
490
    protected function sendJsonErrorResponse(HttpException $exception): never
491
    {
492
        $statusMessage = match ($exception->getStatusCode()) {
493
            400 => 'Bad Request',
494
            403 => 'Forbidden',
495
            404 => 'Not Found',
496
            409 => 'Conflict',
497
            default => 'Internal Server Error',
498
        };
499
500
        (new JsonResponse($exception->getStatusCode(), $statusMessage, [
501
            'error' => $exception->getMessage(),
502
        ]))->send();
503
504
        exit;
505
    }
506
507
    protected function abort(int $code, string $message): never
508
    {
509
        throw new HttpException($code, $message);
510
    }
511
512
    protected function findGeneralOpenBinary(): string
513
    {
514
        return match (PHP_OS_FAMILY) {
515
            // Using PowerShell allows us to open the file in the background
516
            'Windows' => 'powershell Start-Process',
517
            'Darwin' => 'open',
518
            'Linux' => 'xdg-open',
519
            default => throw new HttpException(500,
520
                sprintf("Unable to find a matching binary for OS family '%s'", PHP_OS_FAMILY)
521
            )
522
        };
523
    }
524
}
525