fn_b/app/Services/Editorial/ContentRenderer.php

103 lines
5.0 KiB
PHP

<?php
namespace App\Services\Editorial;
use DOMDocument;
use Illuminate\Support\Facades\DB;
use League\CommonMark\GithubFlavoredMarkdownConverter;
final class ContentRenderer
{
public function render(array $item): string
{
$html = $item['format'] === 'html' ? $item['body'] : (string) (new GithubFlavoredMarkdownConverter([
'html_input' => 'allow', 'allow_unsafe_links' => false,
]))->convert($item['body']);
$dom = $this->document($html);
$allowed = explode(' ', 'html body p div span h1 h2 h3 h4 h5 h6 strong b em i u s del small blockquote pre code ul ol li hr br a img figure figcaption table thead tbody tfoot tr th td nav section details summary link');
$attrs = explode(' ', 'href src alt title class id width height loading decoding rel target colspan rowspan aria-label aria-hidden');
foreach ($dom->getElementsByTagName('*') as $node) {
abort_unless(in_array($node->nodeName, $allowed), 422, 'Unsupported HTML element: '.$node->nodeName);
foreach ($node->attributes as $a) {
abort_unless(in_array($a->name, $attrs), 422, 'Unsupported HTML attribute: '.$a->name);
}
if ($node->hasAttribute('href')) {
$href = $node->getAttribute('href');
abort_unless(preg_match('~\A(?:https://[^\s]+|/(?!/)[^\s]*|\#[^\s]*)\z~u', $href), 422, 'Unsupported link URL');
if (str_starts_with($href, 'https://') && parse_url($href, PHP_URL_HOST) === parse_url(config('editorial.base_url'), PHP_URL_HOST)) {
abort(422, 'Use a root-relative path for internal links');
}
if (str_starts_with($href, '/images/')) {
abort_unless(! str_contains($href, '..') && is_file(public_path(ltrim($href, '/'))), 422, 'Image link unavailable');
}
}
if ($node->nodeName === 'link') {
abort_unless($node->getAttribute('rel') === 'stylesheet' && $node->getAttribute('href') === '/css/tutorials.css', 422, 'Unsupported stylesheet');
}
if ($node->hasAttribute('src')) {
$src = $node->getAttribute('src');
if (preg_match('/\Aasset:([a-f0-9]{64})\z/', $src, $m)) {
abort_unless(DB::table('editorial_assets')->where('sha256', $m[1])->exists(), 422, 'Unknown uploaded image');
} else {
abort_unless(preg_match('~\A/images/(?!.*\.\.)([a-zA-Z0-9_./-]+)\.(png|jpg|jpeg|webp|gif|svg)\z~', $src)
&& is_file(public_path(ltrim($src, '/'))), 422, 'Images must be uploaded or existing local assets');
}
}
if ($node->getAttribute('target') === '_blank') {
$node->setAttribute('rel', 'noopener noreferrer');
}
}
$body = $dom->getElementsByTagName('body')->item(0);
$html = '';
foreach ($body->childNodes as $child) {
$html .= $dom->saveHTML($child);
}
if (! str_contains($html, 'class="tutorial-body"')) {
$html = '<link rel="stylesheet" href="/css/tutorials.css"><div class="tutorial-body">'.$html.'</div>';
}
// Existing article Blade calls nl2br. Preserve code newlines as entities.
$html = preg_replace_callback('/<pre\b[^>]*>.*?<\/pre>/s', fn ($m) => str_replace("\n", '&#10;', $m[0]), $html);
return str_replace(["\r", "\n"], '', $html);
}
public function document(string $html): DOMDocument
{
$dom = new DOMDocument('1.0', 'UTF-8');
$previous = libxml_use_internal_errors(true);
$dom->loadHTML('<?xml encoding="UTF-8"><html><body>'.$html.'</body></html>', LIBXML_NONET);
libxml_clear_errors();
libxml_use_internal_errors($previous);
return $dom;
}
public function publicImages(string $html): string
{
return preg_replace_callback('/asset:([a-f0-9]{64})/', function ($m) {
$asset = DB::table('editorial_assets')->where('sha256', $m[1])->first();
abort_unless($asset && is_file($this->privateImage($asset->sha256)), 422, 'Uploaded image unavailable');
$url = '/images/editorial/'.$asset->sha256.'.'.$asset->extension;
$destination = public_path(ltrim($url, '/'));
if (! is_dir(dirname($destination))) {
mkdir(dirname($destination), 0755, true);
}
if (! is_file($destination)) {
$tmp = $destination.'.'.bin2hex(random_bytes(6)).'.tmp';
if (! copy($this->privateImage($asset->sha256), $tmp) || ! rename($tmp, $destination)) {
throw new \RuntimeException('Image publication failed');
}
chmod($destination, 0644);
}
abort_unless(hash_file('sha256', $destination) === $asset->sha256, 422, 'Image checksum mismatch');
return $url;
}, $html);
}
public function privateImage(string $sha): string
{
return storage_path('app/private/editorial/assets/'.$sha);
}
}