Status codes are broken out to a separate file for better handling, search logic moved to separate file, CSS style improvements

This commit is contained in:
Andrew Gioia 2023-12-08 15:28:41 -05:00
parent 8f3f4e65e1
commit 01d01483d0
No known key found for this signature in database
GPG Key ID: FC09694A000800C8
5 changed files with 501 additions and 251 deletions

View File

@ -1,5 +1,5 @@
<?php
class RedirectCheck
class Follow
{
// constants
const USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36";
@ -141,42 +141,4 @@ class RedirectCheck
return $last['url'];
}
}
// handle form submissions
if (isset($_POST['url']))
{
// check that we got a valid URL
$url = (filter_var(trim($_POST['url']), FILTER_VALIDATE_URL))
? trim($_POST['url'])
: false;
// if so, start up the redirect checks
if ($url)
{
// make a request for this url and add to the path
$request = new RedirectCheck($url);
$code = '';
do {
// set the URL
$request->url = $url;
// make the curl request and update the path
$request->getHttpCode();
// end on an error
if ($request->error)
{
break;
}
// if we have a redirect to follow, update our working $url
$url = ($request->next) ? $request->next : false;
// update our code
$code = ($request->code) ? $request->code : false;
} while ($code != 200);
}
}
?>

291
codes.php Normal file
View File

