'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 = '
]*>.*?<\/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(''.$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);
}
}