Files
Daniel MesserandClaude Opus 5.5 f659b09a0b Show the queued show and its start time in the index.php sidebar
- Shows get a description and an icon upload; broadcasts get a scheduled_at
  start time. schema.sql adds the columns to existing databases and seeds
  the descriptions from the old hand-written sidebar.
- The dashboard and cli/set_show.php take an optional date and Eastern start
  time when queuing; index.php shows it with the UTC conversion.
- liveBroadcast() and currentShow() ignore broadcasts unseen for longer than
  the grace period, so a stopped poller can't leave a show stuck as live.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 09:30:20 -05:00

216 lines
10 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
require __DIR__ . '/../includes/auth.php';
require __DIR__ . '/../includes/admin_layout.php';
$admin = requireAdmin();
$db = getDB();
// Backgrounds and icons are both saved here
const UPLOAD_DIR = __DIR__ . '/../images/backgrounds/';
const MAX_BACKGROUND_BYTES = 5 * 1024 * 1024;
const MAX_ICON_BYTES = 1024 * 1024;
const MAX_DESCRIPTION_CHARS = 5000;
// Uploads are identified by their content, not their filename, and saved under a generated name
const IMAGE_TYPES = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp'];
function slugify(string $name): string {
$slug = strtolower(str_replace(["'", '’'], '', $name));
return trim(preg_replace('/[^a-z0-9]+/', '-', $slug), '-');
}
function deleteUpload(?string $filename): void {
if ($filename && is_file(UPLOAD_DIR . basename($filename))) {
unlink(UPLOAD_DIR . basename($filename));
}
}
// Checks an optional image upload. Returns its file extension, or null if no file was sent or it was rejected
// (rejections are added to $errors).
function validateImageUpload(?array $upload, string $label, int $maxBytes, array &$errors): ?string {
if (!$upload || $upload['error'] === UPLOAD_ERR_NO_FILE) return null;
if (in_array($upload['error'], [UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE], true) || $upload['size'] > $maxBytes) {
$errors[] = "{$label}s can be up to " . ($maxBytes / 1024 / 1024) . ' MB.';
return null;
}
if ($upload['error'] !== UPLOAD_ERR_OK) {
$errors[] = "The {$label} failed to upload. Please try again.";
return null;
}
$mime = (new finfo(FILEINFO_MIME_TYPE))->file($upload['tmp_name']);
$ext = IMAGE_TYPES[$mime] ?? null;
if ($ext === null || getimagesize($upload['tmp_name']) === false) {
$errors[] = "{$label}s must be JPEG, PNG or WebP.";
return null;
}
return $ext;
}
$id = (int)($_GET['id'] ?? 0);
$show = ['id' => 0, 'name' => '', 'slug' => '', 'description' => '', 'icon_image' => null, 'background_image' => null];
if ($id) {
$stmt = $db->prepare('SELECT id, name, slug, description, icon_image, background_image FROM shows WHERE id = ?');
$stmt->execute([$id]);
$show = $stmt->fetch();
if (!$show) {
http_response_code(404);
exit('Show not found.');
}
}
$errors = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// PHP discards the whole form when a request exceeds post_max_size, so catch that before the CSRF check
if (empty($_POST) && (int)($_SERVER['CONTENT_LENGTH'] ?? 0) > 0) {
$errors[] = 'That upload is too large. Background images can be up to ' . (MAX_BACKGROUND_BYTES / 1024 / 1024) . ' MB and icons up to ' . (MAX_ICON_BYTES / 1024 / 1024) . ' MB.';
} else {
verifyCsrf();
if (($_POST['action'] ?? '') === 'delete' && $id) {
$db->prepare('DELETE FROM shows WHERE id = ?')->execute([$id]);
deleteUpload($show['background_image']);
deleteUpload($show['icon_image']);
flash("Deleted {$show['name']}.");
header('Location: shows.php');
exit;
}
$show['name'] = trim((string)($_POST['name'] ?? ''));
$show['slug'] = trim((string)($_POST['slug'] ?? '')) ?: slugify($show['name']);
// Normalise line endings so blank-line paragraph breaks are found on index.php
$show['description'] = trim(str_replace("\r\n", "\n", (string)($_POST['description'] ?? '')));
if ($show['name'] === '' || mb_strlen($show['name']) > 255) {
$errors[] = 'Name is required (up to 255 characters).';
}
if (!preg_match('/^[a-z0-9]+(-[a-z0-9]+)*$/', $show['slug']) || strlen($show['slug']) > 64) {
$errors[] = 'Slug may only contain lowercase letters, numbers and single dashes (up to 64 characters).';
} else {
$stmt = $db->prepare('SELECT COUNT(*) FROM shows WHERE slug = ? AND id != ?');
$stmt->execute([$show['slug'], $id]);
if ($stmt->fetchColumn() > 0) $errors[] = "Another show already uses the slug '{$show['slug']}'.";
}
if (mb_strlen($show['description']) > MAX_DESCRIPTION_CHARS) {
$errors[] = 'The description can be up to ' . MAX_DESCRIPTION_CHARS . ' characters.';
}
// column => [form field, label, size limit, filename suffix after the slug]
$images = [
'background_image' => ['background', 'background image', MAX_BACKGROUND_BYTES, ''],
'icon_image' => ['icon', 'icon', MAX_ICON_BYTES, '-icon'],
];
$uploadExts = [];
foreach ($images as $column => [$field, $label, $maxBytes]) {
$uploadExts[$column] = validateImageUpload($_FILES[$field] ?? null, ucfirst($label), $maxBytes, $errors);
}
$oldImages = ['background_image' => $show['background_image'], 'icon_image' => $show['icon_image']];
if (!$errors) {
foreach ($images as $column => [$field, $label, , $suffix]) {
if ($uploadExts[$column] !== null) {
$filename = $show['slug'] . $suffix . '-' . bin2hex(random_bytes(4)) . '.' . $uploadExts[$column];
if (move_uploaded_file($_FILES[$field]['tmp_name'], UPLOAD_DIR . $filename)) {
$show[$column] = $filename;
} else {
$errors[] = "The {$label} could not be saved. Check that the web server can write to images/backgrounds/.";
}
} elseif (!empty($_POST["remove_{$field}"])) {
$show[$column] = null;
}
}
// Don't leave a newly saved file behind if the other one failed
if ($errors) {
foreach ($oldImages as $column => $old) {
if ($show[$column] !== $old) deleteUpload($show[$column]);
$show[$column] = $old;
}
}
}
if (!$errors) {
$description = $show['description'] === '' ? null : $show['description'];
if ($id) {
$db->prepare('UPDATE shows SET name = ?, slug = ?, description = ?, icon_image = ?, background_image = ? WHERE id = ?')
->execute([$show['name'], $show['slug'], $description, $show['icon_image'], $show['background_image'], $id]);
} else {
$db->prepare('INSERT INTO shows (name, slug, description, icon_image, background_image) VALUES (?, ?, ?, ?, ?)')
->execute([$show['name'], $show['slug'], $description, $show['icon_image'], $show['background_image']]);
}
foreach ($oldImages as $column => $old) {
if ($old !== $show[$column]) deleteUpload($old);
}
flash("Saved {$show['name']}.");
header('Location: shows.php');
exit;
}
}
}
adminHeader($id ? 'Edit Show' : 'Add a Show', $admin);
?>
<?php foreach ($errors as $error): ?>
<p class="admin-message error"><?= e($error) ?></p>
<?php endforeach; ?>
<form method="post" enctype="multipart/form-data" action="show_edit.php<?= $id ? '?id=' . $id : '' ?>">
<?= csrfField() ?>
<input type="hidden" name="MAX_FILE_SIZE" value="<?= MAX_BACKGROUND_BYTES ?>" />
<div class="row gtr-uniform gtr-50">
<div class="col-12">
<label for="name">Name</label>
<input type="text" name="name" id="name" value="<?= e($show['name']) ?>" maxlength="255" required />
</div>
<div class="col-12">
<label for="slug">Slug</label>
<input type="text" name="slug" id="slug" value="<?= e($show['slug']) ?>" maxlength="64" pattern="[a-z0-9]+(-[a-z0-9]+)*" placeholder="Leave blank to generate from the name" />
<p><small>Short name used by <code>cli/set_show.php</code>. Lowercase letters, numbers and dashes.</small></p>
</div>
<div class="col-12">
<label for="description">Description</label>
<textarea name="description" id="description" rows="8" maxlength="<?= MAX_DESCRIPTION_CHARS ?>"><?= e($show['description'] ?? '') ?></textarea>
<p><small>Shown in the sidebar of the main page while this show is queued or live. Plain text; leave a blank line between paragraphs.</small></p>
</div>
<div class="col-12">
<label for="icon">Icon</label>
<?php if ($show['icon_image']): ?>
<p><img src="../images/backgrounds/<?= e(rawurlencode($show['icon_image'])) ?>" alt="Current icon" style="max-width:120px;border-radius:4px;display:block;"></p>
<?php endif; ?>
<input type="file" name="icon" id="icon" accept="image/jpeg,image/png,image/webp" />
<p><small>JPEG, PNG or WebP, up to <?= MAX_ICON_BYTES / 1024 / 1024 ?> MB. The show logo shown next to the description on the main page.</small></p>
<?php if ($show['icon_image']): ?>
<input type="checkbox" name="remove_icon" id="remove_icon" value="1" />
<label for="remove_icon">Remove icon</label>
<?php endif; ?>
</div>
<div class="col-12">
<label for="background">Background image</label>
<?php if ($show['background_image']): ?>
<p><img src="../images/backgrounds/<?= e(rawurlencode($show['background_image'])) ?>" alt="Current background" style="max-width:320px;border-radius:4px;display:block;"></p>
<?php endif; ?>
<input type="file" name="background" id="background" accept="image/jpeg,image/png,image/webp" />
<p><small>JPEG, PNG or WebP, up to <?= MAX_BACKGROUND_BYTES / 1024 / 1024 ?> MB. Shown behind the player on the live page during this show.</small></p>
<?php if ($show['background_image']): ?>
<input type="checkbox" name="remove_background" id="remove_background" value="1" />
<label for="remove_background">Remove background (use the default)</label>
<?php endif; ?>
</div>
<div class="col-12">
<ul class="actions">
<li><input type="submit" class="primary" value="Save" /></li>
<li><a href="shows.php" class="button">Cancel</a></li>
</ul>
</div>
</div>
</form>
<?php if ($id): ?>
<hr />
<form method="post" action="show_edit.php?id=<?= $id ?>" onsubmit="return confirm('Delete <?= e(addslashes($show['name'])) ?>? This cannot be undone.');">
<?= csrfField() ?>
<input type="hidden" name="action" value="delete" />
<input type="submit" class="small" value="Delete this show" />
</form>
<?php endif; ?>
<?php
adminFooter();