Make URL building more resilient and add tests. (#38)
Some checks failed
Run unit tests / run-unit-tests (push) Has been cancelled

Since the base URL and base path are user inputs, I'd like tkr to be resilient to any combination of leading and trailing slashes so people don't have to worry about that. This adds some helper functions to normalize URLs and adds tests to confirm that all combinations are handled correctly.

Reviewed-on: https://gitea.subcultureofone.org/greg/tkr/pulls/38
Co-authored-by: Greg Sarjeant <greg@subcultureofone.org>
Co-committed-by: Greg Sarjeant <greg@subcultureofone.org>
This commit is contained in:
Greg Sarjeant 2025-07-31 02:39:09 +00:00 committed by greg
parent a9f610fc60
commit 8b5a249450
15 changed files with 94 additions and 23 deletions

View File

@ -77,7 +77,7 @@ if ($method === 'POST' && $path != 'setup') {
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::info('Attempt to POST with invalid session. Redirecting to login.');
header('Location: ' . $config->basePath . '/login'); header('Location: ' . Util::buildRelativeUrl($config->basePath, 'login'));
exit; exit;
} }
} else { } else {

View File

@ -30,7 +30,7 @@ class AdminController extends Controller {
public function handleSave(){ public function handleSave(){
if (!Session::isLoggedIn()){ if (!Session::isLoggedIn()){
header('Location: ' . $config->basePath . '/login'); header('Location: ' . Util::buildRelativeUrl($config->basePath, 'login'));
exit; exit;
} }

View File

@ -30,7 +30,7 @@ class AuthController extends Controller {
Log::info("Successful login for {$username}"); Log::info("Successful login for {$username}");
Session::newLoginSession($user); Session::newLoginSession($user);
header('Location: ' . $config->basePath); header('Location: ' . Util::buildRelativeUrl($config->basePath));
exit; exit;
} else { } else {
Log::warning("Failed login for {$username}"); Log::warning("Failed login for {$username}");
@ -48,7 +48,7 @@ class AuthController extends Controller {
Session::end(); Session::end();
global $config; global $config;
header('Location: ' . $config->basePath); header('Location: ' . Util::buildRelativeUrl($config->basePath));
exit; exit;
} }
} }

View File

@ -29,7 +29,7 @@
break; break;
} }
header('Location: ' . $config->basePath . 'admin/emoji'); header('Location: ' . Util::buildRelativeUrl($config->basePath, 'admin/emoji'));
exit; exit;
} }

View File

@ -39,7 +39,7 @@ class HomeController extends Controller {
global $config; global $config;
// redirect to the index (will show the latest tick if one was sent) // redirect to the index (will show the latest tick if one was sent)
header('Location: ' . $config->basePath); header('Location: ' . Util::buildRelativeUrl($config->basePath));
exit; exit;
} }

View File

@ -35,7 +35,7 @@
$user = $user->save(); $user = $user->save();
// go back to the index and show the updated mood // go back to the index and show the updated mood
header('Location: ' . $config->basePath); header('Location: ' . Util::buildRelativeUrl($config->basePath));
exit; exit;
} }
} }

View File

@ -79,4 +79,28 @@ class Util {
return $baseUrl . $basePath . $path; return $baseUrl . $basePath . $path;
} }
public static function buildRelativeUrl(string $basePath, string $path = ''): string {
// Ensure basePath starts with / for relative URLs
$basePath = '/' . ltrim($basePath, '/');
// Remove trailing slash unless it's just '/'
if ($basePath !== '/') {
$basePath = rtrim($basePath, '/');
}
// Add path
$path = ltrim($path, '/');
if ($path === '') {
return $basePath;
}
// If basePath is root, don't add extra slash
if ($basePath === '/') {
return '/' . $path;
}
return $basePath . '/' . $path;
}
} }

View File

