max number of attributes */ private static array $ValidTags = [ '###' => 1, '##' => 1, '#' => 1, '*' => 1, '**' => 1, '***' => 1, 'align' => 1, 'artist' => 0, 'b' => 0, 'box' => 0, 'code' => 1, 'collage' => 1, 'color' => 1, 'colour' => 1, 'forum' => 0, 'headline' => 1, 'hide' => 1, 'hr' => 0, 'i' => 0, 'img' => 1, 'important' => 0, 'inlinesize' => 1, 'inlineurl' => 0, 'mature' => 1, 'n' => 0, 'pad' => 1, 'pl' => 1, 'plain' => 0, 'pre' => 1, 'quote' => 1, 'rule' => 0, 's' => 0, 'size' => 1, 'spoiler' => 1, 'tex' => 0, 'thread' => 0, 'torrent' => 1, 'u' => 0, 'url' => 1, 'user' => 0, ]; /** * Array of smilies; code => image file in STATIC_SERVER/common/smileys */ private static array $Smileys = [ '>.>' => 'eyesright.gif', '<3' => 'heart.gif', ':'(' => 'crying.gif', ':(' => 'sad.gif', ':)' => 'smile.gif', ':-(' => 'sad.gif', ':-)' => 'smile.gif', ':-D' => 'biggrin.gif', ':-P' => 'tongue.gif', ':-p' => 'tongue.gif', ':-|' => 'blank.gif', ':D' => 'biggrin.gif', ':O' => 'ohshit.gif', ':P' => 'tongue.gif', ':angry:' => 'angry.gif', ':blush:' => 'blush.gif', ':cool:' => 'cool.gif', ':creepy:' => 'creepy.gif', ':cry:' => 'crying.gif', ':crying:' => 'crying.gif', ':flaclove:' => 'loveflac.gif', ':frown:' => 'frown.gif', ':frowning:' => 'frown.gif', ':lol:' => 'laughing.gif', ':loveflac:' => 'loveflac.gif', ':ninja:' => 'ninja.gif', ':no:' => 'no.gif', ':nod:' => 'nod.gif', ':o' => 'ohshit.gif', ':ohno:' => 'ohnoes.gif', ':ohnoes:' => 'ohnoes.gif', ':omg:' => 'omg.gif', ':p' => 'tongue.gif', ':paddle:' => 'paddle.gif', ':shifty:' => 'shifty.gif', ':sick:' => 'sick.gif', ':sorry:' => 'sorry.gif', ':thanks:' => 'thanks.gif', ':unsure:' => 'hmm.gif', ':wave:' => 'wave.gif', ':whatlove:' => 'ilu.gif', ':wink:' => 'wink.gif', ':worried:' => 'worried.gif', ':wtf:' => 'wtf.gif', ':wub:' => 'wub.gif', ':|' => 'blank.gif', ';-)' => 'wink.gif', ':\\' => 'hmm.gif', ':/' => 'hmm.gif', ]; private static array $ColorName = [ 'aliceblue', 'antiquewhite', 'aqua', 'aquamarine', 'azure', 'beige', 'bisque', 'black', 'blanchedalmond', 'blue', 'blueviolet', 'brown', 'burlywood', 'cadetblue', 'chartreuse', 'chocolate', 'coral', 'cornflowerblue', 'cornsilk', 'crimson', 'cyan', 'darkblue', 'darkcyan', 'darkgoldenrod', 'darkgray', 'darkgreen', 'darkkhaki', 'darkmagenta', 'darkolivegreen', 'darkorange', 'darkorchid', 'darkred', 'darksalmon', 'darkseagreen', 'darkslateblue', 'darkslategray', 'darkturquoise', 'darkviolet', 'deeppink', 'deepskyblue', 'dimgray', 'dodgerblue', 'firebrick', 'floralwhite', 'forestgreen', 'fuchsia', 'gainsboro', 'ghostwhite', 'gold', 'goldenrod', 'gray', 'green', 'greenyellow', 'honeydew', 'hotpink', 'indianred', 'indigo', 'ivory', 'khaki', 'lavender', 'lavenderblush', 'lawngreen', 'lemonchiffon', 'lightblue', 'lightcoral', 'lightcyan', 'lightgoldenrodyellow', 'lightgray', 'lightgreen', 'lightpink', 'lightsalmon', 'lightseagreen', 'lightskyblue', 'lightslategray', 'lightsteelblue', 'lightyellow', 'lime', 'limegreen', 'linen', 'magenta', 'maroon', 'mediumaquamarine', 'mediumblue', 'mediumorchid', 'mediumpurple', 'mediumseagreen', 'mediumslateblue', 'mediumspringgreen', 'mediumturquoise', 'mediumvioletred', 'midnightblue', 'mintcream', 'mistyrose', 'moccasin', 'navajowhite', 'navy', 'oldlace', 'olive', 'olivedrab', 'orange', 'orangered', 'orchid', 'palegoldenrod', 'palegreen', 'paleturquoise', 'palevioletred', 'papayawhip', 'peachpuff', 'peru', 'pink', 'plum', 'powderblue', 'purple', 'red', 'rosybrown', 'royalblue', 'saddlebrown', 'salmon', 'sandybrown', 'seagreen', 'seashell', 'sienna', 'silver', 'skyblue', 'slateblue', 'slategray', 'snow', 'springgreen', 'steelblue', 'tan', 'teal', 'thistle', 'tomato', 'turquoise', 'violet', 'wheat', 'white', 'whitesmoke', 'yellow', 'yellowgreen', ]; /** * Processed version of the $Smileys array, see {@link smileys} */ private static array $ProcessedSmileys = []; /** * Whether or not to turn images into URLs (used inside [quote] tags). * This is an integer reflecting the number of levels we're doing that * transition, i.e. images will only be displayed as images if $NoImg <= 0. * By setting this variable to a negative number you can delay the * transition to a deeper level of quotes. */ private static int $NoImg = 0; /** * Internal counter for the level of recursion in to_html */ private static int $Levels = 0; /** * The maximum amount of nesting allowed (exclusive) * In reality n-1 nests are shown. */ private static int $MaximumNests = 10; /** * Used to detect and disable parsing (e.g. TOC) within quotes */ private static int $InQuotes = 0; /** * Used to [hide] quote trains starting with the specified depth (inclusive) * * This defaulted to 5 but was raised to 10 to effectively "disable" it until * an optimal number of nested [quote] tags is chosen. The variable $MaximumNests * effectively overrides this variable, if $MaximumNests is less than the value * of $NestsBeforeHide. */ private static int $NestsBeforeHide = 10; /** * Array of headlines for Table Of Contents (TOC) */ private static array $Headlines = []; /** * Counter for making headline URLs unique */ private static int $HeadlineID = 0; /** * Depth */ private static array $HeadlineLevels = ['1', '2', '3', '4']; /** * TOC enabler */ public static bool $TOC = false; // Sometimes we need a viewer context to determine how to render something. // This will be configured correctly for the website, but it can be set up // manually in boris is you need to render something that requires knowledge // of a user context. But most of the time you can forget about this. private static \Gazelle\User $viewer; public static function setViewer(\Gazelle\User $viewer): void { self::$viewer = $viewer; } /** * Render BBCode as HTML without any div-like elements */ public static function span_format(string $text): string { // neuter
and self-closing elements elements $text = preg_replace('/(?:\n+|\[\s*hr[^]]*\])/i', ' ', $text); // neuter [quote] $text = preg_replace('/\[\s*\/?quote[^]]*\]/i', '', $text); // and strip the rest return strip_tags( self::full_format($text, false), [ 'b', 'cite', 'em', 'i', 'span', 'strong', ] ); } /** * Output BBCode as XHTML */ public static function full_format( ?string $Str, bool $OutputTOC = true, int $Min = 3, bool $Rules = false, bool $cache = false, CacheBucket $bucket = CacheBucket::standard, ): string { if (is_null($Str)) { return ''; } $Str = html_escape($Str); self::$Headlines = []; //Inline links $URLPrefix = '(\[url\]|\[url\=|\[img\=|\[img\])'; $Str = preg_replace('/' . $URLPrefix . '\s+/i', '$1', $Str); $Str = preg_replace('/(? str_replace('[inlineurl]', '', $m[0]), $Str, ); if (self::$TOC) { $Str = preg_replace('/(\={5})([^=].*)\1/i', '[headline=4]$2[/headline]', $Str); $Str = preg_replace('/(\={4})([^=].*)\1/i', '[headline=3]$2[/headline]', $Str); $Str = preg_replace('/(\={3})([^=].*)\1/i', '[headline=2]$2[/headline]', $Str); $Str = preg_replace('/(\={2})([^=].*)\1/i', '[headline=1]$2[/headline]', $Str); } else { $Str = preg_replace('/(\={4})([^=].*)\1/i', '[inlinesize=3]$2[/inlinesize]', $Str); $Str = preg_replace('/(\={3})([^=].*)\1/i', '[inlinesize=5]$2[/inlinesize]', $Str); $Str = preg_replace('/(\={2})([^=].*)\1/i', '[inlinesize=7]$2[/inlinesize]', $Str); } $HTML = nl2br(self::to_html(self::parse($Str), $Rules, $cache, $bucket)); if (self::$TOC && $OutputTOC) { $HTML = self::parse_toc($Min) . $HTML; } return $HTML; } public static function strip_bbcode(string $Str): string { $Str = html_escape($Str); //Inline links $Str = preg_replace('/(?findById((int)($args['id'] ?? 0))?->link(); case '/collages.php': if (!isset($args['id']) && !isset($args['collageid'])) { return "$url"; } return self::bbcodeCollageUrl((int)($args['id'] ?? $args['collageid'])); case '/forums.php': return match ($args['action'] ?? '') { 'viewforum' => self::bbcodeForumUrl((int)$args['forumid']), 'viewthread' => (function ($args) { if (isset($args['postid'])) { return self::bbcodePostUrl((int)$args['postid']); } elseif (isset($args['threadid'])) { return self::bbcodeThreadUrl((int)$args['threadid']); } return null; })($args), default => null, }; case '/torrents.php': if (isset($args['torrentid'])) { return new \Gazelle\Manager\Torrent()->findById((int)$args['torrentid'])?->link(); } elseif (isset($args['id'])) { return new \Gazelle\Manager\TGroup()->findById((int)$args['id'])?->link(); } return null; case '/wiki.php': if (($args['action'] ?? '') === 'article') { $wikiMan = new \Gazelle\Manager\Wiki(); $article = null; if (isset($args['id'])) { $article = $wikiMan->findById((int)$args['id']); } elseif (is_string($args['name'])) { $article = $wikiMan->findByAlias($args['name']); } if ($article?->readable(self::$viewer)) { return "Wiki › {$article->link()}"; } } return null; default: return null; } } /* How parsing works Parsing takes $Str, breaks it into blocks, and builds it into $Array. Blocks start at the beginning of $Str, when the parser encounters a [, and after a tag has been closed. This is all done in a loop. EXPLANATION OF PARSER LOGIC 1) Find the next tag (regex) 1a) If there aren't any tags left, write everything remaining to a block and return (done parsing) 1b) If the next tag isn't where the pointer is, write everything up to there to a text block. 2) See if it's a [[wiki-link]] or an ordinary tag, and get the tag name 3) If it's not a wiki link: 3a) check it against the self::$ValidTags array to see if it's actually a tag and not [bullshit] If it's [not a tag], just leave it as plaintext and move on 3b) Get the attribute, if it exists [name=attribute] 4) Move the pointer past the end of the tag 5) Find out where the tag closes (beginning of [/tag]) 5a) Different for different types of tag. Some tags don't close, others are weird like [*] 5b) If it's a normal tag, it may have versions of itself nested inside - e.g.: [quote=bob]* [quote=joe]I am a redneck!**[/quote] Me too! ***[/quote] If we're at the position *, the first [/quote] tag is denoted by **. However, our quote tag doesn't actually close there. We must perform a loop which checks the number of opening [quote] tags, and make sure they are all closed before we find our final [/quote] tag (***). 5c) Get the contents between [open] and [/close] and call it the block. In many cases, this will be parsed itself later on, in a new parse() call. 5d) Move the pointer past the end of the [/close] tag. 6) Depending on what type of tag we're dealing with, create an array with the attribute and block. In many cases, the block may be parsed here itself. Stick them in the $Array. 7) Increment array pointer, start again (past the end of the [/close] tag) */ public static function parse(string $Str, string $ListPrefix = ''): array { // Deal with list markup that is not list markup (e.g. [*] at EOL) // such that "abc [*]" => "abc [[n]*]" $Str = preg_replace('/(? $i) { $Array[$ArrayPos] = substr($Str, $i, $TagPos - $i); ++$ArrayPos; $i = $TagPos; } // 2) See if it's a [[wiki-link]] or an ordinary tag, and get the tag name if (!empty($Tag[4][0])) { // Wiki-link $WikiLink = true; $alias = substr($Tag[4][0], 2, -2); $article = $wikiMan->findByAlias($alias); $TagName = urlencode($alias); $Attrib = ($article instanceof \Gazelle\Wiki) ? $article : urlencode($alias); } else { // 3) If it's not a wiki link: $WikiLink = false; $TagName = strtolower(substr($Tag[2][0], 1)); //3a) check it against the self::$ValidTags array to see if it's actually a tag and not [bullshit] if (!isset(self::$ValidTags[$TagName])) { $Array[$ArrayPos] = substr($Str, $i, ($TagPos - $i) + strlen($Tag[0][0])); $i = $TagPos + strlen($Tag[0][0]); ++$ArrayPos; continue; } $MaxAttribs = self::$ValidTags[$TagName]; // 3b) Get the attribute, if it exists [name=attribute] if (!empty($Tag[3][0])) { $Attrib = substr($Tag[3][0], 1); } else { $Attrib = ''; } } // 4) Move the pointer past the end of the tag $i = $TagPos + strlen($Tag[0][0]); // 5) Find out where the tag closes (beginning of [/tag]) // Unfortunately, BBCode doesn't have nice standards like XHTML // [*], [img=...], and http:// follow different formats // Thus, we have to handle these before we handle the majority of tags //5a) Different for different types of tag. Some tags don't close, others are weird like [*] // phpcs:disable Generic.CodeAnalysis.EmptyStatement.DetectedIf // phpcs:disable Generic.CodeAnalysis.EmptyStatement.DetectedElseif if ($i >= strlen($Str)) { // document end, tag unclosed } elseif ($TagName == 'img' && !empty($Tag[3][0])) { //[img=...] $Block = ''; // Nothing inside this tag // Don't need to touch $i } elseif ($TagName == 'hr') { $Block = ''; // Nothing inside this tag either // Don't need to touch $i } elseif ($TagName == 'inlineurl') { // We did a big replace early on to turn http:// into [inlineurl]http:// // Let's say the block can stop at a newline or a space $CloseTag = strcspn($Str, " \n\r", $i); if ($CloseTag === 0) { // block finishes with URL $CloseTag = $Len; } if (preg_match('/[!,.?:]+$/', substr($Str, $i, $CloseTag), $Match)) { $CloseTag -= strlen($Match[0]); } $URL = substr($Str, $i, $CloseTag); if (str_ends_with($URL, ')') && substr_count($URL, '(') < substr_count($URL, ')')) { $CloseTag--; $URL = substr($URL, 0, -1); } $Block = $URL; // strcspn returns the number of characters after the offset $i, not after the beginning of the string // Therefore, we use += instead of the = everywhere else $i += $CloseTag; // 5d) Move the pointer past the end of the [/close] tag. } elseif ($WikiLink == true || $TagName == 'n') { // Don't need to do anything - empty tag with no closing } elseif (in_array($TagName, ['*', '#'])) { // We're in a list. Find where it ends $NewLine = $i; do { // Look for \n[*] $NewLine = strpos($Str, "\n", $NewLine + 1); } while ($NewLine !== false && substr($Str, $NewLine + 1, 1 + strlen($TagName)) == "[$TagName"); $CloseTag = $NewLine; if ($CloseTag === false) { // block finishes with list $CloseTag = $Len; } $Block = substr($Str, $i, $CloseTag - $i); // Get the list $i = $CloseTag; // 5d) Move the pointer past the end of the [/close] tag. } else { //5b) If it's a normal tag, it may have versions of itself nested inside $CloseTag = $i - 1; $InTagPos = $i - 1; $NumInOpens = 0; $NumInCloses = -1; $InOpenRegex = '/\[(' . $TagName . ')'; if ($MaxAttribs > 0) { $InOpenRegex .= "(=[^\n'\"\[\]]+)?"; } $InOpenRegex .= '\]/i'; // Every time we find an internal open tag of the same type, search for the next close tag // (as the first close tag won't do - it's been opened again) do { $CloseTag = strpos($StrLC, "[/$TagName]", $CloseTag + 1); if ($CloseTag === false) { $CloseTag = $Len; break; } else { $NumInCloses++; // Majority of cases } // Is there another open tag inside this one? $OpenTag = preg_match($InOpenRegex, $Str, $InTag, PREG_OFFSET_CAPTURE, $InTagPos + 1); if (!$OpenTag || $InTag[0][1] > $CloseTag) { break; } else { $InTagPos = $InTag[0][1]; $NumInOpens++; } } while ($NumInOpens > $NumInCloses); // Find the internal block inside the tag $Block = substr($Str, $i, $CloseTag - $i); // 5c) Get the contents between [open] and [/close] and call it the block. $i = $CloseTag + strlen($TagName) + 3; // 5d) Move the pointer past the end of the [/close] tag. } // phpcs:enable Generic.CodeAnalysis.EmptyStatement.DetectedIf // phpcs:enable Generic.CodeAnalysis.EmptyStatement.DetectedElseif // 6) Depending on what type of tag we're dealing with, create an array with the attribute and block. switch ($TagName) { case 'artist': case 'rule': case 'tex': case 'user': $Array[$ArrayPos] = ['Type' => $TagName, 'Val' => $Block]; break; case 'aud': case 'audio': case 'mp3': if (empty($Block)) { $Block = $Attrib; } $Array[$ArrayPos] = ['Type' => 'aud', 'Val' => $Block]; break; case 'box': $Array[$ArrayPos] = ['Type' => 'box', 'Val' => self::parse($Block)]; break; case 'code': case 'plain': case 'pre': $Block = strtr($Block, ['[inlineurl]' => '']); $Callback = function ($matches) { $n = $matches[2]; $text = ''; if ($n < 5 && $n > 0) { $e = str_repeat('=', (int)$matches[2] + 1); $text = $e . $matches[3] . $e; } return $text; }; $Block = preg_replace_callback('/\[(headline)\=(\d)\](.*?)\[\/\1\]/i', $Callback, $Block); $Block = preg_replace('/\[inlinesize\=3\](.*?)\[\/inlinesize\]/i', '====$1====', $Block); $Block = preg_replace('/\[inlinesize\=5\](.*?)\[\/inlinesize\]/i', '===$1===', $Block); $Block = preg_replace('/\[inlinesize\=7\](.*?)\[\/inlinesize\]/i', '==$1==', $Block); $Array[$ArrayPos] = ['Type' => $TagName, 'Val' => $Block]; break; case 'collage': case 'forum': case 'thread': if ((int)$Block or preg_match('/\s*\d+:\d+/', $Block)) { $Array[$ArrayPos] = ['Type' => $TagName, 'Val' => $Block, 'Attr' => $Attrib]; } else { $Array[$ArrayPos] = "[{$TagName}]"; $i = $TagPos + strlen($Tag[0][0]); ++$ArrayPos; } break; case 'image': case 'img': if (empty($Block)) { $Block = $Attrib; } $Array[$ArrayPos] = ['Type' => 'img', 'Val' => $Block]; break; case 'inlineurl': $Array[$ArrayPos] = ['Type' => 'inlineurl', 'Attr' => $Block, 'Val' => '']; break; case 'n': $ArrayPos--; break; // n serves only to disrupt bbcode (backwards compatibility - use [pre]) case 'pad': $Array[$ArrayPos] = ['Type' => 'pad', 'Attr' => $Attrib, 'Val' => self::parse($Block)]; break; case 'pl': case 'torrent': if ((int)$Block) { $Array[$ArrayPos] = ['Type' => $TagName, 'Val' => $Block, 'Attr' => $Attrib]; } else { $Array[$ArrayPos] = "[{$TagName}]"; $i = $TagPos + strlen($Tag[0][0]); ++$ArrayPos; } break; case 'hide': case 'mature': case 'spoiler': $Array[$ArrayPos] = ['Type' => $TagName, 'Attr' => $Attrib, 'Val' => self::parse($Block)]; break; case 'quote': $Array[$ArrayPos] = [ 'Type' => 'quote', 'Attr' => is_object($Attrib) ? $Attrib : self::parse($Attrib), 'Val' => self::parse($Block), ]; break; case 'url': $Array[$ArrayPos] = ['Type' => 'img', 'Attr' => $Attrib, 'Val' => $Block]; if (empty($Attrib)) { // [url]http://...[/url] - always set URL to attribute $Array[$ArrayPos] = ['Type' => 'url', 'Attr' => $Block, 'Val' => '']; } else { $Array[$ArrayPos] = ['Type' => 'url', 'Attr' => $Attrib, 'Val' => self::parse($Block)]; } break; case '#': case '*': case '##': case '**': case '###': case '***': $CurrentId = 1; $Array[$ArrayPos] = ['Type' => 'list']; $Array[$ArrayPos]['Val'] = explode("[$TagName]", $Block); $Array[$ArrayPos]['ListType'] = $TagName[0] === '*' ? 'ul' : 'ol'; $Array[$ArrayPos]['Tag'] = $TagName; $ChildPrefix = $ListPrefix === '' ? $ListId : $ListPrefix; if ($Attrib !== '' && !is_object($Attrib)) { $ChildPrefix = $Attrib; } foreach ($Array[$ArrayPos]['Val'] as $Key => $Val) { // phpstan complains about: // "Call to function is_string() with string will always evaluate to true." // But if you remove the call (since $Val is always supposed to be a string): // "Parameter #1 $string of function trim expects string, array|string given." // This is more a reflection on the hairiness of this code than anything else if (is_string($Val)) { /** @phpstan-ignore-line */ $Id = $ChildPrefix . '.' . $CurrentId++; $Array[$ArrayPos]['Val'][$Key] = self::parse(trim($Val), $Id); $Array[$ArrayPos]['Val'][$Key]['Id'] = $Id; } } $ListId++; break; default: if ($WikiLink == true) { $Array[$ArrayPos] = [ 'Type' => 'wiki', 'Val' => $TagName, 'Attr' => $Attrib, ]; } else { // Basic tags, like [b] or [size=5] $Array[$ArrayPos] = ['Type' => $TagName, 'Val' => self::parse($Block)]; if (!is_object($Attrib) && $MaxAttribs > 0) { $Array[$ArrayPos]['Attr'] = strtolower($Attrib); } } } $ArrayPos++; // 7) Increment array pointer, start again (past the end of the [/close] tag) } return $Array; } /** * Generates a navigation list for TOC * @param int $Min Minimum number of headlines required for a TOC list */ public static function parse_toc(int $Min = 3, bool $RulesTOC = false): string { if (count(self::$Headlines) < $Min) { return ''; } $tag = $RulesTOC ? 'ul' : 'ol'; if ($RulesTOC) { $list = "<$tag>"; } else { $list = "<$tag class=\"navigation_list\">"; } $i = 0; $level = 0; $off = 0; foreach (self::$Headlines as $t) { $n = (int)$t[0]; if ($i === 0 && $n > 1) { $off = $n - $level; } self::headline_level($n, $level, $list, $i, $off, $tag); $list .= sprintf('
  • %1$s', $t[1], $t[2]); $level = (int)$t[0]; $off = 0; $i++; } return $list . str_repeat("
  • ", $level); } /** * Generates the list items and proper depth * * First check if the item should be higher than the current level * - Close the list and previous lists * * Then check if the item should go lower than the current level * - If the list doesn't open on level one, use the Offset * - Open appropriate sub lists * * Otherwise the item is on the same as level as the previous item * * $ItemLevel Current item level * $Level Current list level * $List reference to an XHTML string * $i Iterator digit * $Offset If the list doesn't start at level 1 */ private static function headline_level(int &$ItemLevel, int &$Level, string &$List, int $i, int &$Offset, string $Tag): void { if ($ItemLevel < $Level) { $diff = $Level - $ItemLevel; $List .= '' . str_repeat("", $diff); } elseif ($ItemLevel > $Level) { $diff = $ItemLevel - $Level; if ($Offset > 0) { $List .= str_repeat("
  • <$Tag>", $Offset - 2); } if ($ItemLevel > 1) { $List .= $i === 0 ? '
  • ' : ''; $List .= "\n<$Tag>\n"; } } else { $List .= $i > 0 ? '
  • ' : '
  • '; } } private static function to_html(array $Array, bool $Rules, bool $cache, CacheBucket $bucket): string { self::$Levels++; /* * Hax prevention * That's the original comment on this. * Most likely this was implemented to avoid anyone nesting enough * elements to reach PHP's memory limit as nested elements are * solved recursively. * Original value of 10, it is now replaced in favor of * $MaximumNests. * If this line is ever executed then something is, infact * being haxed as the if before the block type switch for different * tags should always be limiting ahead of this line. * (Larger than vs. smaller than.) */ if (self::$Levels > self::$MaximumNests) { return $Array['Val']; // Hax prevention, breaks upon exceeding nests. } $Str = ''; if (isset($Array['Id'])) { if (isset($Array[0]) && is_string($Array[0]) && count($Array) == 2) { self::$Levels--; return self::smileys(self::userMention($Array[0])); } } foreach ($Array as $Key => $Block) { if ($Key === 'Id') { continue; } if (is_string($Block)) { $escaped = str_replace( // See if there are URLs and prevent :/ from frowning ['http://', 'https://'], ['http:[n]//', 'https:[n]//'], $Block, ); $chunk = self::smileys(self::userMention($escaped)); $Str .= str_replace( // and revert ['http:[n]//', 'https:[n]//'], ['http://', 'https://'], $chunk, ); self::$Levels--; continue; } if (self::$Levels < self::$MaximumNests) { switch ($Block['Type']) { case 'b': $Str .= '' . self::to_html($Block['Val'], $Rules, $cache, $bucket) . ''; break; case 'u': $Str .= '' . self::to_html($Block['Val'], $Rules, $cache, $bucket) . ''; break; case 'i': $Str .= '' . self::to_html($Block['Val'], $Rules, $cache, $bucket) . ""; break; case 's': $Str .= '' . self::to_html($Block['Val'], $Rules, $cache, $bucket) . ''; break; case 'hr': $Str .= '
    '; break; case 'important': $Str .= '' . self::to_html($Block['Val'], $Rules, $cache, $bucket) . ''; break; case 'user': $Str .= '' . $Block['Val'] . ''; break; case 'artist': $Str .= '' . $Block['Val'] . ''; break; case 'rule': $Rule = trim(strtolower($Block['Val'])); if (!preg_match('/^[hr]/', $Rule)) { $Rule = "r$Rule"; } $Str .= '' . preg_replace('/[aA-zZ]/', '', $Block['Val']) . ''; break; case 'collage': $Str .= self::bbcodeCollageUrl((int)$Block['Val']); break; case 'forum': $Str .= self::bbcodeForumUrl((int)$Block['Val']); break; case 'thread': $Str .= self::bbcodeThreadUrl((string)$Block['Val']); break; case 'pl': $found = preg_split('/\s*,\s*/m', strtolower($Block['Attr']), -1, PREG_SPLIT_NO_EMPTY); if ($found !== false) { $Str .= \Gazelle\Manager\Torrent::renderPL((int)$Block['Val'], $found); } else { $Str .= "[pl]{$Block['Val']}[/pl]"; } break; case 'torrent': $GroupID = 0; if (preg_match(TGROUP_REGEXP, $Block['Val'], $match)) { $GroupID = $match['id']; } elseif ((int)$Block['Val']) { $GroupID = $Block['Val']; } $tgroup = new Gazelle\Manager\TGroup()->findById((int)$GroupID); if (is_null($tgroup)) { $Str .= '[torrent]' . str_replace('[inlineurl]', '', (string)$Block['Val']) . '[/torrent]'; } else { if (str_contains($Block['Attr'], 'noartist')) { $Str .= "url()}\" title=\"" . ($tgroup->hashTag() ?: 'View torrent group') . '" dir="ltr">' . html_escape($tgroup->name()) . ''; } else { $Str .= $tgroup->link(); } } break; case 'wiki': $Str .= "Wiki › " . ($Block['Attr'] instanceof \Gazelle\Wiki ? $Block['Attr']->link() : "[[{$Block['Attr']} ???]]" ); break; case 'tex': $Str .= '' . $Block['Val'] . ''; break; case 'plain': $Str .= $Block['Val']; break; case 'pre': $Str .= '
    ' . $Block['Val'] . '
    '; break; case 'code': $Str .= '' . $Block['Val'] . ''; break; case 'list': $Str .= "<{$Block['ListType']} class=\"postlist\">"; foreach ($Block['Val'] as $Line) { $Str .= '' . self::to_html($Line, $Rules, $cache, $bucket) . '
  • '; } $Str .= ''; break; case 'align': $ValidAttribs = ['left', 'center', 'right']; if (!in_array($Block['Attr'], $ValidAttribs)) { $Str .= '[align=' . $Block['Attr'] . ']' . self::to_html($Block['Val'], $Rules, $cache, $bucket) . '[/align]'; } else { $Str .= '
    ' . self::to_html($Block['Val'], $Rules, $cache, $bucket) . '
    '; } break; case 'color': case 'colour': $Block['Attr'] = strtolower($Block['Attr']); if (!in_array($Block['Attr'], self::$ColorName) && !preg_match('/^#[0-9a-f]{6}$/', $Block['Attr'])) { $Str .= '[color=' . $Block['Attr'] . ']' . self::to_html($Block['Val'], $Rules, $cache, $bucket) . '[/color]'; } else { $Str .= '' . self::to_html($Block['Val'], $Rules, $cache, $bucket) . ''; } break; case 'headline': $text = self::to_html($Block['Val'], $Rules, $cache, $bucket); $raw = self::raw_text($Block['Val']); if (!in_array($Block['Attr'], self::$HeadlineLevels)) { $Str .= sprintf('%1$s%2$s%1$s', str_repeat('=', (int)$Block['Attr'] + 1), $text); } else { $id = '_' . crc32($raw . self::$HeadlineID); if (self::$InQuotes === 0) { self::$Headlines[] = [$Block['Attr'], $raw, $id]; } $Str .= sprintf('%2$s', ((int)$Block['Attr'] + 2), $text, $id); self::$HeadlineID++; } break; case 'inlinesize': case 'size': $ValidAttribs = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10']; if (!in_array($Block['Attr'], $ValidAttribs)) { $Str .= '[size=' . $Block['Attr'] . ']' . self::to_html($Block['Val'], $Rules, $cache, $bucket) . '[/size]'; } else { $Str .= '' . self::to_html($Block['Val'], $Rules, $cache, $bucket) . ''; } break; case 'quote': self::$NoImg++; // No images inside quote tags self::$InQuotes++; if (self::$InQuotes == self::$NestsBeforeHide) { //Put quotes that are nested beyond the specified limit in [hide] tags. $Str .= 'Older quotes: Show
    '; // Ensure new line after quote train hiding } self::$NoImg--; self::$InQuotes--; break; case 'box': $Str .= '
    ' . self::to_html($Block['Val'], $Rules, $cache, $bucket) . '
    '; break; case 'pad': $Attr = array_filter(explode('|', $Block['Attr'] ?? ''), fn($x) => is_numeric($x) && (float)$x >= 0); if (count($Attr) === 0) { $Str .= self::to_html($Block['Val'], $Rules, $cache, $bucket); } else { $Padding = implode(' ', array_map(fn($x) => "{$x}px", $Attr)); $Str .= "" . self::to_html($Block['Val'], $Rules, $cache, $bucket) . ''; } break; case 'hide': case 'spoiler': $Str .= '' . ($Block['Attr'] ?: 'Hidden text') . ': Show'; $Str .= ''; break; case 'mature': if (self::$viewer->option('EnableMatureContent')) { if (!empty($Block['Attr'])) { $Str .= 'Mature content: ' . $Block['Attr'] . '
    Show'; $Str .= ''; } else { $Str .= 'Use of the [mature] tag requires a description. The correct format is as follows: [mature=description] ...content... [/mature], where "description" is a mandatory description of the post. Misleading descriptions will be penalized. For further information on our mature content policies, please refer to this wiki.'; } } else { $Str .= 'Mature content has been blocked. You can choose to view mature content by editing your settings.'; } break; case 'img': if (self::$NoImg > 0 && self::valid_url($Block['Val'])) { $Str .= '' . $Block['Val'] . ' (image)'; break; } if (!self::valid_url($Block['Val'], '\.(?:avif|bmp|gif|jpe?g|png|svg|tiff|webp)')) { $Str .= "[img]{$Block['Val']}[/img]"; } else { $LocalURL = self::local_url($Block['Val']); if ($LocalURL) { $Str .= '' . $Block['Val'] . ''; } else { if ($cache) { $image = image_cache_encode($Block['Val'], bucket: $bucket); $original = " data-origin-src=\"{$Block['Val']}\""; } else { $image = $Block['Val']; $original = ""; } $Str .= "\"$image\""; } } break; case 'aud': if (self::$NoImg > 0 && self::valid_url($Block['Val'])) { $Str .= '' . $Block['Val'] . ' (audio)'; break; } if (!self::valid_url($Block['Val'], '\.(?:mp3|ogg|wav)')) { $Str .= '[aud]' . $Block['Val'] . '[/aud]'; } else { //TODO: Proxy this for staff? $Str .= ''; } break; case 'url': // Make sure the URL has a label if (empty($Block['Val'])) { $Block['Val'] = $Block['Attr']; $NoName = true; // If there isn't a Val for this } else { $Block['Val'] = self::to_html($Block['Val'], $Rules, $cache, $bucket); $NoName = false; } if (!self::valid_url($Block['Attr'])) { if (self::relative_url($Block['Attr'])) { $Str .= '' . $Block['Val'] . ''; } else { $Str .= '[url=' . $Block['Attr'] . ']' . $Block['Val'] . '[/url]'; } } else { $LocalURL = self::local_url($Block['Attr']); if ($LocalURL) { if ($NoName) { $Block['Val'] = substr($LocalURL, 1); } if ($resolved = self::resolve_url($Block['Val'])) { $Str .= $resolved; } else { $Str .= '' . $Block['Val'] . ''; } } else { if ($resolved = self::resolve_url($Block['Val'])) { $Str .= $resolved; } else { $Str .= '' . $Block['Val'] . ''; } } } break; case 'inlineurl': if (!self::valid_url($Block['Attr'], '', true)) { $Array = self::parse($Block['Attr']); $Block['Attr'] = $Array; $Str .= self::to_html($Block['Attr'], $Rules, $cache, $bucket); } else { $LocalURL = self::local_url($Block['Attr']); if ($LocalURL) { $Str .= self::resolve_url($Block['Attr']) ?? ('' . substr($LocalURL, 1) . ''); } else { $Str .= self::resolve_url($Block['Attr']) ?? sprintf('%s', $Block['Attr'], $Block['Attr']); } } break; } } } self::$Levels--; return $Str; } private static function raw_text(array $Array): string { $Str = ''; foreach ($Array as $Block) { if (is_string($Block)) { $Str .= $Block; continue; } switch ($Block['Type']) { case 'headline': case 'tex': //since this will never strip cleanly, just remove it break; case 'b': case 'u': case 'i': case 's': case 'color': case 'size': case 'quote': case 'align': case 'pad': $Str .= self::raw_text($Block['Val']); break; case 'artist': case 'user': case 'wiki': case 'pre': case 'code': case 'aud': case 'img': $Str .= $Block['Val']; break; case 'list': foreach ($Block['Val'] as $Line) { $Str .= $Block['Tag'] . self::raw_text($Line); } break; case 'url': // Make sure the URL has a label if (empty($Block['Val'])) { $Block['Val'] = $Block['Attr']; } else { $Block['Val'] = self::raw_text($Block['Val']); } $Str .= $Block['Val']; break; case 'inlineurl': if (!self::valid_url($Block['Attr'], '', true)) { $Array = self::parse($Block['Attr']); $Block['Attr'] = $Array; $Str .= self::raw_text($Block['Attr']); } else { $Str .= $Block['Attr']; } break; } } return $Str; } private static function userMention(string $text): string { return preg_replace_callback('/(?<=^|\W)@' . str_replace('/', '', USERNAME_REGEXP) . '/', function ($match) { $username = $match['username']; static $cache; if (!isset($cache[$username])) { $userMan = new \Gazelle\Manager\User(); $user = $userMan->findByUsername($username); if (is_null($user) && preg_match('/^(.*)[.?]+$/', $username, $match)) { // strip off trailing dots to see if we can match @Spine... $username = $match[1]; $user = $userMan->findByUsername($username); } if ($user) { $cache[$username] = $user; } } return !isset($cache[$username]) ? "@$username" : sprintf('@%s', $cache[$username]->url(), $cache[$username]->username()); }, $text ); } private static function smileys(string $Str): string { if (!isset(self::$viewer) || self::$viewer->option('DisableSmileys')) { return $Str; } if (count(self::$ProcessedSmileys) == 0 && count(self::$Smileys) > 0) { foreach (self::$Smileys as $Key => $Val) { self::$ProcessedSmileys[$Key] = '"; } reset(self::$ProcessedSmileys); } return strtr($Str, self::$ProcessedSmileys); } protected static function bbcodeCollageUrl(int $id): string { $collage = new \Gazelle\Manager\Collage()->findById($id); return $collage?->link() ?? "[collage]{$id}[/collage]"; } protected static function bbcodeForumUrl(int $id): string { $forum = new \Gazelle\Manager\Forum()->findById($id); if (is_null($forum)) { return "[forum]{$id}[/forum]"; } if (!self::$viewer->readAccess($forum)) { return "[restricted forum]"; } return $forum->link(); } protected static function bbcodeCommentUrl(int $postId): ?string { $post = new Gazelle\Manager\Comment()->findById($postId); if (is_null($post)) { return null; } // FIXME: this should give context about where the comment was posted (artist/collage/request/tgroup name) return sprintf('%s Comment #%s', $post->url(), ucfirst($post->page()), $postId); } protected static function bbcodePostUrl(int $postId): ?string { $post = new \Gazelle\Manager\ForumPost()->findById($postId); if (is_null($post)) { return null; } return self::bbcodeThreadUrl($post->threadId(), $post->id); } protected static function bbcodeThreadUrl(int|string $thread, int|null $postId = null): string { if (is_string($thread) && str_contains($thread, ':')) { [$threadId, $postId] = array_map('intval', explode(':', $thread)); } else { $threadId = (int)$thread; } if (!$threadId) { return "[thread]{$thread}[/thread]"; } $thread = new \Gazelle\Manager\ForumThread()->findById($threadId); if (is_null($thread)) { return $postId ? "[thread]{$threadId}:{$postId}[/thread]" : "[thread]{$threadId}[/thread]"; } if (!self::$viewer->readAccess($thread->forum())) { return sprintf('%s', $thread->forum()->id, 'restricted'); } if ($postId) { return sprintf('%s%s (Post #%s)', $threadId, $postId, $postId, ($thread->isLocked() ? "🔒 " : ''), $thread->title(), $postId); } return sprintf('%s%s', $threadId, ($thread->isLocked() ? "🔒 " : ''), $thread->title()); } }