- Descriptions now render Markdown or sanitized HTML via [includes/db.php](/Users/tyemeclifford/Documents/GH/mediaplayer/includes/db.php).
Public player descriptions render formatted HTML on the homepage. Catalogue cards, embed meta tags, and social metadata use safe plain-text excerpts. Add/edit admin pages now use a rich description editor that preserves pasted HTML styling, with a textarea fallback for no-JS. External/YouTube-imported descriptions are sanitized before storage. README now documents Markdown/HTML description support.
This commit is contained in:
+420
-4
@@ -595,6 +595,420 @@ function media_pick_longest_text(string $current, string $candidate): string {
|
||||
return strlen($candidate) > strlen($current) ? $candidate : $current;
|
||||
}
|
||||
|
||||
function media_description_contains_html(string $value): bool {
|
||||
return (bool)preg_match('/<\/?[a-z][a-z0-9:-]*(?:\s[^>]*)?>/i', $value);
|
||||
}
|
||||
|
||||
function media_pick_longest_description(string $current, string $candidate): string {
|
||||
$candidate = trim($candidate);
|
||||
if ($candidate === '') return $current;
|
||||
$current_length = strlen(media_description_plain_text($current));
|
||||
$candidate_length = strlen(media_description_plain_text($candidate));
|
||||
return $candidate_length > $current_length ? $candidate : $current;
|
||||
}
|
||||
|
||||
function media_prepare_description_for_storage(string $value): string {
|
||||
$value = trim($value);
|
||||
if ($value === '') return '';
|
||||
return media_description_contains_html($value) ? media_sanitize_html($value) : $value;
|
||||
}
|
||||
|
||||
function media_description_plain_text(string $value): string {
|
||||
$value = trim($value);
|
||||
if ($value === '') return '';
|
||||
$html = media_description_html($value);
|
||||
$text = html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
$text = preg_replace('/[ \t]+/', ' ', $text);
|
||||
$text = preg_replace('/\s*\n\s*/', ' ', (string)$text);
|
||||
return trim((string)$text);
|
||||
}
|
||||
|
||||
function media_description_html(string $value): string {
|
||||
$value = trim($value);
|
||||
if ($value === '') return '';
|
||||
$html = media_description_contains_html($value)
|
||||
? $value
|
||||
: media_markdown_to_html($value);
|
||||
return media_sanitize_html($html);
|
||||
}
|
||||
|
||||
function media_markdown_to_html(string $markdown): string {
|
||||
$markdown = str_replace(["\r\n", "\r"], "\n", trim($markdown));
|
||||
if ($markdown === '') return '';
|
||||
|
||||
$lines = explode("\n", $markdown);
|
||||
$html = [];
|
||||
$paragraph = [];
|
||||
$blockquote = [];
|
||||
$list_type = '';
|
||||
$in_code = false;
|
||||
$code_lines = [];
|
||||
|
||||
$flush_paragraph = function () use (&$html, &$paragraph): void {
|
||||
if (!$paragraph) return;
|
||||
$parts = array_map('media_markdown_inline', $paragraph);
|
||||
$html[] = '<p>' . implode('<br>', $parts) . '</p>';
|
||||
$paragraph = [];
|
||||
};
|
||||
$flush_blockquote = function () use (&$html, &$blockquote): void {
|
||||
if (!$blockquote) return;
|
||||
$parts = array_map('media_markdown_inline', $blockquote);
|
||||
$html[] = '<blockquote><p>' . implode('<br>', $parts) . '</p></blockquote>';
|
||||
$blockquote = [];
|
||||
};
|
||||
$close_list = function () use (&$html, &$list_type): void {
|
||||
if ($list_type === '') return;
|
||||
$html[] = '</' . $list_type . '>';
|
||||
$list_type = '';
|
||||
};
|
||||
|
||||
foreach ($lines as $line) {
|
||||
if (preg_match('/^\s*```/', $line)) {
|
||||
if ($in_code) {
|
||||
$html[] = '<pre><code>' . h(implode("\n", $code_lines)) . '</code></pre>';
|
||||
$code_lines = [];
|
||||
$in_code = false;
|
||||
} else {
|
||||
$flush_paragraph();
|
||||
$flush_blockquote();
|
||||
$close_list();
|
||||
$in_code = true;
|
||||
$code_lines = [];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($in_code) {
|
||||
$code_lines[] = $line;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trim($line) === '') {
|
||||
$flush_paragraph();
|
||||
$flush_blockquote();
|
||||
$close_list();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (preg_match('/^(#{1,6})\s+(.+)$/', $line, $match)) {
|
||||
$flush_paragraph();
|
||||
$flush_blockquote();
|
||||
$close_list();
|
||||
$level = strlen($match[1]);
|
||||
$html[] = '<h' . $level . '>' . media_markdown_inline($match[2]) . '</h' . $level . '>';
|
||||
continue;
|
||||
}
|
||||
|
||||
if (preg_match('/^>\s?(.*)$/', $line, $match)) {
|
||||
$flush_paragraph();
|
||||
$close_list();
|
||||
$blockquote[] = $match[1];
|
||||
continue;
|
||||
}
|
||||
|
||||
if (preg_match('/^\s*[-*+]\s+(.+)$/', $line, $match)) {
|
||||
$flush_paragraph();
|
||||
$flush_blockquote();
|
||||
if ($list_type !== 'ul') {
|
||||
$close_list();
|
||||
$html[] = '<ul>';
|
||||
$list_type = 'ul';
|
||||
}
|
||||
$html[] = '<li>' . media_markdown_inline($match[1]) . '</li>';
|
||||
continue;
|
||||
}
|
||||
|
||||
if (preg_match('/^\s*\d+[.)]\s+(.+)$/', $line, $match)) {
|
||||
$flush_paragraph();
|
||||
$flush_blockquote();
|
||||
if ($list_type !== 'ol') {
|
||||
$close_list();
|
||||
$html[] = '<ol>';
|
||||
$list_type = 'ol';
|
||||
}
|
||||
$html[] = '<li>' . media_markdown_inline($match[1]) . '</li>';
|
||||
continue;
|
||||
}
|
||||
|
||||
$close_list();
|
||||
$flush_blockquote();
|
||||
$paragraph[] = $line;
|
||||
}
|
||||
|
||||
if ($in_code) $html[] = '<pre><code>' . h(implode("\n", $code_lines)) . '</code></pre>';
|
||||
$flush_paragraph();
|
||||
$flush_blockquote();
|
||||
$close_list();
|
||||
|
||||
return implode("\n", $html);
|
||||
}
|
||||
|
||||
function media_markdown_inline(string $text): string {
|
||||
$tokens = [];
|
||||
$text = preg_replace_callback('/`([^`\n]+)`/', function ($match) use (&$tokens): string {
|
||||
$token = '@@MDTOKEN' . count($tokens) . '@@';
|
||||
$tokens[$token] = '<code>' . h($match[1]) . '</code>';
|
||||
return $token;
|
||||
}, $text);
|
||||
|
||||
$html = h((string)$text);
|
||||
|
||||
$html = preg_replace_callback('/\[([^\]\n]+)\]\(([^)\s]+)(?:\s+"[^&]*")?\)/', function ($match): string {
|
||||
$url = html_entity_decode($match[2], ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
return media_description_link_html($url, $match[1]);
|
||||
}, $html);
|
||||
|
||||
$html = preg_replace('/\*\*([^*\n]+)\*\*/', '<strong>$1</strong>', (string)$html);
|
||||
$html = preg_replace('/__([^_\n]+)__/', '<strong>$1</strong>', (string)$html);
|
||||
$html = preg_replace('/(?<!\*)\*([^*\n]+)\*(?!\*)/', '<em>$1</em>', (string)$html);
|
||||
$html = preg_replace('/(?<!_)_([^_\n]+)_(?!_)/', '<em>$1</em>', (string)$html);
|
||||
$html = preg_replace('/~~([^~\n]+)~~/', '<s>$1</s>', (string)$html);
|
||||
|
||||
foreach ($tokens as $token => $replacement) {
|
||||
$html = str_replace($token, $replacement, (string)$html);
|
||||
}
|
||||
|
||||
return media_autolink_html_text((string)$html);
|
||||
}
|
||||
|
||||
function media_autolink_html_text(string $html): string {
|
||||
$parts = preg_split('/(<a\b[^>]*>.*?<\/a>|<code\b[^>]*>.*?<\/code>|<[^>]+>)/is', $html, -1, PREG_SPLIT_DELIM_CAPTURE);
|
||||
if (!is_array($parts)) return $html;
|
||||
|
||||
foreach ($parts as $i => $part) {
|
||||
if ($part === '' || str_starts_with($part, '<')) continue;
|
||||
$parts[$i] = preg_replace_callback('~https?://[^\s<]+~i', function ($match): string {
|
||||
$url = $match[0];
|
||||
$trail = '';
|
||||
while ($url !== '' && preg_match('/[.,;:!?)]$/', $url)) {
|
||||
$trail = substr($url, -1) . $trail;
|
||||
$url = substr($url, 0, -1);
|
||||
}
|
||||
return media_description_link_html($url, h($url)) . $trail;
|
||||
}, $part);
|
||||
}
|
||||
|
||||
return implode('', $parts);
|
||||
}
|
||||
|
||||
function media_description_link_html(string $url, string $label_html): string {
|
||||
$href = media_clean_url_attribute($url, false);
|
||||
if ($href === '') return $label_html;
|
||||
return '<a href="' . h($href) . '" target="_blank" rel="noopener noreferrer">' . $label_html . '</a>';
|
||||
}
|
||||
|
||||
function media_clean_url_attribute(string $url, bool $for_src): string {
|
||||
$url = trim(html_entity_decode($url, ENT_QUOTES | ENT_HTML5, 'UTF-8'));
|
||||
$url = preg_replace('/[\x00-\x1F\x7F\s]+/', '', $url);
|
||||
$url = (string)$url;
|
||||
if ($url === '') return '';
|
||||
|
||||
if (str_starts_with($url, '//')) return 'https:' . $url;
|
||||
if (str_starts_with($url, '#')) return $for_src ? '' : $url;
|
||||
if (str_starts_with($url, '/')) return $url;
|
||||
|
||||
if (preg_match('/^[a-z][a-z0-9+.-]*:/i', $url, $match)) {
|
||||
$scheme = strtolower(rtrim($match[0], ':'));
|
||||
$allowed = $for_src ? ['http', 'https'] : ['http', 'https', 'mailto', 'tel'];
|
||||
return in_array($scheme, $allowed, true) ? $url : '';
|
||||
}
|
||||
|
||||
return preg_match('/^[^<>"\']+$/', $url) ? $url : '';
|
||||
}
|
||||
|
||||
function media_clean_style_attribute(string $style): string {
|
||||
$allowed = [
|
||||
'background-color' => true,
|
||||
'color' => true,
|
||||
'font-size' => true,
|
||||
'font-style' => true,
|
||||
'font-weight' => true,
|
||||
'letter-spacing' => true,
|
||||
'line-height' => true,
|
||||
'text-align' => true,
|
||||
'text-decoration' => true,
|
||||
'text-transform' => true,
|
||||
];
|
||||
$clean = [];
|
||||
|
||||
foreach (explode(';', $style) as $rule) {
|
||||
if (!str_contains($rule, ':')) continue;
|
||||
[$property, $value] = array_map('trim', explode(':', $rule, 2));
|
||||
$property = strtolower($property);
|
||||
if (!isset($allowed[$property]) || $value === '') continue;
|
||||
if (preg_match('/url\s*\(|expression\s*\(|javascript:|data:|@|[<>\\\\]/i', $value)) continue;
|
||||
if (strlen($value) > 160) continue;
|
||||
$clean[] = $property . ': ' . $value;
|
||||
}
|
||||
|
||||
return implode('; ', $clean);
|
||||
}
|
||||
|
||||
function media_description_allowed_tags(): array {
|
||||
return [
|
||||
'a' => true, 'b' => true, 'blockquote' => true, 'br' => true,
|
||||
'code' => true, 'div' => true, 'em' => true, 'h1' => true,
|
||||
'h2' => true, 'h3' => true, 'h4' => true, 'h5' => true,
|
||||
'h6' => true, 'i' => true, 'img' => true, 'li' => true,
|
||||
'ol' => true, 'p' => true, 'pre' => true, 's' => true,
|
||||
'span' => true, 'strong' => true, 'u' => true, 'ul' => true,
|
||||
];
|
||||
}
|
||||
|
||||
function media_description_drop_tags(): array {
|
||||
return [
|
||||
'audio' => true, 'base' => true, 'button' => true, 'canvas' => true,
|
||||
'embed' => true, 'form' => true, 'iframe' => true, 'input' => true,
|
||||
'link' => true, 'math' => true, 'meta' => true, 'object' => true,
|
||||
'picture' => true, 'script' => true, 'select' => true, 'source' => true,
|
||||
'style' => true, 'svg' => true, 'textarea' => true, 'track' => true,
|
||||
'video' => true,
|
||||
];
|
||||
}
|
||||
|
||||
function media_description_allowed_attrs(string $tag): array {
|
||||
$attrs = ['title' => true, 'style' => true];
|
||||
if ($tag === 'a') {
|
||||
$attrs['href'] = true;
|
||||
$attrs['rel'] = true;
|
||||
$attrs['target'] = true;
|
||||
}
|
||||
if ($tag === 'img') {
|
||||
$attrs['src'] = true;
|
||||
$attrs['alt'] = true;
|
||||
$attrs['width'] = true;
|
||||
$attrs['height'] = true;
|
||||
$attrs['loading'] = true;
|
||||
$attrs['decoding'] = true;
|
||||
}
|
||||
if ($tag === 'ol') $attrs['start'] = true;
|
||||
return $attrs;
|
||||
}
|
||||
|
||||
function media_sanitize_html(string $html): string {
|
||||
$html = trim($html);
|
||||
if ($html === '') return '';
|
||||
if (!class_exists('DOMDocument')) {
|
||||
return nl2br(h(html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5, 'UTF-8')));
|
||||
}
|
||||
|
||||
$doc = new DOMDocument('1.0', 'UTF-8');
|
||||
$previous = function_exists('libxml_use_internal_errors') ? libxml_use_internal_errors(true) : null;
|
||||
$options = 0;
|
||||
if (defined('LIBXML_HTML_NOIMPLIED')) $options |= LIBXML_HTML_NOIMPLIED;
|
||||
if (defined('LIBXML_HTML_NODEFDTD')) $options |= LIBXML_HTML_NODEFDTD;
|
||||
|
||||
$loaded = $doc->loadHTML(
|
||||
'<?xml encoding="UTF-8"><div id="media-description-root">' . $html . '</div>',
|
||||
$options
|
||||
);
|
||||
if (function_exists('libxml_clear_errors')) libxml_clear_errors();
|
||||
if ($previous !== null && function_exists('libxml_use_internal_errors')) libxml_use_internal_errors($previous);
|
||||
if (!$loaded) return '';
|
||||
|
||||
$root = $doc->getElementById('media-description-root');
|
||||
if (!$root) return '';
|
||||
|
||||
media_sanitize_dom_node($root, $doc);
|
||||
|
||||
$out = '';
|
||||
foreach ($root->childNodes as $child) {
|
||||
$out .= $doc->saveHTML($child);
|
||||
}
|
||||
return trim($out);
|
||||
}
|
||||
|
||||
function media_sanitize_dom_node($node, $doc): void {
|
||||
$allowed_tags = media_description_allowed_tags();
|
||||
$drop_tags = media_description_drop_tags();
|
||||
|
||||
for ($child = $node->firstChild; $child !== null; $child = $next) {
|
||||
$next = $child->nextSibling;
|
||||
|
||||
if ($child->nodeType === 8) {
|
||||
$node->removeChild($child);
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($child->nodeType !== 1) continue;
|
||||
|
||||
$tag = strtolower($child->nodeName);
|
||||
if (isset($drop_tags[$tag])) {
|
||||
$node->removeChild($child);
|
||||
continue;
|
||||
}
|
||||
|
||||
media_sanitize_dom_node($child, $doc);
|
||||
|
||||
if (!isset($allowed_tags[$tag])) {
|
||||
while ($child->firstChild) {
|
||||
$node->insertBefore($child->firstChild, $child);
|
||||
}
|
||||
$node->removeChild($child);
|
||||
continue;
|
||||
}
|
||||
|
||||
media_sanitize_dom_attributes($child, $tag);
|
||||
if ($tag === 'img' && !$child->hasAttribute('src')) {
|
||||
$node->removeChild($child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function media_sanitize_dom_attributes($element, string $tag): void {
|
||||
$allowed = media_description_allowed_attrs($tag);
|
||||
$attrs = [];
|
||||
if ($element->hasAttributes()) {
|
||||
foreach ($element->attributes as $attr) $attrs[] = $attr;
|
||||
}
|
||||
|
||||
foreach ($attrs as $attr) {
|
||||
$name = strtolower($attr->nodeName);
|
||||
$value = (string)$attr->nodeValue;
|
||||
if (str_starts_with($name, 'on') || !isset($allowed[$name])) {
|
||||
$element->removeAttribute($attr->nodeName);
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($name === 'style') {
|
||||
$clean = media_clean_style_attribute($value);
|
||||
if ($clean === '') $element->removeAttribute($attr->nodeName);
|
||||
else $element->setAttribute('style', $clean);
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($name === 'href' || $name === 'src') {
|
||||
$clean = media_clean_url_attribute($value, $name === 'src');
|
||||
if ($clean === '') $element->removeAttribute($attr->nodeName);
|
||||
else $element->setAttribute($name, $clean);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (in_array($name, ['width', 'height', 'start'], true)) {
|
||||
$number = max(0, min(4000, (int)$value));
|
||||
if ($number <= 0) $element->removeAttribute($attr->nodeName);
|
||||
else $element->setAttribute($name, (string)$number);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if ($tag === 'a') {
|
||||
if ($element->hasAttribute('href')) {
|
||||
$element->setAttribute('target', '_blank');
|
||||
$element->setAttribute('rel', 'noopener noreferrer');
|
||||
} else {
|
||||
$element->removeAttribute('target');
|
||||
$element->removeAttribute('rel');
|
||||
}
|
||||
}
|
||||
|
||||
if ($tag === 'img') {
|
||||
if (!$element->hasAttribute('src')) return;
|
||||
$element->setAttribute('loading', 'lazy');
|
||||
$element->setAttribute('decoding', 'async');
|
||||
}
|
||||
}
|
||||
|
||||
function media_view_count_from_html(string $html): int {
|
||||
$html = html_entity_decode($html, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
$variants = [$html, stripcslashes($html)];
|
||||
@@ -984,14 +1398,16 @@ function media_apply_metadata_value(array &$metadata, string $key, $value): void
|
||||
return;
|
||||
}
|
||||
|
||||
$text = trim(html_entity_decode(strip_tags((string)$value), ENT_QUOTES | ENT_HTML5, 'UTF-8'));
|
||||
if ($text === '') return;
|
||||
|
||||
if ($key === 'description') {
|
||||
$metadata['description'] = media_pick_longest_text((string)$metadata['description'], $text);
|
||||
$description = media_prepare_description_for_storage((string)$value);
|
||||
if ($description === '') return;
|
||||
$metadata['description'] = media_pick_longest_description((string)$metadata['description'], $description);
|
||||
return;
|
||||
}
|
||||
|
||||
$text = trim(html_entity_decode(strip_tags((string)$value), ENT_QUOTES | ENT_HTML5, 'UTF-8'));
|
||||
if ($text === '') return;
|
||||
|
||||
if ((string)$metadata[$key] === '') $metadata[$key] = $text;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user