@ -10,10 +10,10 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" <link rel="stylesheet"
href="<?= Util::escape_html($config->basePath) ?>css/default.css"> href="<?= Util::escape_html(Util::buildRelativeUrl($config->basePath, 'css/default.css')) ?>">
<?php if (!empty($config->cssId)): ?> <?php if (!empty($config->cssId)): ?>
<link rel="stylesheet" <link rel="stylesheet"
href="<?= Util::escape_html($config->basePath) ?>css/custom/<?= Util::escape_html($config->customCssFilename()) ?>"> href="<?= Util::escape_html(Util::buildRelativeUrl($config->basePath, 'css/custom/' . $config->customCssFilename())) ?>">
<?php endif; ?> <?php endif; ?>
<link rel="alternate" <link rel="alternate"
type="application/rss+xml" type="application/rss+xml"

View File

@ -4,7 +4,7 @@
<h1><?php if ($isSetup): ?>Setup<?php else: ?>Admin<?php endif; ?></h1> <h1><?php if ($isSetup): ?>Setup<?php else: ?>Admin<?php endif; ?></h1>
<main> <main>
<form <form
action="<?php echo $config->basePath . ($isSetup ? 'setup' : 'admin') ?>" action="<?php echo Util::buildRelativeUrl($config->basePath, ($isSetup ? 'setup' : 'admin')) ?>"
method="post"> method="post">
<input type="hidden" name="csrf_token" value="<?= Util::escape_html($_SESSION['csrf_token']) ?>"> <input type="hidden" name="csrf_token" value="<?= Util::escape_html($_SESSION['csrf_token']) ?>">
<fieldset> <fieldset>

View File

@ -2,7 +2,7 @@
<?php /** @var Array $customCss */ ?> <?php /** @var Array $customCss */ ?>
<h1>CSS Management</h1> <h1>CSS Management</h1>
<main> <main>
<form action="<?= $config->basePath ?>admin/css" method="post" enctype="multipart/form-data"> <form action="<?= Util::buildRelativeUrl($config->basePath, 'admin/css') ?>" method="post" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="<?= Util::escape_html($_SESSION['csrf_token']) ?>"> <input type="hidden" name="csrf_token" value="<?= Util::escape_html($_SESSION['csrf_token']) ?>">
<fieldset> <fieldset>
<legend>Manage</legend> <legend>Manage</legend>

View File

@ -2,7 +2,7 @@
<?php /** @var array $emojiList */ ?> <?php /** @var array $emojiList */ ?>
<h1>Emoji Management</h1> <h1>Emoji Management</h1>
<main> <main>
<form action="<?= $config->basePath ?>admin/emoji" method="post" enctype="multipart/form-data"> <form action="<?= Util::buildRelativeUrl($config->basePath, 'admin/emoji') ?>" method="post" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="<?= Util::escape_html($_SESSION['csrf_token']) ?>"> <input type="hidden" name="csrf_token" value="<?= Util::escape_html($_SESSION['csrf_token']) ?>">
<fieldset> <fieldset>
<legend>Add Emoji</legend> <legend>Add Emoji</legend>
@ -24,7 +24,7 @@
</fieldset> </fieldset>
</form> </form>
<?php if (!empty($emojiList)): ?> <?php if (!empty($emojiList)): ?>
<form action="<?= $config->basePath ?>admin/emoji" method="post" enctype="multipart/form-data"> <form action="<?= Util::buildRelativeUrl($config->basePath, 'admin/emoji') ?>" method="post" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="<?= Util::escape_html($_SESSION['csrf_token']) ?>"> <input type="hidden" name="csrf_token" value="<?= Util::escape_html($_SESSION['csrf_token']) ?>">
<fieldset class="delete-emoji-fieldset"> <fieldset class="delete-emoji-fieldset">
<legend>Delete Emoji</legend> <legend>Delete Emoji</legend>

View File

@ -14,7 +14,7 @@
<?php if (Session::isLoggedIn()): ?> <?php if (Session::isLoggedIn()): ?>
<a <a
<?php if($config->strictAccessibility): ?>tabindex="0"<?php endif; ?> <?php if($config->strictAccessibility): ?>tabindex="0"<?php endif; ?>
href="<?= Util::escape_html($config->basePath) ?>mood" href="<?= Util::escape_html(Util::buildRelativeUrl($config->basePath, 'mood')) ?>"
class="change-mood">Change mood</a> class="change-mood">Change mood</a>
<?php endif ?> <?php endif ?>
</dd> </dd>

