Fix feed links, clean up single tick pages (#69)

Fix feed links. Clean up single-tick page.

Reviewed-on: https://gitea.subcultureofone.org/greg/tkr/pulls/69
Co-authored-by: Greg Sarjeant <greg@subcultureofone.org>
Co-committed-by: Greg Sarjeant <greg@subcultureofone.org>
This commit is contained in:
Greg Sarjeant 2025-08-14 01:10:35 +00:00 committed by greg
parent d3a537aa6c
commit eeb73eccd4
10 changed files with 58 additions and 34 deletions

View File

@ -370,6 +370,11 @@ time {
font-size: 1.0em; font-size: 1.0em;
display: block; display: block;
} }
.tick-meta {
color: var(--color-log-muted);
font-size: 0.9em;
margin-bottom: 0.4em;
}
.tick-pagination a { .tick-pagination a {
margin: 0 5px; margin: 0 5px;

View File

@ -118,7 +118,7 @@ if ($method === 'POST' && $path != 'tkr-setup') {
if ($path != 'login'){ if ($path != 'login'){
if (!Session::isValid($_POST['csrf_token'])) { if (!Session::isValid($_POST['csrf_token'])) {
// Invalid session - redirect to /login // Invalid session - redirect to /login
Log::info('Attempt to POST with invalid session. Redirecting to login.'); Log::warning('Attempt to POST with invalid session. Redirecting to login.');
header('Location: ' . Util::buildRelativeUrl($app['settings']->basePath, 'login')); header('Location: ' . Util::buildRelativeUrl($app['settings']->basePath, 'login'));
exit; exit;
} }

View File

@ -6,8 +6,8 @@ class HomeController extends Controller {
// renders the homepage view. // renders the homepage view.
public function index(){ public function index(){
$page = isset($_GET['page']) ? max(1, (int)$_GET['page']) : 1; $page = isset($_GET['page']) ? max(1, (int)$_GET['page']) : 1;
$data = $this->getHomeData($page); $vars = $this->getHomeData($page);
$this->render("home.php", $data); $this->render("home.php", $vars);
} }
public function getHomeData(int $page): array { public function getHomeData(int $page): array {

View File

@ -2,22 +2,27 @@
declare(strict_types=1); declare(strict_types=1);
class TickController extends Controller{ class TickController extends Controller{
public function index(int $id){ public function index(string $id){
// This is because my router is too simplistic to cleanly handle type casting,
// so I just accept a sting here and cast it to an int immediately.
$id = (int) $id;
global $app; global $app;
$vars = ['settings' => $app['settings']];
Log::debug("Fetching tick with ID: {$id}"); Log::debug("Fetching tick with ID: {$id}");
try { try {
$tickModel = new TickModel($app['db'], $app['settings']); $tickModel = new TickModel($app['db'], $app['settings']);
$vars = $tickModel->get($id); $tick = $tickModel->get($id);
if (empty($vars) || !isset($vars['tick'])) { if (empty($tick) || !isset($tick['tick'])) {
Log::warning("Tick not found for ID: {$id}"); Log::warning("Tick not found for ID: {$id}");
http_response_code(404); http_response_code(404);
echo '<h1>404 - Tick Not Found</h1>'; $this->render('tick-404.php', $vars);
return; return;
} }
$vars = array_merge($tick, $vars);
Log::info("Successfully loaded tick {$id}: " . substr($vars['tick'], 0, 50) . (strlen($vars['tick']) > 50 ? '...' : '')); Log::info("Successfully loaded tick {$id}: " . substr($vars['tick'], 0, 50) . (strlen($vars['tick']) > 50 ? '...' : ''));
$this->render('tick.php', $vars); $this->render('tick.php', $vars);
@ -30,30 +35,30 @@ class TickController extends Controller{
public function handleDelete(string $id){ public function handleDelete(string $id){
global $app; global $app;
$id = (int) $id; $id = (int) $id;
Log::debug("Attempting to delete tick with ID: {$id}"); Log::debug("Attempting to delete tick with ID: {$id}");
try { try {
$tickModel = new TickModel($app['db'], $app['settings']); $tickModel = new TickModel($app['db'], $app['settings']);
// TickModel->delete() handles validation and sets flash messages: // TickModel->delete() handles validation and sets flash messages:
// - "Tick not found" if tick doesn't exist // - "Tick not found" if tick doesn't exist
// - "Tick is too old to delete" if outside deletion window // - "Tick is too old to delete" if outside deletion window
// - "Deleted: '{content}'" on success // - "Deleted: '{content}'" on success
$success = $tickModel->delete($id); $success = $tickModel->delete($id);
if ($success) { if ($success) {
Log::info("Successfully deleted tick {$id}"); Log::info("Successfully deleted tick {$id}");
} else { } else {
Log::warning("Failed to delete tick {$id}"); Log::warning("Failed to delete tick {$id}");
} }
} catch (Exception $e) { } catch (Exception $e) {
Log::error("Exception while deleting tick {$id}: " . $e->getMessage()); Log::error("Exception while deleting tick {$id}: " . $e->getMessage());
Session::setFlashMessage('error', 'An error occurred while deleting the tick'); Session::setFlashMessage('error', 'An error occurred while deleting the tick');
} }
// Redirect back to homepage // Redirect back to homepage
header('Location: ' . Util::buildRelativeUrl($app['settings']->basePath, '')); header('Location: ' . Util::buildRelativeUrl($app['settings']->basePath, ''));
exit(); exit();

View File

@ -41,7 +41,7 @@ class AtomGenerator extends FeedGenerator {
$tickUrl = Util::escape_xml($siteUrl . $tickPath); $tickUrl = Util::escape_xml($siteUrl . $tickPath);
$tickTime = date(DATE_ATOM, strtotime($tick['timestamp'])); $tickTime = date(DATE_ATOM, strtotime($tick['timestamp']));
$tickTitle = Util::escape_xml($tick['tick']); $tickTitle = Util::escape_xml($tick['tick']);
$tickContent = Util::linkify($tickTitle); $tickContent = Util::escape_xml(Util::linkify(Util::escape_html($tick['tick'])));
?> ?>
<entry> <entry>
<title><?= $tickTitle ?></title> <title><?= $tickTitle ?></title>

View File

@ -35,10 +35,11 @@ class RssGenerator extends FeedGenerator {
$tickUrl = Util::escape_xml($this->buildTickUrl($tick['id'])); $tickUrl = Util::escape_xml($this->buildTickUrl($tick['id']));
$tickDate = date(DATE_RSS, strtotime($tick['timestamp'])); $tickDate = date(DATE_RSS, strtotime($tick['timestamp']));
$tickTitle = Util::escape_xml($tick['tick']); $tickTitle = Util::escape_xml($tick['tick']);
$tickDescription = Util::linkify($tickTitle); $tickDescription = Util::escape_xml(Util::linkify(Util::escape_html($tick['tick'])));
Log::debug("RSS item: {$tickDescription}");
?> ?>
<item> <item>
<title><?php echo $tickTitle ?></title> <title><?php echo $tickTitle; ?></title>
<link><?php echo $tickUrl; ?></link> <link><?php echo $tickUrl; ?></link>
<description><?php echo $tickDescription; ?></description> <description><?php echo $tickDescription; ?></description>
<pubDate><?php echo $tickDate; ?></pubDate> <pubDate><?php echo $tickDate; ?></pubDate>

View File

@ -7,14 +7,14 @@ class TickModel {
public function getPage(int $limit, int $offset = 0): array { public function getPage(int $limit, int $offset = 0): array {
$stmt = $this->db->prepare("SELECT id, timestamp, tick FROM tick ORDER BY timestamp DESC LIMIT ? OFFSET ?"); $stmt = $this->db->prepare("SELECT id, timestamp, tick FROM tick ORDER BY timestamp DESC LIMIT ? OFFSET ?");
$stmt->execute([$limit, $offset]); $stmt->execute([$limit, $offset]);
$ticks = $stmt->fetchAll(PDO::FETCH_ASSOC); $ticks = $stmt->fetchAll(PDO::FETCH_ASSOC);
return array_map(function($tick) { return array_map(function($tick) {
$tickTime = new DateTimeImmutable($tick['timestamp'], new DateTimeZone('UTC')); $tickTime = new DateTimeImmutable($tick['timestamp'], new DateTimeZone('UTC'));
$now = new DateTimeImmutable('now', new DateTimeZone('UTC')); $now = new DateTimeImmutable('now', new DateTimeZone('UTC'));
$hoursSinceCreation = ($now->getTimestamp() - $tickTime->getTimestamp()) / 3600; $hoursSinceCreation = ($now->getTimestamp() - $tickTime->getTimestamp()) / 3600;
$tick['can_delete'] = $hoursSinceCreation <= $this->settings->tickDeleteHours; $tick['can_delete'] = $hoursSinceCreation <= $this->settings->tickDeleteHours;
return $tick; return $tick;
}, $ticks); }, $ticks);
@ -41,7 +41,6 @@ class TickModel {
return [ return [
'tickTime' => $row['timestamp'], 'tickTime' => $row['timestamp'],
'tick' => $row['tick'], 'tick' => $row['tick'],
'settings' => $this->settings,
]; ];
} }
@ -50,26 +49,26 @@ class TickModel {
$stmt = $this->db->prepare("SELECT tick, timestamp FROM tick WHERE id=?"); $stmt = $this->db->prepare("SELECT tick, timestamp FROM tick WHERE id=?");
$stmt->execute([$id]); $stmt->execute([$id]);
$row = $stmt->fetch(PDO::FETCH_ASSOC); $row = $stmt->fetch(PDO::FETCH_ASSOC);
if ($row === false || empty($row)) { if ($row === false || empty($row)) {
Session::setFlashMessage('error', 'Tick not found'); Session::setFlashMessage('error', 'Tick not found');
return false; return false;
} }
// Check deletion window // Check deletion window
$tickTime = new DateTimeImmutable($row['timestamp'], new DateTimeZone('UTC')); $tickTime = new DateTimeImmutable($row['timestamp'], new DateTimeZone('UTC'));
$now = new DateTimeImmutable('now', new DateTimeZone('UTC')); $now = new DateTimeImmutable('now', new DateTimeZone('UTC'));
$hoursSinceCreation = ($now->getTimestamp() - $tickTime->getTimestamp()) / 3600; $hoursSinceCreation = ($now->getTimestamp() - $tickTime->getTimestamp()) / 3600;
if ($hoursSinceCreation > $this->settings->tickDeleteHours) { if ($hoursSinceCreation > $this->settings->tickDeleteHours) {
Session::setFlashMessage('error', 'Tick is too old to delete'); Session::setFlashMessage('error', 'Tick is too old to delete');
return false; return false;
} }
// Delete and set success message // Delete and set success message
$stmt = $this->db->prepare("DELETE FROM tick WHERE id=?"); $stmt = $this->db->prepare("DELETE FROM tick WHERE id=?");
$stmt->execute([$id]); $stmt->execute([$id]);
Session::setFlashMessage('success', "Deleted: '{$row['tick']}'"); Session::setFlashMessage('success', "Deleted: '{$row['tick']}'");
return true; return true;
} }

View File

@ -0,0 +1,4 @@
<div class="not-found-container">
<h1>Tick Not Found</h1>
<p>The tick you're looking for has been deleted or never existed.</p>
</div>

View File

@ -1,4 +1,14 @@
<?php /** @var Date $tickTime */ ?> <?php /** @var Date $tickTime */ ?>
<?php /** @var string $tick */ ?> <?php /** @var string $tick */ ?>
<h1>Tick from <?= $tickTime; ?></h1> <?php $displayTime = DateTimeImmutable::createFromformat('Y-m-d H:i:s', $tickTime) ?>
<p><?= Util::linkify(Util::escape_html($tick)) ?></p> <div class="tick-container">
<article class="tick">
<header class="tick-header">
<h1>Tick</h1>
<p class="tick-meta">Posted on <time class="tick-meta" datetime="<?= $displayTime->format('c') ?>"><?= $displayTime->format('F j, Y \a\t g:i A') ?></time> UTC</p>
</header>
<div class="tick-text">
<?= Util::linkify(Util::escape_html($tick)) ?>
</div>
</article>
</div>

View File

@ -62,7 +62,7 @@ class TickControllerTest extends TestCase
ob_start(); ob_start();
$controller = new TickController(); $controller = new TickController();
$controller->index(123); $controller->index("123");
$output = ob_get_clean(); $output = ob_get_clean();
@ -96,12 +96,12 @@ class TickControllerTest extends TestCase
ob_start(); ob_start();
$controller = new TickController(); $controller = new TickController();
$controller->index(999); $controller->index("999");
$output = ob_get_clean(); $output = ob_get_clean();
// Should return 404 error // Should return 404 error
$this->assertStringContainsString('404 - Tick Not Found', $output); $this->assertStringContainsString('Tick Not Found', $output);
} }
public function testIndexWithEmptyTickData(): void public function testIndexWithEmptyTickData(): void
@ -125,12 +125,12 @@ class TickControllerTest extends TestCase
ob_start(); ob_start();
$controller = new TickController(); $controller = new TickController();
$controller->index(456); $controller->index("456");
$output = ob_get_clean(); $output = ob_get_clean();
// Should return 404 error for empty data // Should return 404 error for empty data
$this->assertStringContainsString('404 - Tick Not Found', $output); $this->assertStringContainsString('Tick Not Found', $output);
} }
public function testIndexWithDatabaseException(): void public function testIndexWithDatabaseException(): void
@ -145,7 +145,7 @@ class TickControllerTest extends TestCase
ob_start(); ob_start();
$controller = new TickController(); $controller = new TickController();
$controller->index(123); $controller->index("123");
$output = ob_get_clean(); $output = ob_get_clean();