| 1 | <?php |
| 2 | /************************************************************* |
| 3 | # 076 License |
| 4 | |
| 5 | Copyright (c) テクニカル諏訪子 |
| 6 | |
| 7 | Permission is hereby granted to any person obtaining a copy of the software |
| 8 | Little Beast (the "Software") to use, modify, merge, copy, publish, distribute, |
| 9 | sublicense, and/or sell copies of the Software, subject to the following conditions: |
| 10 | |
| 11 | 1. **Origin Attribution**: |
| 12 | - You must not misrepresent the origin of the Software; you must not claim |
| 13 | you created the original Software. |
| 14 | - If the Software is used in a product, you must either: |
| 15 | a. Provide clear attribution in the product's documentation, user interface, |
| 16 | or other visible areas, **OR** |
| 17 | b. Pay the original developers a fee they specify in writing. |
| 18 | 2. **Usage Restriction**: |
| 19 | - The Software, or any derivative works, dependencies, or libraries |
| 20 | incorporating it, must not be used for censorship or to suppress freedom of |
| 21 | speech, expression, or creativity. Prohibited uses include, but are not |
| 22 | limited to: |
| 23 | - Censorship of so-called "hate speech", visuals, non-mainstream opinions, |
| 24 | ideas, or objective reality. |
| 25 | - Tools or systems designed to restrict access to information or |
| 26 | artistic works. |
| 27 | 3. **Notice Preservation**: |
| 28 | - This license and the above copyright notice must remain intact in all copies |
| 29 | of the source code. |
| 30 | |
| 31 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, |
| 32 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A |
| 33 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT |
| 34 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF |
| 35 | CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE |
| 36 | OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. |
| 37 | */ |
| 38 | namespace Std\Lib\Fedi; |
| 39 | |
| 40 | /** |
| 41 | * 投稿の本文のHTML |
| 42 | * |
| 43 | * render():ローカルの投稿(Markdown)をHTMLにする(リンク、メンション、ハッシュタグ) |
| 44 | * sanitize():リモートの投稿のHTMLを許可したタグだけにする(XSS対策) |
| 45 | */ |
| 46 | final class Html { |
| 47 | /** タグ => 許可する属性 */ |
| 48 | private const ALLOWED = [ |
| 49 | 'p' => [], 'br' => [], 'a' => ['href', 'class', 'rel'], 'span' => ['class'], |
| 50 | 'strong' => [], 'b' => [], 'em' => [], 'i' => [], 'u' => [], 's' => [], 'del' => [], |
| 51 | 'code' => [], 'pre' => [], 'blockquote' => [], 'ul' => [], 'ol' => [], 'li' => [], |
| 52 | ]; |
| 53 | /** 中身ごと消すタグ */ |
| 54 | private const DROP = ['script', 'style', 'iframe', 'object', 'embed', 'template', 'svg', 'math', 'form', 'textarea', 'select', 'button', 'noscript']; |
| 55 | /** 許可するクラス(Mastodonのメンション等) */ |
| 56 | private const CLASSES = ['mention', 'hashtag', 'h-card', 'u-url', 'invisible', 'ellipsis']; |
| 57 | |
| 58 | // 区切り文字なしの正規表現(組み合わせて使う) |
| 59 | private const URL = '\bhttps?://[^\s<>"\' \x00]+'; |
| 60 | private const MENTION = '(?<![\w@/])@([A-Za-z0-9_]+)(?:@([A-Za-z0-9.-]+\.[A-Za-z]{2,}(?::\d+)?))?(?![@\w])'; |
| 61 | private const TAG = '(?<![\w#&/])#([\p{L}\p{N}_]{1,64})'; |
| 62 | private const URL_RE = '~'.self::URL.'~u'; |
| 63 | public const MENTION_RE = '~'.self::MENTION.'~u'; |
| 64 | public const TAG_RE = '~'.self::TAG.'~u'; |
| 65 | |
| 66 | /** |
| 67 | * プレーンテキストからメンションを取り出す |
| 68 | * |
| 69 | * @return array [「@user」又は「@user@domain」 => [user, domain|null]] |
| 70 | */ |
| 71 | public static function mentions(string $text): array { |
| 72 | $text = preg_replace(self::URL_RE, ' ', self::withoutCode($text)); |
| 73 | preg_match_all(self::MENTION_RE, $text, $m, PREG_SET_ORDER); |
| 74 | $out = []; |
| 75 | foreach ($m as $x) $out[$x[0]] = [$x[1], isset($x[2]) && $x[2] !== '' ? strtolower($x[2]) : null]; |
| 76 | return $out; |
| 77 | } |
| 78 | |
| 79 | /** |
| 80 | * プレーンテキストからハッシュタグを取り出す |
| 81 | * |
| 82 | * @return array タグ(「#」なし、小文字、重複なし) |
| 83 | */ |
| 84 | public static function hashtags(string $text): array { |
| 85 | $text = preg_replace(self::URL_RE, ' ', self::withoutCode($text)); |
| 86 | preg_match_all(self::TAG_RE, $text, $m); |
| 87 | return array_values(array_unique(array_map(fn($t) => mb_strtolower($t), $m[1]))); |
| 88 | } |
| 89 | |
| 90 | /** |
| 91 | * 本文(Markdown)をHTMLにする |
| 92 | * |
| 93 | * @param string $text 本文 |
| 94 | * @param array $mentionUrls 「@user@domain」 => アクターのURL(解決出来たメンションだけリンクにする) |
| 95 | * @param bool $hashtags ハッシュタグをマイクロブログのタグのページへのリンクにする(記事のコメント等ではfalse) |
| 96 | * @return string HTML |
| 97 | */ |
| 98 | public static function render(string $text, array $mentionUrls = [], bool $hashtags = true): string { |
| 99 | $text = str_replace(["\r\n", "\r", "\0"], ["\n", "\n", ''], trim($text)); |
| 100 | return self::blocks(explode("\n", $text), $mentionUrls, 0, $hashtags); |
| 101 | } |
| 102 | |
| 103 | /** |
| 104 | * Markdownのブロック:段落(空行で区切り、改行は<br>)、```コード```、> 引用、- と 1. のリスト |
| 105 | * 見出し・表・画像は他のサーバー(Mastodon等)で表示出来ないので扱わない |
| 106 | */ |
| 107 | private static function blocks(array $lines, array $mentionUrls, int $depth, bool $hashtags = true): string { |
| 108 | $out = ''; |
| 109 | $para = []; |
| 110 | $flush = function() use (&$para, &$out, $mentionUrls, $hashtags): void { |
| 111 | if ($para) $out .= '<p>'.implode('<br />', array_map(fn($l) => self::inline($l, $mentionUrls, $hashtags), $para)).'</p>'; |
| 112 | $para = []; |
| 113 | }; |
| 114 | $n = count($lines); |
| 115 | for ($i = 0; $i < $n; $i++) { |
| 116 | $line = $lines[$i]; |
| 117 | if (str_starts_with($line, '```')) { |
| 118 | $flush(); |
| 119 | $code = []; |
| 120 | for ($i++; $i < $n && !preg_match('/^```\s*$/', $lines[$i]); $i++) $code[] = $lines[$i]; |
| 121 | $out .= '<pre><code>'.self::esc(implode("\n", $code)).'</code></pre>'; |
| 122 | continue; |
| 123 | } |
| 124 | if ($depth < 3 && preg_match('/^>/', $line)) { |
| 125 | $flush(); |
| 126 | $quote = []; |
| 127 | for (; $i < $n && preg_match('/^> ?(.*)$/', $lines[$i], $m); $i++) $quote[] = $m[1]; |
| 128 | $i--; |
| 129 | $out .= '<blockquote>'.self::blocks($quote, $mentionUrls, $depth + 1, $hashtags).'</blockquote>'; |
| 130 | continue; |
| 131 | } |
| 132 | if (preg_match('/^(?:[-*+]|\d{1,9}[.)])\s+\S/', $line)) { |
| 133 | $flush(); |
| 134 | $ordered = ctype_digit($line[0]); |
| 135 | $item = $ordered ? '/^\d{1,9}[.)]\s+(.*)$/' : '/^[-*+]\s+(.*)$/'; |
| 136 | $items = ''; |
| 137 | for (; $i < $n && preg_match($item, $lines[$i], $m); $i++) $items .= '<li>'.self::inline($m[1], $mentionUrls, $hashtags).'</li>'; |
| 138 | $i--; |
| 139 | $out .= $ordered ? "<ol>{$items}</ol>" : "<ul>{$items}</ul>"; |
| 140 | continue; |
| 141 | } |
| 142 | if (trim($line) === '') { |
| 143 | $flush(); |
| 144 | continue; |
| 145 | } |
| 146 | $para[] = $line; |
| 147 | } |
| 148 | $flush(); |
| 149 | return $out; |
| 150 | } |
| 151 | |
| 152 | /** |
| 153 | * 1行の中:`コード`、[文字](URL)、\記号、URL・メンション・ハッシュタグ、**太字**、*斜体*、~~取り消し線~~ |
| 154 | * 変換した部分は「\0番号\0」で預かり、エスケープと強調の後で戻す |
| 155 | */ |
| 156 | private static function inline(string $text, array $mentionUrls, bool $hashtags = true): string { |
| 157 | $keep = []; |
| 158 | $hold = function(string $html) use (&$keep): string { |
| 159 | $keep[] = $html; |
| 160 | return "\0".(count($keep) - 1)."\0"; |
| 161 | }; |
| 162 | |
| 163 | $text = preg_replace_callback('/`([^`]+)`/', fn($m) => $hold('<code>'.self::esc($m[1]).'</code>'), $text); |
| 164 | $text = preg_replace_callback('~\[([^\]]+)\]\((https?://[^\s()<>"\']+)\)~u', |
| 165 | fn($m) => $hold('<a href="'.self::esc($m[2]).'" rel="nofollow noopener noreferrer">'.self::emphasis(self::esc($m[1])).'</a>'), $text); |
| 166 | $text = preg_replace_callback('/\\\\([\\\\`*_~\[\]()#@>+-])/', fn($m) => $hold(self::esc($m[1])), $text); |
| 167 | |
| 168 | // URL・メンション・ハッシュタグを1回で置き換える(URLの中の「#」や「@」を変換しない様に) |
| 169 | // グループ:1=URL、2=ユーザー、3=ドメイン、4=タグ |
| 170 | $re = '~('.self::URL.')|'.self::MENTION.'|'.self::TAG.'~u'; |
| 171 | $out = ''; |
| 172 | $pos = 0; |
| 173 | preg_match_all($re, $text, $ms, PREG_SET_ORDER | PREG_OFFSET_CAPTURE); |
| 174 | foreach ($ms as $m) { |
| 175 | [$whole, $at] = $m[0]; |
| 176 | $out .= self::esc(substr($text, $pos, $at - $pos)); |
| 177 | $pos = $at + strlen($whole); |
| 178 | |
| 179 | if (($m[1][0] ?? '') !== '') { |
| 180 | // 文末の句読点はURLに含めない |
| 181 | $url = rtrim($whole, '.,!?)。、」』'); |
| 182 | $pos -= strlen($whole) - strlen($url); |
| 183 | $shown = preg_replace('#^https?://#', '', $url); |
| 184 | if (mb_strlen($shown) > 40) $shown = mb_substr($shown, 0, 40).'…'; |
| 185 | $out .= $hold('<a href="'.self::esc($url).'" rel="nofollow noopener noreferrer">'.self::esc($shown).'</a>'); |
| 186 | } else if (($m[2][0] ?? '') !== '') { |
| 187 | $url = $mentionUrls[$whole] ?? $mentionUrls[strtolower($whole)] ?? null; |
| 188 | // 解決出来なかったメンションはリンクにしない |
| 189 | $out .= $hold($url === null ? self::esc($whole) |
| 190 | : '<span class="h-card"><a href="'.self::esc($url).'" class="u-url mention">@<span>'.self::esc($m[2][0]).'</span></a></span>'); |
| 191 | } else if (!$hashtags) { |
| 192 | $out .= self::esc($whole); |
| 193 | } else { |
| 194 | $tag = $m[4][0]; |
| 195 | $out .= $hold('<a href="'.self::esc(Fedi::base().'/microblog/tag/'.rawurlencode(mb_strtolower($tag))).'" class="mention hashtag" rel="tag">#<span>'.self::esc($tag).'</span></a>'); |
| 196 | } |
| 197 | } |
| 198 | $out = self::emphasis($out.self::esc(substr($text, $pos))); |
| 199 | |
| 200 | // 預けた部分の中に預けた部分がある(リンクの中のコード等)ので、無くなるまで戻す |
| 201 | for ($k = 0; $k < 4 && str_contains($out, "\0"); $k++) { |
| 202 | $out = preg_replace_callback('/\0(\d+)\0/', fn($m) => $keep[(int)$m[1]] ?? '', $out); |
| 203 | } |
| 204 | return $out; |
| 205 | } |
| 206 | |
| 207 | /** **太字**、__太字__、~~取り消し線~~、*斜体*、_斜体_(「_」は単語の中では使わない:snake_case等) */ |
| 208 | private static function emphasis(string $s): string { |
| 209 | $s = preg_replace('/\*\*(?=\S)(.+?)(?<=\S)\*\*/u', '<strong>$1</strong>', $s); |
| 210 | $s = preg_replace('/(?<!\w)__(?=\S)(.+?)(?<=\S)__(?!\w)/u', '<strong>$1</strong>', $s); |
| 211 | $s = preg_replace('/~~(?=\S)(.+?)(?<=\S)~~/u', '<del>$1</del>', $s); |
| 212 | $s = preg_replace('/(?<!\*)\*(?=[^\s*])(.+?)(?<=[^\s*])\*(?!\*)/u', '<em>$1</em>', $s); |
| 213 | return preg_replace('/(?<!\w)_(?=[^\s_])(.+?)(?<=[^\s_])_(?!\w)/u', '<em>$1</em>', $s); |
| 214 | } |
| 215 | |
| 216 | /** コードの中のメンションとハッシュタグは使わない */ |
| 217 | private static function withoutCode(string $text): string { |
| 218 | $text = preg_replace('/^```.*?(?:^```[ \t]*$|\z)/msu', ' ', $text); |
| 219 | return preg_replace('/`[^`]+`/u', ' ', $text); |
| 220 | } |
| 221 | |
| 222 | /** |
| 223 | * リモートのHTMLを安全にする |
| 224 | * |
| 225 | * @param string $html HTML |
| 226 | * @return string 許可したタグと属性だけのHTML |
| 227 | */ |
| 228 | public static function sanitize(string $html): string { |
| 229 | if ($html === '') return ''; |
| 230 | if (!class_exists(\DOMDocument::class)) { |
| 231 | // DOMが無い環境:タグを全部外す |
| 232 | return '<p>'.nl2br(self::esc(html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5, 'UTF-8')), false).'</p>'; |
| 233 | } |
| 234 | |
| 235 | $doc = new \DOMDocument(); |
| 236 | $prev = libxml_use_internal_errors(true); |
| 237 | $doc->loadHTML('<?xml encoding="utf-8"?><div id="root">'.$html.'</div>', LIBXML_NONET | LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD); |
| 238 | libxml_clear_errors(); |
| 239 | libxml_use_internal_errors($prev); |
| 240 | |
| 241 | $root = $doc->getElementById('root') ?? $doc->documentElement; |
| 242 | if (!$root) return ''; |
| 243 | $out = ''; |
| 244 | foreach ($root->childNodes as $child) $out .= self::clean($child); |
| 245 | return $out; |
| 246 | } |
| 247 | |
| 248 | /** |
| 249 | * HTMLからタグを外した文(検索や通知用) |
| 250 | */ |
| 251 | public static function text(string $html, int $max = 0): string { |
| 252 | $html = preg_replace('#<br\s*/?>|</p>#i', "\n", $html); |
| 253 | $t = trim(html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5, 'UTF-8')); |
| 254 | if ($max > 0 && mb_strlen($t) > $max) $t = mb_substr($t, 0, $max).'…'; |
| 255 | return $t; |
| 256 | } |
| 257 | |
| 258 | public static function esc(string $s): string { |
| 259 | return htmlspecialchars($s, ENT_QUOTES | ENT_HTML5, 'UTF-8'); |
| 260 | } |
| 261 | |
| 262 | /////// |
| 263 | |
| 264 | private static function clean(\DOMNode $node): string { |
| 265 | if ($node instanceof \DOMText) return self::esc($node->nodeValue ?? ''); |
| 266 | if (!$node instanceof \DOMElement) return ''; |
| 267 | |
| 268 | $tag = strtolower($node->nodeName); |
| 269 | if (in_array($tag, self::DROP, true)) return ''; |
| 270 | |
| 271 | $inner = ''; |
| 272 | foreach ($node->childNodes as $c) $inner .= self::clean($c); |
| 273 | |
| 274 | // 許可しないタグは中身だけ残す(見出し等は段落にする) |
| 275 | if (!isset(self::ALLOWED[$tag])) { |
| 276 | return in_array($tag, ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'div'], true) ? "<p>{$inner}</p>" : $inner; |
| 277 | } |
| 278 | if ($tag === 'br') return '<br />'; |
| 279 | |
| 280 | $attrs = ''; |
| 281 | foreach (self::ALLOWED[$tag] as $name) { |
| 282 | if (!$node->hasAttribute($name)) continue; |
| 283 | $v = trim($node->getAttribute($name)); |
| 284 | if ($name === 'href') { |
| 285 | if (!preg_match('#^https?://#i', $v)) continue; |
| 286 | } else if ($name === 'class') { |
| 287 | $v = implode(' ', array_intersect(preg_split('/\s+/', $v), self::CLASSES)); |
| 288 | if ($v === '') continue; |
| 289 | } else if ($name === 'rel') { |
| 290 | continue; // 下で付ける |
| 291 | } |
| 292 | $attrs .= ' '.$name.'="'.self::esc($v).'"'; |
| 293 | } |
| 294 | if ($tag === 'a') $attrs .= ' rel="nofollow noopener noreferrer"'; |
| 295 | |
| 296 | return "<{$tag}{$attrs}>{$inner}</{$tag}>"; |
| 297 | } |
| 298 | } |