@ -0,0 +1,291 @@
<?php
$codes = [
100 => [
'label' => 'Informational',
'start' => '1XX',
'range' => '199',
'codes' => [
100 => [
'name' => 'Continue',
'desc' => 'Continue the request or ignore the response if the request is already finished.'
],
101 => [
'name' => 'Switching Protocols',
'desc' => 'Sent in response to a client\'s <code>Upgrade</code> request header and indicates the protocol the server is switching to.'
],
102 => [
'name' => 'Processing',
'desc' => 'Server has received and is processing the request, but no response is available yet.'
],
103 => [
'name' => 'Early Hints',
'desc' => 'Intended alongside <code>Link</code> header to let the user agent preconnect or start preloading resources while the server prepares a response.'
]
]
],
200 => [
'label' => 'Successful',
'start' => '2XX',
'range' => '299',
'codes' => [
200 => [
'name' => 'OK',
'desc' => 'The request succeeded.'
],
201 => [
'name' => 'Created',
'desc' => 'The request succeeded, and a new resource was created as a result.'
],
202 => [
'name' => 'Accepted',
'desc' => 'The request has been received but not yet acted upon.'
],
203 => [
'name' => 'Non-Authoritative Information',
'desc' => 'Typically for mirrors of original resources, the returned metadata is not exactly the same as is available from the origin server but is collected from a local or a third-party copy.'
],
204 => [
'name' => 'No Content',
'desc' => 'There is no content to send for this request, but the headers may be useful.'
],
205 => [
'name' => 'Reset Content',
'desc' => 'Tells the user agent to reset the document which sent this request.'
],
206 => [
'name' => 'Partial Content',
'desc' => 'Only part of a resource is sent in response to the <code>Range</code> header.'
],
207 => [
'name' => 'Multi-Status',
'desc' => 'Conveys information about multiple resources, for situations where multiple status codes might be appropriate.'
],
208 => [
'name' => 'Already Reported (WebDAV)',
'desc' => 'Used inside a <code>&lt;dav:propstat&gt;</code> response element to limit repetition.'
],
226 => [
'name' => 'IM Used',
'desc' => 'For <code>HTTP Delta encoding</code> when the server has fullfilled a <code>GET</code> request and the response is from 1+ instance manipulations.'
]
]
],
300 => [
'label' => 'Redirection',
'start' => '3XX',
'range' => '399',
'codes' => [
300 => [
'name' => 'Multiple Choices',
'desc' => 'The request has more than one possible response and the user agent or user should choose one of them.'
],
301 => [
'name' => 'Moved Permanently',
'desc' => 'The URL of the requested resource has been changed permanently. The new URL is given in the response.'
],
302 => [
'name' => 'Found',
'desc' => 'URI of the requested resource has been changed temporarily and may change in the future.'
],
303 => [
'name' => 'See Other',
'desc' => 'Client should get the requested resource at another URI with a GET request.'
],
304 => [
'name' => 'Not Modified',
'desc' => 'For caching purposes, indicating the response has not been modified so the client can use the cached version.'
],
305 => [
'name' => '<del>Use Proxy</del>',
'desc' => 'Deprecated. Indicates the requested response must be accessed via proxy.'
],
306 => [
'name' => '(Reserved)',
'desc' => 'Unused and now reserved. Previously in the HTTP/1.1 spec.'
],
307 => [
'name' => 'Temporary Redirect',
'desc' => 'Client should get the requested resource at another URI via the same method. Similar to <code>302</code> but the request method must stay the same.'
],
308 => [
'name' => 'Permanent Redirect',
'desc' => 'Response is now permanently located at a new URI included in the <code>Location:</code> header. Similar to <code>301</code> but the request method must stay the same.'
]
]
],
400 => [
'label' => 'Client Error',
'start' => '4XX',
'range' => '499',
'codes' => [
400 => [
'name' => 'Bad Request',
'desc' => 'Server will not process the request due to an unspecified error by the client.'
],
401 => [
'name' => 'Unauthorized',
'desc' => 'More accurately, "unauthenticated."'
],
402 => [
'name' => '<var>Payment Required</var>',
'desc' => 'Reserved for future use in digital payment systems.'
],
403 => [
'name' => 'Forbidden',
'desc' => 'Client is unauthorized and does not have access rights to the resource.'
],
404 => [
'name' => 'Not Found',
'desc' => 'Server cannot find the requested resource. One of the most common response headers after <code>200</code>.'
],
405 => [
'name' => 'Method Not Allowed',
'desc' => 'Request method is known by the server but is not supported by the target resource. Common in APIs.'
],
406 => [
'name' => 'Not Acceptable',
'desc' => 'Server performs content negotiation with the client and does not find content that meets the user agent\'s criteria.'
],
407 => [
'name' => 'Proxy Authentication Required',
'desc' => 'Similar to <code>401 Unauthorized</code> but authentication is needed to be done by a proxy.'
],
408 => [
'name' => 'Request Timeout',
'desc' => 'Server shuts down an unnused connection by the client.'
],
409 => [
'name' => 'Conflict',
'desc' => 'Request conflicts with the current state of the server.'
],
410 => [
'name' => 'Gone',
'desc' => 'Requested resource has been permanently deleted from server, with no forwarding address.'
],
411 => [
'name' => 'Length Required',
'desc' => 'Server rejected the request because the <code>Content-Length</code> header field is not defined and the server requires it.'
],
412 => [
'name' => 'Precondition Failed',
'desc' => 'Client has indicated preconditions in its headers which the server does not meet.'
],
413 => [
'name' => 'Payload Too Large',
'desc' => 'Request entity is larger than limits defined by server.'
],
414 => [
'name' => 'URI Too Long',
'desc' => 'URI requested by the client is longer than the server is willing to interpret.'
],
415 => [
'name' => 'Unsupported Media Type',
'desc' => 'Media format of the requested data is not supported by the server.'
],
416 => [
'name' => 'Range Not Satisfiable',
'desc' => 'Size specified by the <code>Range</code> header field in the request cannot be fulfilled.'
],
417 => [
'name' => 'Expectation Failed',
'desc' => 'Expectation indicated by the <code>Expect</code> request header field cannot be met by the server.'
],
418 => [
'name' => 'I\'m a teapot',
'desc' => 'Server refuses the attempt to brew coffee with a teapot. Reference to Hyper Text Coffee Pot Control Protocol defined in April Fools\' jokes in 1998 and 2014!'
],
421 => [
'name' => 'Misdirected Request',
'desc' => 'Request was directed at a server that is not able to produce a response.'
],
422 => [
'name' => 'Unprocessable Content (WebDAV)',
'desc' => 'Request was well-formed but was unable to be followed due to semantic errors.'
],
423 => [
'name' => 'Locked (WebDAV)',
'desc' => 'Resource that is being accessed is locked.'
],
424 => [
'name' => 'Failed Dependency (WebDAV)',
'desc' => 'Request failed due to failure of a previous request.'
],
425 => [
'name' => '<var>Too Early</var>',
'desc' => 'Reserved for future use. Server is unwilling to risk processing a request that might be replayed.'
],
426 => [
'name' => 'Upgrade Required',
'desc' => 'Request currently refused over the current protocol but the server may provide a reponse should the client use a different protocol.'
],
428 => [
'name' => 'Precondition Required',
'desc' => 'Origin server requires the request to be conditional.'
],
429 => [
'name' => 'Too Many Requests',
'desc' => 'User agent has sent too many requests in a given amount of time (i.e., it\'s rate limited).'
],
431 => [
'name' => 'Request Header Fields Too Large',
'desc' => 'Server is unwilling to process the request because its header fields are too large.'
],
451 => [
'name' => 'Unavailable For Legal Reasons',
'desc' => 'User agent requested a resource that cannot legally be provided (e.g., web page censored by a government).'
]
]
],
500 => [
'label' => 'Server Error',
'start' => '5XX',
'range' => '599',
'codes' => [
500 => [
'name' => 'Internal Server Error',
'desc' => 'Server has encountered a situation it does not know how to handle. One of the most common response codes.'
],
501 => [
'name' => 'Not Implemented',
'desc' => 'Request method is not supported by the server and cannot be handled.'
],
502 => [
'name' => 'Bad Gateway',
'desc' => 'Server recieved an invalid response while acting as a gateway.'
],
503 => [
'name' => 'Service Unavailable',
'desc' => 'Server is not ready to handle the request, commonly due to maintenance downtime or overload.'
],
504 => [
'name' => 'Gateway Timeout',
'desc' => 'Server cannot get a response in time while acting as a gateway.'
],
505 => [
'name' => 'HTTP Version Not Supported',
'desc' => 'HTTP version used in the request is not supported by the server.'
],
506 => [
'name' => 'Variant Also Negotiates',
'desc' => 'Only in Transparent Content Negotiation contexts; server has an internal configuration error where the user agent\'s chosen variant is not available.'
],
507 => [
'name' => 'Insufficient Storage (WebDAV)',
'desc' => 'Server is unable to store the representation needed to successfully complete the request.'
],
508 => [
'name' => 'Loop Detected (WebDAV)',
'desc' => 'Server detected an infinite loop while processing the request.'
],
509 => [
'name' => 'Not Extended',
'desc' => 'Further extensions to the request are required for the server to fulfill it.'
],
510 => [
'name' => 'Network Authentication Required',
'desc' => 'Client needs to authenticate to gain network access.'
]
]
]
];
?>

