Files
Roardom 393b45349a update: laravel pint dev dependency
The latest version adds stdin support for use in IDEs that integrate with formatters if the formatter accepts input on stdin and outputs on stdout. Zed is one example of such an IDE.
2025-11-29 04:47:14 +00:00

528 lines
21 KiB
PHP

<?php
declare(strict_types=1);
/**
* NOTICE OF LICENSE.
*
* UNIT3D Community Edition is open-sourced software licensed under the GNU Affero General Public License v3.0
* The details is bundled with this project in the file LICENSE.txt.
*
* @project UNIT3D Community Edition
*
* @author Roardom <roardom@protonmail.com>
* @license https://www.gnu.org/licenses/agpl-3.0.en.html/ GNU Affero General Public License v3.0
*/
namespace App\Helpers;
use App\Models\WhitelistedImageUrl;
class Bbcode
{
/**
* @return array<
* string,
* array{
* openBbcode: string,
* closeBbcode: string,
* openHtml: string,
* closeHtml: string,
* block: bool
* }
* > $parsers.
*/
private static function parsers(): array
{
return [
'h1' => [
'openBbcode' => '/^\[h1\]/i',
'closeBbcode' => '[/h1]',
'openHtml' => '<h1>',
'closeHtml' => '</h1>',
'block' => true,
],
'h2' => [
'openBbcode' => '/^\[h2\]/i',
'closeBbcode' => '[/h2]',
'openHtml' => '<h2>',
'closeHtml' => '</h2>',
'block' => true,
],
'h3' => [
'openBbcode' => '/^\[h3\]/i',
'closeBbcode' => '[/h3]',
'openHtml' => '<h3>',
'closeHtml' => '</h3>',
'block' => true,
],
'h4' => [
'openBbcode' => '/^\[h4\]/i',
'closeBbcode' => '[/h4]',
'openHtml' => '<h4>',
'closeHtml' => '</h4>',
'block' => true,
],
'h5' => [
'openBbcode' => '/^\[h5\]/i',
'closeBbcode' => '[/h5]',
'openHtml' => '<h5>',
'closeHtml' => '</h5>',
'block' => true,
],
'h6' => [
'openBbcode' => '/^\[h6\]/i',
'closeBbcode' => '[/h6]',
'openHtml' => '<h6>',
'closeHtml' => '</h6>',
'block' => true,
],
'bold' => [
'openBbcode' => '/^\[b\]/i',
'closeBbcode' => '[/b]',
'openHtml' => '<b>',
'closeHtml' => '</b>',
'block' => false,
],
'italic' => [
'openBbcode' => '/^\[i\]/i',
'closeBbcode' => '[/i]',
'openHtml' => '<i>',
'closeHtml' => '</i>',
'block' => false,
],
'underline' => [
'openBbcode' => '/^\[u\]/i',
'closeBbcode' => '[/u]',
'openHtml' => '<u>',
'closeHtml' => '</u>',
'block' => false,
],
'linethrough' => [
'openBbcode' => '/^\[s\]/i',
'closeBbcode' => '[/s]',
'openHtml' => '<s>',
'closeHtml' => '</s>',
'block' => false,
],
'size' => [
'openBbcode' => '/^\[size=(\d+)\]/i',
'closeBbcode' => '[/size]',
'openHtml' => '<span style="font-size: clamp(10px, $1px, 100px);">',
'closeHtml' => '</span>',
'block' => false,
],
'font' => [
'openBbcode' => '/^\[font=([a-z0-9 ]+)\]/i',
'closeBbcode' => '[/font]',
'openHtml' => '<span style="font-family: $1;">',
'closeHtml' => '</span>',
'block' => false,
],
'color' => [
'openBbcode' => '/^\[color=(\#[a-f0-9]{3,4}|\#[a-f0-9]{6}|\#[a-f0-9]{8}|[a-z]+)\]/i',
'closeBbcode' => '[/color]',
'openHtml' => '<span style="color: $1;">',
'closeHtml' => '</span>',
'block' => false,
],
'center' => [
'openBbcode' => '/^\[center\]/i',
'closeBbcode' => '[/center]',
'openHtml' => '<div class="bbcode-rendered__center" style="text-align: center;">',
'closeHtml' => '</div>',
'block' => true,
],
'left' => [
'openBbcode' => '/^\[left\]/i',
'closeBbcode' => '[/left]',
'openHtml' => '<div class="bbcode-rendered__left" style="text-align: left;">',
'closeHtml' => '</div>',
'block' => true,
],
'right' => [
'openBbcode' => '/^\[right\]/i',
'closeBbcode' => '[/right]',
'openHtml' => '<div class="bbcode-rendered__right" style="text-align: right;">',
'closeHtml' => '</div>',
'block' => true,
],
'quote' => [
'openBbcode' => '/^\[quote\]/i',
'closeBbcode' => '[/quote]',
'openHtml' => '<blockquote>',
'closeHtml' => '</blockquote>',
'block' => true,
],
'namedquote' => [
'openBbcode' => '/^\[quote=(.*?)\]/i',
'closeBbcode' => '[/quote]',
'openHtml' => '<blockquote><i class="fas fa-quote-left"></i> <cite>Quoting $1:</cite><p>',
'closeHtml' => '</p></blockquote>',
'block' => true,
],
'orderedlistnumerical' => [
'openBbcode' => '/^\[list=1\]/i',
'closeBbcode' => '[/list]',
'openHtml' => '<ol>',
'closeHtml' => '</ol>',
'block' => true,
],
'orderedlistalpha' => [
'openBbcode' => '/^\[list=a\]/i',
'closeBbcode' => '[/list]',
'openHtml' => '<ol type="a">',
'closeHtml' => '</ol>',
'block' => true,
],
'unorderedlist' => [
'openBbcode' => '/^\[list\]/i',
'closeBbcode' => '[/list]',
'openHtml' => '<ul>',
'closeHtml' => '</ul>',
'block' => true,
],
'code' => [
'openBbcode' => '/^\[code\]/i',
'closeBbcode' => '[/code]',
'openHtml' => '<div class="bbcode-rendered__clipboard" x-data="clipboardButton"><pre><code>',
'closeHtml' => '</code></pre><div class="bbcode-rendered__clipboard-container"><button class="bbcode-rendered__clipboard-button" x-bind="button"><i class="fa fa-clone"></i></button></div></div>',
'block' => true,
],
'pre' => [
'openBbcode' => '/^\[pre\]/i',
'closeBbcode' => '[/pre]',
'openHtml' => '<code>',
'closeHtml' => '</code>',
'block' => false,
],
'alert' => [
'openBbcode' => '/^\[alert\]/i',
'closeBbcode' => '[/alert]',
'openHtml' => '<div class="bbcode-rendered__alert">',
'closeHtml' => '</div>',
'block' => true,
],
'note' => [
'openBbcode' => '/^\[note\]/i',
'closeBbcode' => '[/note]',
'openHtml' => '<div class="bbcode-rendered__note">',
'closeHtml' => '</div>',
'block' => true,
],
'sub' => [
'openBbcode' => '/^\[sub\]/i',
'closeBbcode' => '[/sub]',
'openHtml' => '<sub>',
'closeHtml' => '</sub>',
'block' => false,
],
'sup' => [
'openBbcode' => '/^\[sup\]/i',
'closeBbcode' => '[/sup]',
'openHtml' => '<sup>',
'closeHtml' => '</sup>',
'block' => false,
],
'small' => [
'openBbcode' => '/^\[small\]/i',
'closeBbcode' => '[/small]',
'openHtml' => '<small>',
'closeHtml' => '</small>',
'block' => false,
],
'table' => [
'openBbcode' => '/^\[table\]/i',
'closeBbcode' => '[/table]',
'openHtml' => '<table>',
'closeHtml' => '</table>',
'block' => true,
],
'table-row' => [
'openBbcode' => '/^\[tr\]/i',
'closeBbcode' => '[/tr]',
'openHtml' => '<tr>',
'closeHtml' => '</tr>',
'block' => true,
],
'table-header' => [
'openBbcode' => '/^\[th\]/i',
'closeBbcode' => '[/th]',
'openHtml' => '<th>',
'closeHtml' => '</th>',
'block' => true,
],
'table-data' => [
'openBbcode' => '/^\[td\]/i',
'closeBbcode' => '[/td]',
'openHtml' => '<td>',
'closeHtml' => '</td>',
'block' => true,
],
'spoiler' => [
'openBbcode' => '/^\[spoiler\]/i',
'closeBbcode' => '[/spoiler]',
'openHtml' => '<details><summary>Spoiler</summary><div style="text-align:left;">',
'closeHtml' => '</div></details>',
'block' => false,
],
'named-spoiler' => [
'openBbcode' => '/^\[spoiler=(.*?)\]/i',
'closeBbcode' => '[/spoiler]',
'openHtml' => '<details><summary>$1</summary><div style="text-align:left;">',
'closeHtml' => '</div></details>',
'block' => false,
],
];
}
/**
* Parses the BBCode string.
*/
public function parse(?string $source, bool $replaceLineBreaks = true): string
{
$source ??= '';
$source = htmlspecialchars($source, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5, 'UTF-8');
// Replace all void elements since they don't have closing tags
$source = str_replace('[*]', '<li>', (string) $source);
$source = str_replace('[hr]', '<hr>', $source);
$source = preg_replace_callback(
'/\[url](.*?)\[\/url]/i',
fn ($matches) => '<a href="'.self::sanitizeUrl($matches[1]).'">'.self::sanitizeUrl($matches[1]).'</a>',
$source
);
$source = preg_replace_callback(
'/\[url=(.*?)](.*?)\[\/url]/i',
fn ($matches) => '<a href="'.self::sanitizeUrl($matches[1]).'">'.$matches[2].'</a>',
$source ?? ''
);
$source = preg_replace_callback(
'/\[img](.*?)\[\/img]/i',
fn ($matches) => '<img src="'.self::sanitizeUrl($matches[1], isImage: true).'" loading="lazy" class="img-responsive" style="display: inline !important;">',
$source ?? ''
);
$source = preg_replace_callback(
'/\[img width=(\d+)](.*?)\[\/img]/i',
fn ($matches) => '<img src="'.self::sanitizeUrl($matches[2], isImage: true).'" loading="lazy" width="'.$matches[1].'px">',
$source ?? ''
);
$source = preg_replace_callback(
'/\[img=(\d+)(?:x\d+)?](.*?)\[\/img]/i',
fn ($matches) => '<img src="'.self::sanitizeUrl($matches[2], isImage: true).'" loading="lazy" width="'.$matches[1].'px">',
$source ?? ''
);
// YouTube video elements need to be replaced like this because the content inside the two tags
// has to be moved into an HTML attribute
$source = preg_replace_callback(
'/\[youtube]([a-z0-9_-]{11})\[\/youtube]/i',
static fn ($matches) => '<iframe width="560" height="315" src="https://www.youtube-nocookie.com/embed/'.$matches[1].'?rel=0" allow="autoplay; encrypted-media" allowfullscreen></iframe>',
$source ?? ''
);
$source = preg_replace_callback(
'/\[video]([a-z0-9_-]{11})\[\/video]/i',
static fn ($matches) => '<iframe width="560" height="315" src="https://www.youtube-nocookie.com/embed/'.$matches[1].'?rel=0" allow="autoplay; encrypted-media" allowfullscreen></iframe>',
$source ?? ''
);
$source = preg_replace_callback(
'/\[video=&quot;youtube&quot;]([a-z0-9_-]{11})\[\/video]/i',
static fn ($matches) => '<iframe width="560" height="315" src="https://www.youtube-nocookie.com/embed/'.$matches[1].'?rel=0" allow="autoplay; encrypted-media" allowfullscreen></iframe>',
$source ?? ''
);
// Common comparison syntax used in other torrent management systems is quite specific,
// so it must be done here instead
$source = preg_replace_callback(
'/\[comparison=(.*?)]\s*(.*?)\s*\[\/comparison]/is',
function ($matches) {
$comparates = preg_split('/\s*,\s*/', $matches[1]);
$urls = preg_split('/\s*(?:,|\s)\s*/', $matches[2]);
if ($comparates === false || $urls === false) {
return 'Broken comparison';
}
$validatedUrls = collect($urls)->map(fn ($url) => self::sanitizeUrl($url, isImage: true));
$chunkedUrls = $validatedUrls->chunk(\count($comparates));
$html = view('partials.comparison', ['comparates' => $comparates, 'urls' => $chunkedUrls])->render();
$html = preg_replace('/\s+/', ' ', $html);
if (!\is_string($html)) {
return 'Broken html';
}
return $html;
},
$source ?? ''
);
// Stack of unclosed elements
$openedElements = [];
// Character index
$index = 0;
// Don't loop more than the length of the source
while ($index < \strlen((string) $source)) {
// Get the next occurrence of `[`
$index = strpos((string) $source, '[', $index);
// Break if there are no more occurrences of `[`
if ($index === false) {
break;
}
// Break if `[` is the last character of the source
if ($index + 1 >= \strlen((string) $source)) {
break;
}
// Is the potential tag opening or closing?
if ($source[$index + 1] === '/' && !empty($openedElements)) {
$name = array_pop($openedElements);
$el = self::parsers()[$name];
$tag = substr((string) $source, $index, \strlen((string) $el['closeBbcode']));
// Replace bbcode tag with html tag if found tag matches expected tag,
// otherwise return the expected element's to the stack
if (strcasecmp($tag, (string) $el['closeBbcode']) === 0) {
$source = substr_replace((string) $source, (string) $el['closeHtml'], $index, \strlen((string) $el['closeBbcode']));
if ($replaceLineBreaks === true && $el['block'] === true) {
self::handleBlockElementSpacing($source, $index, $index, $index + \strlen((string) $el['closeHtml']) - 1);
}
} else {
$openedElements[] = $name;
}
} else {
$remainingText = substr((string) $source, $index);
// Find match between found bbcode tag and valid elements
foreach (self::parsers() as $name => $el) {
// The opening bbcode tag uses the regex `^` character to make
// sure only the beginning of $remainingText is matched
if (preg_match($el['openBbcode'], $remainingText, $matches) === 1) {
$replacement = (string) preg_replace($el['openBbcode'], (string) $el['openHtml'], $matches[0]);
$source = substr_replace((string) $source, $replacement, $index, \strlen($matches[0]));
if ($replaceLineBreaks === true && $el['block'] === true) {
self::handleBlockElementSpacing($source, $index, $index, $index + \strlen($replacement) - 1);
}
$openedElements[] = $name;
break;
}
}
}
$index++;
}
while (!empty($openedElements)) {
$source .= self::parsers()[array_pop($openedElements)]['closeHtml'];
}
if ($replaceLineBreaks) {
// Replace line breaks
$source = str_replace(["\r\n", "\n"], '<br>', (string) $source);
}
return $source;
}
/**
* Remove line breaks immediately before and after tags for block elements.
*
* Useful so that you can write bbcode like the following without worrying about spacing:
*
* ```
* [list]
* [*]item 1
* [*]item 2
* [/list]
* ```
*
* @param string $source Reference to the source text content currently being converted from bbcode to html.
* @param int $index Reference to the current index of `$source` that the parser must keep track of.
* @param int $tagStartIndex The index of the first character of the tag being parsed inside `$source`. Should be the `[` character.
* @param int $tagStopIndex The index of the last character of the tag being parsed inside `$source`. Should be the `]` character.
*/
private static function handleBlockElementSpacing(string &$source, int &$index, int $tagStartIndex, int $tagStopIndex): void
{
// Remove two line breaks (if they exist) instead of one, since a
// line break after a block element is positioned on the line after
// the block element, while a line break after a non-block element
// is positioned at the end of the same line of the non-block element.
// I.e. two line breaks after a block element provides the same amount
// of vertical space as one line break after a non-block element.
for ($i = 0; $i < 2; $i++) {
$bbcodeStopIndex = \strlen($source) - 1;
// Does there exist 2 characters after the tag and are they \r\n?
// Otherwise, does there exist 1 character after the tag and is it \n?
// In either case, remove those characters.
if ($tagStopIndex + 2 <= $bbcodeStopIndex && substr_compare($source, "\r\n", $tagStopIndex + 1, 2) === 0) {
$source = substr_replace($source, '', $tagStopIndex + 1, 2);
} elseif ($tagStopIndex + 1 <= $bbcodeStopIndex && $source[$tagStopIndex + 1] === "\n") {
$source = substr_replace($source, '', $tagStopIndex + 1, 1);
}
}
// Does there exist 2 characters before the tag and are they \r\n?
// Otherwise, does there exist 1 character before the tag and is it \n?
// In either case, remove those characters and adjust the current index appropriately.
if ($tagStartIndex >= 2 && substr_compare($source, "\r\n", $tagStartIndex - 2, 2) === 0) {
$source = substr_replace($source, '', $tagStartIndex - 2, 2);
$index -= 2;
} elseif ($tagStartIndex >= 1 && $source[$tagStartIndex - 1] === "\n") {
$source = substr_replace($source, '', $tagStartIndex - 1, 1);
$index -= 1;
}
}
private static function sanitizeUrl(string $url, ?bool $isImage = null): string
{
// Do NOT add `javascript`, `data` or `vbscript` here
// or else you will allow an XSS vulnerability!
$protocolWhitelist = [
'http',
'https',
'irc',
'ftp',
'sftp',
'magnet',
];
if (str_starts_with($url, '/')) {
$url = config('app.url').'/'.ltrim($url, '/');
} elseif (!\in_array(parse_url($url, PHP_URL_SCHEME), $protocolWhitelist)) {
$url = 'https://'.$url;
}
if (false === filter_var($url, FILTER_VALIDATE_URL)) {
return 'Broken link';
}
if ($isImage) {
$whitelistedImageUrls = cache()->rememberForever(
'whitelisted-image-urls',
fn () => WhitelistedImageUrl::query()->pluck('pattern'),
);
$isWhitelisted = $whitelistedImageUrls->contains(function (string $pattern) use ($url) {
$pattern = str_replace(['\*\*', '\*'], ['.*', '[^\/.]*'], preg_quote($pattern, '/'));
return preg_match('/^'.$pattern.'$/i', $url) === 1;
});
if (!$isWhitelisted) {
$url = 'https://wsrv.nl/?n=-1&ll&url='.urlencode($url);
}
}
return $url;
}
}