View File

@ -2,7 +2,7 @@
<?php /** @var string $csrf_token */ ?> <?php /** @var string $csrf_token */ ?>
<?php /** @var string $error */ ?> <?php /** @var string $error */ ?>
<h2>Login</h2> <h2>Login</h2>
<form method="post" action="<?= $config->basePath ?>login"> <form method="post" action="<?= Util::buildRelativeUrl($config->basePath, 'login') ?>">
<div class="fieldset-items"> <div class="fieldset-items">
<input type="hidden" name="csrf_token" value="<?= Util::escape_html($csrf_token) ?>"> <input type="hidden" name="csrf_token" value="<?= Util::escape_html($csrf_token) ?>">
<label for="username">Username:</label> <label for="username">Username:</label>

View File

@ -2,32 +2,32 @@
<?php /* https://www.w3schools.com/howto/howto_css_dropdown.asp */ ?> <?php /* https://www.w3schools.com/howto/howto_css_dropdown.asp */ ?>
<nav aria-label="Main navigation"> <nav aria-label="Main navigation">
<a <?php if($config->strictAccessibility): ?>tabindex="0"<?php endif; ?> <a <?php if($config->strictAccessibility): ?>tabindex="0"<?php endif; ?>
href="<?= Util::escape_html($config->basePath) ?>">home</a> href="<?= Util::escape_html(Util::buildRelativeUrl($config->basePath)) ?>">home</a>
<details> <details>
<summary aria-haspopup="true">feeds</summary> <summary aria-haspopup="true">feeds</summary>
<div class="dropdown-items"> <div class="dropdown-items">
<a <?php if($config->strictAccessibility): ?>tabindex="0"<?php endif; ?> <a <?php if($config->strictAccessibility): ?>tabindex="0"<?php endif; ?>
href="<?= Util::escape_html($config->basePath) ?>feed/rss">rss</a> href="<?= Util::escape_html(Util::buildRelativeUrl($config->basePath, 'feed/rss')) ?>">rss</a>
<a <?php if($config->strictAccessibility): ?>tabindex="0"<?php endif; ?> <a <?php if($config->strictAccessibility): ?>tabindex="0"<?php endif; ?>
href="<?= Util::escape_html($config->basePath) ?>feed/atom">atom</a> href="<?= Util::escape_html(Util::buildRelativeUrl($config->basePath, 'feed/atom')) ?>">atom</a>
</div> </div>
</details> </details>
<?php if (!Session::isLoggedIn()): ?> <?php if (!Session::isLoggedIn()): ?>
<a tabindex="0" <a tabindex="0"
href="<?= Util::escape_html($config->basePath) ?>login">login</a> href="<?= Util::escape_html(Util::buildRelativeUrl($config->basePath, 'login')) ?>">login</a>
<?php else: ?> <?php else: ?>
<details> <details>
<summary aria-haspopup="true">admin</summary> <summary aria-haspopup="true">admin</summary>
<div class="dropdown-items"> <div class="dropdown-items">
<a <?php if($config->strictAccessibility): ?>tabindex="0"<?php endif; ?> <a <?php if($config->strictAccessibility): ?>tabindex="0"<?php endif; ?>
href="<?= Util::escape_html($config->basePath) ?>admin">settings</a> href="<?= Util::escape_html(Util::buildRelativeUrl($config->basePath, 'admin')) ?>">settings</a>
<a <?php if($config->strictAccessibility): ?>tabindex="0"<?php endif; ?> <a <?php if($config->strictAccessibility): ?>tabindex="0"<?php endif; ?>
href="<?= Util::escape_html($config->basePath) ?>admin/css">css</a> href="<?= Util::escape_html(Util::buildRelativeUrl($config->basePath, 'admin/css')) ?>">css</a>
<a <?php if($config->strictAccessibility): ?>tabindex="0"<?php endif; ?> <a <?php if($config->strictAccessibility): ?>tabindex="0"<?php endif; ?>
href="<?= Util::escape_html($config->basePath) ?>admin/emoji">emoji</a> href="<?= Util::escape_html(Util::buildRelativeUrl($config->basePath, 'admin/emoji')) ?>">emoji</a>
</div> </div>
</details> </details>
<a <?php if($config->strictAccessibility): ?>tabindex="0"<?php endif; ?> <a <?php if($config->strictAccessibility): ?>tabindex="0"<?php endif; ?>
href="<?= Util::escape_html($config->basePath) ?>logout">logout</a> href="<?= Util::escape_html(Util::buildRelativeUrl($config->basePath, 'logout')) ?>">logout</a>
<?php endif; ?> <?php endif; ?>
</nav> </nav>