312
index.php
View File

@ -1,246 +1,140 @@
<?php
include('class.php');
include('search.php');
include('codes.php');
?>
<!DOCTYPE html>
<html>
<head>
<title>Redirect Checker</title>
<link rel="stylesheet" type="text/css" href="style.css" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Redirect Checker</title>
<link rel="stylesheet" type="text/css" href="style.css" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body>
<header>
<menu>
<h1>Follow <span>URL Redirect Checker</span></h1>
<nav>
<a href="/"><span>Check a new URL</span></a>
</nav>
</menu>
<form method="post" action="index.php" type="application/x-www-form-urlencoded">
<input type="search" value="" placeholder="URL to check..." name="url">
<button type="submit">Check</button>
</form>
</header>
<main>
<header>
<menu>
<h1>Follow <span>URL Redirect Checker</span></h1>
<nav>
<a href="/"><span>Check a new URL</span></a>
</nav>
</menu>
<form method="post" action="/" type="application/x-www-form-urlencoded">
<input type="url" value="" placeholder="URL to check..." name="url">
<button type="submit">Check</button>
</form>
</header>
<main>
<?php
// error message display
if (isset($error))
{
}
// if we have a valid request object
if (isset($request) && count($request->path) > 0)
{
$steps = count($request->path) - 1;
$count = ($steps == 1) ? '1 redirect' : $steps.' redirects';
echo "
<article>
<h2>
Final destination
</h2>
<p>
The URL you traced had ".$count." and ended here:
</p>
<p class=\"final\">
<a href=\"".$request->getFinalRedirect()."\" target=\"blank\">
".$request->getFinalRedirect()."
</a>
</p>
<h3>
Redirect trace
</h3>
<table>
<thead>
<tr>
<th>Step</th>
<th>Request</th>
<th>Code</th>
<th>Redirect</th>
</tr>
</thead>
<tbody>";
<h2>Final destination</h2>
<p>The URL you traced had ".$count." and ended here:</p>
<p class=\"final\">
<a href=\"".$request->getFinalRedirect()."\" target=\"blank\">
".$request->getFinalRedirect()."
</a>
</p>
<h3>Redirect trace</h3>
<table>
<thead>
<tr>
<th>Step</th>
<th>Request</th>
<th colspan=\"2\">Code</th>
</tr>
</thead>
<tbody>";
foreach ($request->path as $step)
{
$item = $step['step'];
$url = $step['url'];
$code = $step['code'];
$next = ($step['next']) ? 'Yes' : '--';
$next = ($step['next']) ? '⥂⇄↩︎' : '✓';
echo "
<tr>
<td>".$item."</td>
<td>".$url."</td>
<td><code>".$code."</code></td>
<td>".$next."</td>
</tr>";
<tr>
<td>".$item."</td>
<td>".$url." <a href=\"".$url."\" target=\"blank\"><b>↗︎</b></a></td>
<td><code>".$code."</code></td>
<td>".$next."</td>
</tr>";
}
echo "
</tbody>
<caption>
User agent: ".$request::USER_AGENT."
</caption>
</table>
</tbody>
<caption>
User agent: ".$request::USER_AGENT."
</caption>
</table>
</article>";
}
else
{
echo "
<article>
<h2>
Trace a URL's redirects
</h2>
<p>
Enter a URL in the search box above to derive the final resolved URL after all redirects. The script runs recursive cURL requests until we get a <code>200</code> status code. This is helpful to get around link tracking or original URLs that Pi-hole outright blocks (like email links).
</p>
<p>
I used to use <a href=\"https://wheregoes.com\">wheregoes.com</a> which is a good, reliable service, but decided to roll my own for privacy reasons. Absolutely nothing is logged as all URL searches are via POST and that's not currently included in my nginx logs.
</p>
<h3>
Technical details
</h3>
<p>
User agent
</p>
<p>
Other cURL settings
</p>
<h2>
Trace a URL's redirects
</h2>
<p>
Enter a URL in the search box above to derive the final resolved URL after all redirects. The script runs recursive cURL requests until we get a <code>200</code> status code. This is helpful to get around link tracking or original URLs that Pi-hole outright blocks (like email links).
</p>
<p>
I used to use <a href=\"https://wheregoes.com\">wheregoes.com</a> which is a good, reliable service, but decided to roll my own for privacy reasons. Absolutely nothing is logged as all URL searches are via POST and that's not currently included in my nginx logs.
</p>
<h3>
Technical details
</h3>
<p>
User agent
</p>
<p>
Other cURL settings
</p>
</article>
";
}
?>
<aside>
<h3>
HTTP response status codes
</h3>
<details>
<summary>
Informational (<code>1XX</code>&ndash;<code>199</code>)
</summary>
<dl>
<dt>
<label for="100">
<code>100</code> Continue
</label>
</dt>
<input type="checkbox" id="100">
<dd>Continue the request or ignore the response if the request is already finished.</dd>
<dt>
<label for="101">
<code>101</code> Switching Protocols
</label>
</dt>
<input type="checkbox" id="101">
<dd>Sent in response to a client's <code>Upgrade</code> request header and indicates the protocol the server is switching to.</dd>
<dt>
<label for="102">
<code>102</code> Processing
</label>
</dt>
<input type="checkbox" id="102">
<dd>Server has received and is processing the request, but no response is available yet.</dd>
<dt>
<label for="103">
<code>103</code> Early Hints
</label>
</dt>
<input type="checkbox" id="103">
<dd>Intended alongside <code>Link</code> header to let the user agent preconnect or start preloading resources while the server prepares a response.</dd>
</dl>
</details>
<details>
<summary>
Successful (<code>2XX</code>&ndash;<code>299</code>)
</summary>
<dl>
<dt>
<label for="200">
<code>200</code> OK
</label>
</dt>
<input type="checkbox" id="200">
<dd>The request succeeded.</dd>
<dt>
<label for="201">
<code>201</code> Created
</label>
</dt>
<input type="checkbox" id="201">
<dd>The request succeeded, and a new resource was created as a result.</dd>
<dt>
<label for="202">
<code>202</code> Accepted
</label>
</dt>
<input type="checkbox" id="202">
<dd>The request has been received but not yet acted upon.</dd>
<dt>
<label for="203">
<code>203</code> Non-Authoritative Information
</label>
</dt>
<input type="checkbox" id="203">
<dd>Typically for mirrors of original resources, the returned metadata is not exactly the same as is available from the origin server but is collected from a local or a third-party copy.</dd>
<dt>
<label for="204">
<code>204</code> No Content
</label>
</dt>
<input type="checkbox" id="204">
<dd>There is no content to send for this request, but the headers may be useful.</dd>
<dt>
<label for="205">
<code>205</code> Reset Content
</label>
</dt>
<input type="checkbox" id="205">
<dd>Tells the user agent to reset the document which sent this request.</dd>
<dt>
<label for="206">
<code>206</code> Partial Content
</label>
</dt>
<input type="checkbox" id="206">
<dd>Only part of a resource is sent in response to the <code>Range</code> header.</dd>
<dt>
<label for="207">
<code>207</code> Multi-Status
</label>
</dt>
<input type="checkbox" id="207">
<dd>Conveys information about multiple resources, for situations where multiple status codes might be appropriate.</dd>
<dt>
<label for="208">
<code>208</code> Already Reported (WebDAV)
</label>
</dt>
<input type="checkbox" id="208">
<dd>Used inside a <code>&lt;dav:propstat&gt;</code> response element to limit repetition.</dd>
<dt>
<label for="226">
<code>226</code> IM Used
</label>
</dt>
<input type="checkbox" id="226">
<dd>For <code>HTTP Delta encoding</code> when the server has fullfilled a <code>GET</code> request and the response is from 1+ instance manipulations.</dd>
</dl>
</details>
<details>
<summary>
Redirects (<code>3XX</code>&ndash;<code>399</code>)
</summary>
<p>
300
</p>
</details>
<details>
<summary>
Client error (<code>4XX</code>&ndash;<code>499</code>)
</summary>
<p>
100
</p>
</details>
<details>
<summary>
Server error (<code>5XX</code>&ndash;<code>599</code>)
</summary>
<p>
500
</p>
</details>
<h3>
HTTP response status codes
</h3>
<?php
if (isset($codes) && is_array($codes) && !empty($codes))
{
foreach ($codes as $group)
{
echo "
<details>
<summary>
".$group['label']." (<code>".$group['start']."</code>&ndash;<code>".$group['range']."</code>)
</summary>
<dl>";
foreach ($group['codes'] as $code => $prop)
{
echo "
<!-- ".$code." -->
<dt>
<label for=\"".$code."\"><code>".$code."</code> ".$prop['name']."</label>
</dt>
<input type=\"checkbox\" id=\"".$code."\">
<dd>".$prop['desc']."</dd>";
}
echo "
</dl>
</details>";
}
}
?>
<p>
See <a href="https://httpwg.org/specs/rfc9110.html#overview.of.status.codes">RFC 9110</a> for official documentation on each status code.
</p>

