103 lines
5.0 KiB
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", ' ', $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);
|
|
}
|
|
}
|