- 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:
Ty Clifford
2026-07-01 15:41:01 -04:00
parent e9369e242c
commit 6a1c545f35
11 changed files with 787 additions and 17 deletions
+2
View File
@@ -82,6 +82,8 @@ External URLs must use `http://` or `https://`. Uploaded files and remote URLs c
When adding an external URL, the admin form can fetch title, description, duration, thumbnail metadata, and readable provider view counts through oEmbed, page metadata, and JSON-LD. The save action repeats the metadata lookup as a fallback, so blank titles can be filled from supported external pages even if the browser autofill was not used. When adding an external URL, the admin form can fetch title, description, duration, thumbnail metadata, and readable provider view counts through oEmbed, page metadata, and JSON-LD. The save action repeats the metadata lookup as a fallback, so blank titles can be filled from supported external pages even if the browser autofill was not used.
Descriptions support Markdown, sanitized HTML, and rich pasted HTML from provider pages such as YouTube. Public pages render formatting while catalogue cards and social metadata use safe plain-text excerpts.
If no thumbnail image is uploaded, the add/edit forms can auto-grab one from the first supported source. YouTube and Dailymotion use direct thumbnail URLs; Vimeo, Wistia, TikTok, SoundCloud, and Spotify use oEmbed when available. Local uploaded videos can generate a first-frame thumbnail when `ffmpeg` is installed on the server. Uploaded M4A files also use `ffmpeg` to create a black-frame H.264 MP4 playback source while keeping the original M4A source. If no thumbnail image is uploaded, the add/edit forms can auto-grab one from the first supported source. YouTube and Dailymotion use direct thumbnail URLs; Vimeo, Wistia, TikTok, SoundCloud, and Spotify use oEmbed when available. Local uploaded videos can generate a first-frame thumbnail when `ffmpeg` is installed on the server. Uploaded M4A files also use `ffmpeg` to create a black-frame H.264 MP4 playback source while keeping the original M4A source.
### Live Homepage Option ### Live Homepage Option
+11 -5
View File
@@ -9,7 +9,7 @@ $msg = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$title = trim($_POST['title'] ?? ''); $title = trim($_POST['title'] ?? '');
$description = trim($_POST['description'] ?? ''); $description = media_prepare_description_for_storage($_POST['description'] ?? '');
$duration = (int)($_POST['duration'] ?? 0); $duration = (int)($_POST['duration'] ?? 0);
$sort_order = (int)($_POST['sort_order'] ?? 0); $sort_order = (int)($_POST['sort_order'] ?? 0);
$published = isset($_POST['published']) ? 1 : 0; $published = isset($_POST['published']) ? 1 : 0;
@@ -40,7 +40,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$metadata = media_external_metadata($url); $metadata = media_external_metadata($url);
$source_metadata[$url] = $metadata; $source_metadata[$url] = $metadata;
if ($title === '' && $metadata['title'] !== '') $title = $metadata['title']; if ($title === '' && $metadata['title'] !== '') $title = $metadata['title'];
if ($description === '' && $metadata['description'] !== '') $description = $metadata['description']; if ($description === '' && $metadata['description'] !== '') {
$description = media_prepare_description_for_storage($metadata['description']);
}
if ($duration <= 0 && (int)$metadata['duration'] > 0) $duration = (int)$metadata['duration']; if ($duration <= 0 && (int)$metadata['duration'] > 0) $duration = (int)$metadata['duration'];
if ((int)$metadata['view_count'] > $initial_external_view_count) { if ((int)$metadata['view_count'] > $initial_external_view_count) {
$initial_external_view_count = (int)$metadata['view_count']; $initial_external_view_count = (int)$metadata['view_count'];
@@ -291,8 +293,8 @@ render_head('Add Video — Admin', '
<span class="hint">Leave blank when pasting a supported external URL, then use Fetch Info.</span> <span class="hint">Leave blank when pasting a supported external URL, then use Fetch Info.</span>
</div> </div>
<div class="form-group span2"> <div class="form-group span2">
<label class="form-label" for="description">Description</label> <label class="form-label" id="description-label" for="description">Description</label>
<textarea class="form-textarea" id="description" name="description" rows="3"><?= h($_POST['description'] ?? '') ?></textarea> <?php render_description_editor($_POST['description'] ?? ''); ?>
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label" for="duration">Duration (seconds)</label> <label class="form-label" for="duration">Duration (seconds)</label>
@@ -401,6 +403,7 @@ render_head('Add Video — Admin', '
<?php render_footer(); ?> <?php render_footer(); ?>
</main> </main>
<?php render_description_editor_script(); ?>
<script> <script>
function toggleSourceRow(row) { function toggleSourceRow(row) {
var select = row.querySelector('.source-type-select'); var select = row.querySelector('.source-type-select');
@@ -430,7 +433,10 @@ function fillFromMetadata(row, metadata, force) {
var label = row.querySelector('input[name="source_label[]"]'); var label = row.querySelector('input[name="source_label[]"]');
if (metadata.title && title && (force || !title.value.trim())) title.value = metadata.title; if (metadata.title && title && (force || !title.value.trim())) title.value = metadata.title;
if (metadata.description && description && (force || !description.value.trim())) description.value = metadata.description; if (metadata.description && description && (force || !description.value.trim())) {
if (window.setDescriptionEditorValue) window.setDescriptionEditorValue(metadata.description);
else description.value = metadata.description;
}
if (metadata.duration && duration && (force || !parseInt(duration.value || '0', 10))) duration.value = metadata.duration; if (metadata.duration && duration && (force || !parseInt(duration.value || '0', 10))) duration.value = metadata.duration;
if (label && !label.value.trim()) label.value = providerLabel(metadata.provider) || metadata.title || 'External'; if (label && !label.value.trim()) label.value = providerLabel(metadata.provider) || metadata.title || 'External';
} }
+4 -3
View File
@@ -39,7 +39,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if ($action === 'save') { if ($action === 'save') {
$title = trim($_POST['title'] ?? ''); $title = trim($_POST['title'] ?? '');
$description = trim($_POST['description'] ?? ''); $description = media_prepare_description_for_storage($_POST['description'] ?? '');
$duration = (int)($_POST['duration'] ?? 0); $duration = (int)($_POST['duration'] ?? 0);
$sort_order = (int)($_POST['sort_order'] ?? 0); $sort_order = (int)($_POST['sort_order'] ?? 0);
$view_count = max(0, (int)($_POST['view_count'] ?? $video['view_count'])); $view_count = max(0, (int)($_POST['view_count'] ?? $video['view_count']));
@@ -335,8 +335,8 @@ render_head('Edit Video — Admin', '
value="<?= h($video['title']) ?>" required> value="<?= h($video['title']) ?>" required>
</div> </div>
<div class="form-group span2"> <div class="form-group span2">
<label class="form-label" for="description">Description</label> <label class="form-label" id="description-label" for="description">Description</label>
<textarea class="form-textarea" id="description" name="description" rows="3"><?= h($video['description']) ?></textarea> <?php render_description_editor($video['description']); ?>
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">Slug (auto-generated)</label> <label class="form-label">Slug (auto-generated)</label>
@@ -508,6 +508,7 @@ render_head('Edit Video — Admin', '
<?php render_footer(); ?> <?php render_footer(); ?>
</main> </main>
<?php render_description_editor_script(); ?>
<script> <script>
function toggleSourceRow(row) { function toggleSourceRow(row) {
var select = row.querySelector('.source-type-select'); var select = row.querySelector('.source-type-select');
+1 -1
View File
@@ -251,7 +251,7 @@ function yt_video_record(string $id, string $title, string $description = '', st
return [ return [
'id' => $id, 'id' => $id,
'title' => $title, 'title' => $title,
'description' => trim($description), 'description' => media_prepare_description_for_storage($description),
'duration' => yt_duration_seconds($duration_text), 'duration' => yt_duration_seconds($duration_text),
'duration_text' => trim($duration_text), 'duration_text' => trim($duration_text),
'thumbnail_url' => $thumbnail_url ?: 'https://i.ytimg.com/vi/' . rawurlencode($id) . '/hqdefault.jpg', 'thumbnail_url' => $thumbnail_url ?: 'https://i.ytimg.com/vi/' . rawurlencode($id) . '/hqdefault.jpg',
+1 -1
View File
@@ -161,7 +161,7 @@ render_head('Videos — TyClifford.com', '
<?php foreach ($paged['videos'] as $v): <?php foreach ($paged['videos'] as $v):
$vslug = $v['slug']; $vslug = $v['slug'];
$vtitle = $v['title']; $vtitle = $v['title'];
$vdesc = $v['description']; $vdesc = media_description_plain_text($v['description']);
$vthumb = $v['thumbnail']; $vthumb = $v['thumbnail'];
$vdur = (int)$v['duration']; $vdur = (int)$v['duration'];
$created = substr($v['created_at'], 0, 10); $created = substr($v['created_at'], 0, 10);
+106
View File
@@ -27,6 +27,10 @@ function cli_main(array $argv): int {
$command = 'help'; $command = 'help';
} }
if (cli_command_requires_database_write($command)) {
cli_assert_database_writable();
}
return match ($command) { return match ($command) {
'help' => cli_help(), 'help' => cli_help(),
'site:status', 'status' => cli_site_status(), 'site:status', 'status' => cli_site_status(),
@@ -51,6 +55,7 @@ function cli_main(array $argv): int {
'maintenance:external-views' => cli_maintenance_external_views($parsed), 'maintenance:external-views' => cli_maintenance_external_views($parsed),
'maintenance:all' => cli_maintenance_all($parsed), 'maintenance:all' => cli_maintenance_all($parsed),
'db:path' => cli_db_path(), 'db:path' => cli_db_path(),
'db:doctor' => cli_db_doctor(),
'db:backup' => cli_db_backup($parsed), 'db:backup' => cli_db_backup($parsed),
'db:vacuum' => cli_db_vacuum(), 'db:vacuum' => cli_db_vacuum(),
default => cli_unknown($command), default => cli_unknown($command),
@@ -157,6 +162,7 @@ Maintenance:
Database: Database:
db:path db:path
db:doctor
db:backup [--output=/path/to/media.db] db:backup [--output=/path/to/media.db]
db:vacuum db:vacuum
@@ -164,6 +170,46 @@ HELP;
return 0; return 0;
} }
function cli_command_requires_database_write(string $command): bool {
return in_array($command, [
'settings:set',
'admin:password',
'password:set',
'video:add',
'video:update',
'video:publish',
'video:unpublish',
'video:toggle',
'video:delete',
'source:add',
'source:update',
'source:delete',
'maintenance:catalogue-order',
'maintenance:youtube-timestamps',
'maintenance:external-views',
'maintenance:all',
'db:vacuum',
], true);
}
function cli_assert_database_writable(): void {
$paths = cli_database_writable_checks();
$problems = [];
foreach ($paths as $check) {
if (!$check['ok']) $problems[] = $check['message'];
}
if ($problems) {
$details = implode(PHP_EOL . ' - ', $problems);
throw new RuntimeException(
"Database is not writable by the current CLI user (" . cli_current_user_label() . ")." .
PHP_EOL . " - " . $details .
PHP_EOL . "Run db:doctor for details, then make the data directory and media.db writable by this user."
);
}
}
function cli_unknown(string $command): int { function cli_unknown(string $command): int {
fwrite(STDERR, "Unknown command: {$command}" . PHP_EOL . PHP_EOL); fwrite(STDERR, "Unknown command: {$command}" . PHP_EOL . PHP_EOL);
cli_help(); cli_help();
@@ -768,6 +814,66 @@ function cli_db_path(): int {
return 0; return 0;
} }
function cli_db_doctor(): int {
$rows = [
['Current user', cli_current_user_label()],
['Database path', DB_PATH],
['Data directory', dirname(DB_PATH)],
['Database exists', cli_yes_no(file_exists(DB_PATH))],
];
foreach (cli_database_writable_checks() as $check) {
$rows[] = [$check['label'], $check['ok'] ? 'ok' : $check['message']];
}
cli_table(['check', 'result'], $rows);
return 0;
}
function cli_database_writable_checks(): array {
$directory = dirname(DB_PATH);
$checks = [];
$checks[] = [
'label' => 'data directory',
'ok' => is_dir($directory) && is_writable($directory),
'message' => is_dir($directory)
? "data directory is not writable: {$directory}"
: "data directory does not exist: {$directory}",
];
if (file_exists(DB_PATH)) {
$checks[] = [
'label' => 'media.db',
'ok' => is_writable(DB_PATH),
'message' => "database file is not writable: " . DB_PATH,
];
}
foreach ([DB_PATH . '-wal', DB_PATH . '-shm'] as $path) {
if (!file_exists($path)) continue;
$checks[] = [
'label' => basename($path),
'ok' => is_writable($path),
'message' => "SQLite sidecar file is not writable: {$path}",
];
}
return $checks;
}
function cli_current_user_label(): string {
if (function_exists('posix_geteuid') && function_exists('posix_getpwuid')) {
$uid = posix_geteuid();
$info = posix_getpwuid($uid);
$name = is_array($info) && isset($info['name']) ? (string)$info['name'] : '';
return $name !== '' ? "{$name} (uid {$uid})" : "uid {$uid}";
}
$user = get_current_user();
return $user !== '' ? $user : 'unknown';
}
function cli_db_backup(array $parsed): int { function cli_db_backup(array $parsed): int {
cli_db()->exec('PRAGMA wal_checkpoint(FULL)'); cli_db()->exec('PRAGMA wal_checkpoint(FULL)');
BIN
View File
Binary file not shown.
+1 -1
View File
@@ -35,7 +35,7 @@ foreach ($player_sources as $player_source) {
} }
$meta_title = $video ? $video['title'] . ' — TyClifford.com' : 'Video Player'; $meta_title = $video ? $video['title'] . ' — TyClifford.com' : 'Video Player';
$meta_description = $video && trim($video['description'] ?? '') !== '' ? trim($video['description']) : 'TyClifford.com media player'; $meta_description = $video && trim($video['description'] ?? '') !== '' ? media_description_plain_text($video['description']) : 'TyClifford.com media player';
$meta_url = $video $meta_url = $video
? public_absolute_url('embed.php?v=' . rawurlencode($video['slug'])) ? public_absolute_url('embed.php?v=' . rawurlencode($video['slug']))
: public_absolute_url('embed.php'); : public_absolute_url('embed.php');
+420 -4
View File
@@ -595,6 +595,420 @@ function media_pick_longest_text(string $current, string $candidate): string {
return strlen($candidate) > strlen($current) ? $candidate : $current; 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+&quot;[^&]*&quot;)?\)/', 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 { function media_view_count_from_html(string $html): int {
$html = html_entity_decode($html, ENT_QUOTES | ENT_HTML5, 'UTF-8'); $html = html_entity_decode($html, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$variants = [$html, stripcslashes($html)]; $variants = [$html, stripcslashes($html)];
@@ -984,14 +1398,16 @@ function media_apply_metadata_value(array &$metadata, string $key, $value): void
return; return;
} }
$text = trim(html_entity_decode(strip_tags((string)$value), ENT_QUOTES | ENT_HTML5, 'UTF-8'));
if ($text === '') return;
if ($key === 'description') { 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; 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; if ((string)$metadata[$key] === '') $metadata[$key] = $text;
} }
+235
View File
@@ -228,6 +228,43 @@ function render_head(string $title = 'Media — TyClifford.com', string $extra_c
} }
.form-textarea { resize:vertical; min-height:80px; } .form-textarea { resize:vertical; min-height:80px; }
.form-select option { background:var(--surface-2); } .form-select option { background:var(--surface-2); }
.description-editor {
min-height:130px; line-height:1.55; overflow:auto; white-space:pre-wrap;
}
.description-editor:empty::before {
content:attr(data-placeholder); color:var(--text-3); pointer-events:none;
}
.description-editor :where(p, ul, ol, blockquote, pre, h1, h2, h3, h4, h5, h6) { margin:.45rem 0; }
.description-editor :where(ul, ol) { padding-left:1.35rem; }
.description-editor a,
.media-description a {
color:var(--blue); text-decoration:none; border-bottom:1px solid rgba(0,200,255,.35);
}
.description-editor a:hover,
.media-description a:hover { border-color:rgba(0,200,255,.7); }
.media-description {
font-family:"Inter",sans-serif; color:var(--text-2);
letter-spacing:0; line-height:1.6; overflow-wrap:anywhere;
}
.media-description :where(p, ul, ol, blockquote, pre, h1, h2, h3, h4, h5, h6) { margin:.55rem 0; }
.media-description :where(p:first-child, ul:first-child, ol:first-child, blockquote:first-child, pre:first-child, h1:first-child, h2:first-child, h3:first-child, h4:first-child, h5:first-child, h6:first-child) { margin-top:0; }
.media-description :where(p:last-child, ul:last-child, ol:last-child, blockquote:last-child, pre:last-child, h1:last-child, h2:last-child, h3:last-child, h4:last-child, h5:last-child, h6:last-child) { margin-bottom:0; }
.media-description :where(ul, ol) { padding-left:1.35rem; }
.media-description :where(h1, h2, h3, h4, h5, h6) { color:var(--text); line-height:1.25; font-size:.95rem; }
.media-description blockquote {
padding-left:.85rem; border-left:2px solid var(--border-2); color:var(--text-2);
}
.media-description code {
font-family:"Share Tech Mono",monospace; font-size:.9em;
background:rgba(255,255,255,.06); border:1px solid var(--border-2);
border-radius:4px; padding:.08rem .28rem;
}
.media-description pre {
overflow:auto; background:rgba(0,0,0,.24); border:1px solid var(--border);
border-radius:var(--r); padding:.7rem;
}
.media-description pre code { background:transparent; border:0; padding:0; }
.media-description img { max-width:100%; height:auto; border-radius:var(--r); }
/* ── Notice/alert ── */ /* ── Notice/alert ── */
.notice { .notice {
@@ -290,6 +327,204 @@ function render_footer(): void { ?>
</footer> </footer>
<?php } <?php }
function render_description_editor(string $description): void {
$description = trim($description);
$is_html = media_description_contains_html($description);
?>
<textarea class="form-textarea description-fallback" id="description" name="description" rows="5"><?= h($description) ?></textarea>
<div class="description-editor form-textarea"
id="description-editor"
hidden
contenteditable="true"
role="textbox"
aria-labelledby="description-label"
aria-multiline="true"
data-rich="<?= $is_html ? '1' : '0' ?>"
data-placeholder="Description"><?php if ($is_html): ?><?= media_description_html($description) ?><?php else: ?><?= h($description) ?><?php endif; ?></div>
<?php }
function render_description_editor_script(): void { ?>
<script>
(function() {
var textarea = document.getElementById('description');
var editor = document.getElementById('description-editor');
if (!textarea || !editor) return;
textarea.hidden = true;
editor.hidden = false;
var rich = editor.dataset.rich === '1';
var allowedTags = {
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
};
var dropTags = {
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
};
var styleProps = [
'background-color', 'color', 'font-size', 'font-style', 'font-weight',
'letter-spacing', 'line-height', 'text-align', 'text-decoration',
'text-transform'
];
function isSafeUrl(value, forSrc) {
value = (value || '').trim();
if (!value) return '';
if (value.indexOf('//') === 0) return 'https:' + value;
if (value.charAt(0) === '#') return forSrc ? '' : value;
if (value.charAt(0) === '/') return value;
try {
var parsed = new URL(value, window.location.origin);
var protocol = parsed.protocol.replace(':', '').toLowerCase();
if (forSrc) return (protocol === 'http' || protocol === 'https') ? value : '';
return ['http', 'https', 'mailto', 'tel'].indexOf(protocol) !== -1 ? value : '';
} catch (e) {
return /^[^<>"']+$/.test(value) ? value : '';
}
}
function cleanStyle(value) {
var probe = document.createElement('span');
probe.setAttribute('style', value || '');
var kept = [];
styleProps.forEach(function(prop) {
var v = probe.style.getPropertyValue(prop);
if (!v || /url\s*\(|expression\s*\(|javascript:|data:|@|[<>\\]/i.test(v)) return;
kept.push(prop + ': ' + v);
});
return kept.join('; ');
}
function sanitizeElement(el) {
Array.prototype.slice.call(el.childNodes).forEach(function(child) {
if (child.nodeType === Node.TEXT_NODE) return;
if (child.nodeType !== Node.ELEMENT_NODE) {
child.remove();
return;
}
if (dropTags[child.tagName]) {
child.remove();
return;
}
sanitizeElement(child);
if (!allowedTags[child.tagName]) {
while (child.firstChild) child.parentNode.insertBefore(child.firstChild, child);
child.remove();
return;
}
Array.prototype.slice.call(child.attributes).forEach(function(attr) {
var name = attr.name.toLowerCase();
var value = attr.value;
var keep = name === 'title' || name === 'style';
if (child.tagName === 'A') keep = keep || name === 'href' || name === 'target' || name === 'rel';
if (child.tagName === 'IMG') keep = keep || name === 'src' || name === 'alt' || name === 'width' || name === 'height';
if (child.tagName === 'OL') keep = keep || name === 'start';
if (name.indexOf('on') === 0 || !keep) {
child.removeAttribute(attr.name);
return;
}
if (name === 'style') {
var style = cleanStyle(value);
if (style) child.setAttribute('style', style);
else child.removeAttribute(attr.name);
}
if (name === 'href' || name === 'src') {
var url = isSafeUrl(value, name === 'src');
if (url) child.setAttribute(name, url);
else child.removeAttribute(attr.name);
}
});
if (child.tagName === 'A') {
if (child.getAttribute('href')) {
child.setAttribute('target', '_blank');
child.setAttribute('rel', 'noopener noreferrer');
} else {
child.removeAttribute('target');
child.removeAttribute('rel');
}
}
if (child.tagName === 'IMG' && child.getAttribute('src')) {
child.setAttribute('loading', 'lazy');
child.setAttribute('decoding', 'async');
}
if (child.tagName === 'IMG' && !child.getAttribute('src')) child.remove();
});
}
function sanitizeHtml(html) {
var template = document.createElement('template');
template.innerHTML = html || '';
sanitizeElement(template.content);
return template.innerHTML.trim();
}
function plainText() {
return editor.innerText.replace(/\u00a0/g, ' ').replace(/\n{3,}/g, '\n\n').trim();
}
function syncDescription() {
textarea.value = rich ? sanitizeHtml(editor.innerHTML) : plainText();
}
function insertHtml(html) {
var selection = window.getSelection();
if (!selection || !selection.rangeCount) {
editor.insertAdjacentHTML('beforeend', html);
return;
}
var range = selection.getRangeAt(0);
range.deleteContents();
var fragment = range.createContextualFragment(html);
var last = fragment.lastChild;
range.insertNode(fragment);
if (last) {
range.setStartAfter(last);
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
}
}
window.setDescriptionEditorValue = function(value) {
value = value || '';
if (/<\/?[a-z][\s\S]*>/i.test(value)) {
rich = true;
editor.dataset.rich = '1';
editor.innerHTML = sanitizeHtml(value);
} else {
rich = false;
editor.dataset.rich = '0';
editor.textContent = value;
}
syncDescription();
};
editor.addEventListener('paste', function(event) {
var data = event.clipboardData || window.clipboardData;
var html = data ? data.getData('text/html') : '';
if (!html) return;
event.preventDefault();
rich = true;
editor.dataset.rich = '1';
insertHtml(sanitizeHtml(html));
syncDescription();
});
editor.addEventListener('input', syncDescription);
var form = editor.closest('form');
if (form) form.addEventListener('submit', syncDescription);
syncDescription();
})();
</script>
<?php }
function render_pagination(int $current, int $total, string $url_pattern): void { function render_pagination(int $current, int $total, string $url_pattern): void {
if ($total <= 1) return; if ($total <= 1) return;
echo '<div class="pagination">'; echo '<div class="pagination">';
+6 -2
View File
@@ -68,7 +68,7 @@ if ($home_live_first) {
} else { } else {
$share_title = $video ? $video['title'] . ' — ' . $site_title : 'Media — ' . $site_title; $share_title = $video ? $video['title'] . ' — ' . $site_title : 'Media — ' . $site_title;
$share_description = $video && trim($video['description'] ?? '') !== '' $share_description = $video && trim($video['description'] ?? '') !== ''
? trim($video['description']) ? media_description_plain_text($video['description'])
: setting('site_sub', 'tyclifford.com / media'); : setting('site_sub', 'tyclifford.com / media');
$share_url = $video ? public_absolute_url(public_video_url($video['slug'])) : public_absolute_url(public_home_url()); $share_url = $video ? public_absolute_url(public_video_url($video['slug'])) : public_absolute_url(public_home_url());
$share_image = ($video && !empty($video['thumbnail'])) ? public_absolute_url(public_media_url($video['thumbnail'])) : ''; $share_image = ($video && !empty($video['thumbnail'])) ? public_absolute_url(public_media_url($video['thumbnail'])) : '';
@@ -212,6 +212,10 @@ render_head($share_title, '
.home-mode-panel[hidden] { display:none; } .home-mode-panel[hidden] { display:none; }
.live-footer { justify-content:space-between; } .live-footer { justify-content:space-between; }
.live-footer .stream-meta { max-width:60ch; line-height:1.5; letter-spacing:.06em; } .live-footer .stream-meta { max-width:60ch; line-height:1.5; letter-spacing:.06em; }
.stream-description {
flex:1 1 100%; max-width:min(72ch, 100%);
font-size:.8rem;
}
/* ── Lower grid ── */ /* ── Lower grid ── */
.lower-grid { display:grid; grid-template-columns:1fr; gap:1rem; } .lower-grid { display:grid; grid-template-columns:1fr; gap:1rem; }
@@ -370,7 +374,7 @@ render_head($share_title, '
<div class="stream-footer"> <div class="stream-footer">
<p class="eyebrow eyebrow-green"><?= $video ? h($video['title']) : 'Media Player' ?></p> <p class="eyebrow eyebrow-green"><?= $video ? h($video['title']) : 'Media Player' ?></p>
<?php if ($video && $video['description']): ?> <?php if ($video && $video['description']): ?>
<p class="stream-meta" style="font-family:Inter,sans-serif;font-size:.78rem;color:var(--text-2);letter-spacing:0;max-width:60ch;line-height:1.5"><?= h($video['description']) ?></p> <div class="stream-description media-description"><?= media_description_html($video['description']) ?></div>
<?php endif; ?> <?php endif; ?>
<?php <?php
$stream_meta = []; $stream_meta = [];