46
search.php Normal file
View File

@ -0,0 +1,46 @@
<?php
// handle form submissions
if (isset($_POST['url']))
{
// check that we got a valid URL
$url = (filter_var(trim($_POST['url']), FILTER_VALIDATE_URL))
? trim($_POST['url'])
: false;
// if so, start up the redirect checks
if ($url)
{
// make a request for this url and add to the path
$request = new Follow($url);
$code = '';
do {
// set the URL
$request->url = $url;
// make the curl request and update the path
$request->getHttpCode();
// end on an error
if ($request->error)
{
break;
}
// if we have a redirect to follow, update our working $url
$url = ($request->next) ? $request->next : false;
// update our code
$code = ($request->code) ? $request->code : false;
} while ($code != 200);
}
else
{
$error = [
'type' => 'search',
'message' => 'There was an issue with URL you searched. Make sure it\'s a well-formed URL.'
];
}
}
?>

View File

@ -23,7 +23,7 @@ header {
background: #fff;
display: flex;
flex-direction: column;
padding-bottom: 3rem;
height: 14rem;
width: 100%;
}
@ -79,7 +79,7 @@ form {
position: relative;
width: 90%;
}
input[type="search"] {
input[type="url"] {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='11' cy='11' r='8'%3E%3C/circle%3E%3Cline x1='21' y1='21' x2='16.65' y2='16.65'%3E%3C/line%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: 1.25rem 50%;
@ -93,7 +93,7 @@ input[type="search"] {
transition: box-shadow 150ms ease-in-out;
width: 100%;
}
input[type="search"]:focus {
input[type="url"]:focus {
outline: none;
box-shadow: inset 0 0.25rem 0 0 #f0eded, 0 0 0 1px #fff, 0 0 0 0.25rem #39b9e4;
}
@ -132,6 +132,8 @@ article {
}
aside {
max-height: calc(100vh - 16.5rem);
overflow: auto;
width: 86%;
}
details {
@ -151,6 +153,30 @@ details dl {
details dl dt {
margin: 0 0 0.5rem;
}
details dl dt del,
details dl dt var {
align-items: center;
display: inline-flex;
flex-direction: row;
font-style: normal;
}
details dl dt del::after,
details dl dt var::after {
background-repeat: no-repeat;
background-size: 1rem 1rem;
background-position: 100% 50%;
content: '';
display: block;
height: 1rem;
margin-left: 0.5rem;
width: 1rem;
}
details dl dt del::after {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 6h18'/%3E%3Cpath d='M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6'/%3E%3Cpath d='M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2'/%3E%3Cline x1='10' x2='10' y1='11' y2='17'/%3E%3Cline x1='14' x2='14' y1='11' y2='17'/%3E%3C/svg%3E");
}
details dl dt var::after {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 2v7.527a2 2 0 0 1-.211.896L4.72 20.55a1 1 0 0 0 .9 1.45h12.76a1 1 0 0 0 .9-1.45l-5.069-10.127A2 2 0 0 1 14 9.527V2'/%3E%3Cpath d='M8.5 2h7'/%3E%3Cpath d='M7 16h10'/%3E%3C/svg%3E");
}
details dl dd {
display: none;
font-size: 0.875rem;
@ -158,6 +184,9 @@ details dl dd {
margin-left: 0;
padding: 0 0 1rem 0.1rem;
}
details dl dd code {
font-size: 0.8rem;
}
details dl input[type="checkbox"] {
display: none;
}
@ -174,6 +203,7 @@ code {
background: #fff;
padding: 0.1rem 0.2rem;
border-radius: 0.25rem;
white-space: nowrap;
}
h2 {
@ -222,9 +252,36 @@ td {
vertical-align: top;
word-wrap: anywhere;
}
td:nth-child(2) {
line-height: 1.5rem;
padding: 0.8rem 1rem;
}
td:nth-child(2) a:hover {
text-decoration-color: transparent;
}
td b {
border: 1px solid #aaa;
border-right-width: 2px;
border-bottom-width: 2px;
border-radius: 0.25rem;
color: #555;
font-weight: 500;
line-height: 1;
margin-left: 0.5rem;
padding: 0 0.25rem;
transition: border-color 150ms ease-in-out, color 150ms ease-in-out;
}
td:nth-child(2) a:hover b {
border-color: #1a1a1a;
color: #1a1a1a;
}
tbody tr:nth-child(odd) td {
background-color: #fff;
}
tbody td:last-child {
padding-left: 0;
width: 1rem;
}
caption {
caption-side: bottom;
line-height: 1.5rem;