'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 = '
'.$html.'
'; } // Existing article Blade calls nl2br. Preserve code newlines as entities. $html = preg_replace_callback('/]*>.*?<\/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); } }