View File

@ -26,4 +26,51 @@ final class UtilTest extends TestCase
$this->assertSame($relativeTime, $display); $this->assertSame($relativeTime, $display);
} }
public static function buildUrlProvider(): array {
return [
'basic path' => ['https://example.com', 'tkr', 'admin', 'https://example.com/tkr/admin'],
'baseUrl with trailing slash' => ['https://example.com/', 'tkr', 'admin', 'https://example.com/tkr/admin'],
'empty basePath' => ['https://example.com', '', 'admin', 'https://example.com/admin'],
'root basePath' => ['https://example.com', '/', 'admin', 'https://example.com/admin'],
'basePath no leading slash' => ['https://example.com', 'tkr', 'admin', 'https://example.com/tkr/admin'],
'basePath with leading slash' => ['https://example.com', '/tkr', 'admin', 'https://example.com/tkr/admin'],
'basePath with trailing slash' => ['https://example.com', 'tkr/', 'admin', 'https://example.com/tkr/admin'],
'basePath with both slashes' => ['https://example.com', '/tkr/', 'admin', 'https://example.com/tkr/admin'],
'complex path' => ['https://example.com', 'tkr', 'admin/css/upload', 'https://example.com/tkr/admin/css/upload'],
'path with leading slash' => ['https://example.com', 'tkr', '/admin', 'https://example.com/tkr/admin'],
'no path - empty basePath' => ['https://example.com', '', '', 'https://example.com/'],
'no path - root basePath' => ['https://example.com', '/', '', 'https://example.com/'],
'no path - tkr basePath' => ['https://example.com', 'tkr', '', 'https://example.com/tkr/'],
];
}
#[DataProvider('buildUrlProvider')]
public function testBuildUrl(string $baseUrl, string $basePath, string $path, string $expected): void {
$result = Util::buildUrl($baseUrl, $basePath, $path);
$this->assertEquals($expected, $result);
}
public static function buildRelativeUrlProvider(): array {
return [
'empty basePath with path' => ['', 'admin', '/admin'],
'root basePath with path' => ['/', 'admin', '/admin'],
'tkr basePath with path' => ['tkr', 'admin', '/tkr/admin'],
'tkr with leading slash' => ['/tkr', 'admin', '/tkr/admin'],
'tkr with trailing slash' => ['tkr/', 'admin', '/tkr/admin'],
'tkr with both slashes' => ['/tkr/', 'admin', '/tkr/admin'],
'complex path' => ['tkr', 'admin/css/upload', '/tkr/admin/css/upload'],
'path with leading slash' => ['tkr', '/admin', '/tkr/admin'],
'no path - empty basePath' => ['', '', '/'],
'no path - root basePath' => ['/', '', '/'],
'no path - tkr basePath' => ['tkr', '', '/tkr'],
'no path - tkr with slashes' => ['/tkr/', '', '/tkr'],
];
}
#[DataProvider('buildRelativeUrlProvider')]
public function testBuildRelativeUrl(string $basePath, string $path, string $expected): void {
$result = Util::buildRelativeUrl($basePath, $path);
$this->assertEquals($expected, $result);
}
} }