guestbook/guestbook.php

372 lines
8.6 KiB
PHP

<?php
define('GUESTBOOK_NAME', 'Your guestbook');
define('GUESTBOOK_PAGE_SIZE', 10);
define('GUESTBOOK_RATE_LIMIT_SECONDS', 20);
define('GUESTBOOK_SQLITE_LOCATION', './testing.sqlite');
/* If the given answer to the challenge question does not match
* CHALLENGE_ANSWER_REGEX, the submission will be quietly rejected
*/
define('CHALLENGE_QUESTION', 'What month is it? Give your answer as a name of a month:');
define('CHALLENGE_ANSWER_REGEX', '/^' . date('F') . '$/i');
/* If content in the name, website or message match this regex, the
* submission will be quietly rejected.
*/
define('SHADOWBAN_REGEX', '/As a large language model/im');
enum SubmissionOutcome: Int {
case Success = 0;
case Invalid = 1;
case Failure = 2;
case Shadowban = 3;
case RateLimit = 4;
case NoSubmission = 5;
}
$submission_outcome = SubmissionOutcome::NoSubmission;
$conn = new SQLite3(GUESTBOOK_SQLITE_LOCATION);
$conn->exec("
CREATE TABLE IF NOT EXISTS guest (
hash TEXT, PRIMARY KEY(hash)
);
");
$conn->exec("
CREATE TABLE IF NOT EXISTS entry (
guestHash TEXT NOT NULL,
id INTEGER,
name TEXT,
website TEXT,
message TEXT,
published integer default (cast(strftime('%s', 'now') as int)),
FOREIGN KEY(guestHash) REFERENCES guest(hash),
PRIMARY KEY(id AUTOINCREMENT)
);
");
function stringLengthIsBetween($str, $lower, $upper) {
return strlen($str) >= $lower && strlen($str) <= $upper;
}
function submissionIsValid($data) {
if (!stringLengthIsBetween($data['name'], 1, 100)) {
return false;
}
if (!($data['website'] == null || stringLengthIsBetween($data['website'], 3, 100))) {
return false;
}
if (!stringLengthIsBetween($data['website'], 0, 1000)) {
return false;
}
return true;
}
function handleEntrySubmission($db) {
if (!submissionIsValid($_POST)) {
return SubmissionOutcome::Invalid;
}
if (preg_match(CHALLENGE_ANSWER_REGEX, $_POST["challengeQuestion"]) === 0) {
return SubmissionOutcome::Shadowban;
}
if (
preg_match(SHADOWBAN_REGEX, $_POST["name"]) == 1 ||
preg_match(SHADOWBAN_REGEX, $_POST["website"]) == 1 ||
preg_match(SHADOWBAN_REGEX, $_POST["message"]) == 1
) {
return SubmissionOutcome::Shadowban;
}
$client_fingerprint = hash('sha256', $_SERVER['REMOTE_ADDR'] . "#" . $_SERVER['REMOTE_ADDR']);
$latest_entry_stmt = $db->prepare("
select published from entry
where guestHash = :guestHash
order by published desc
limit 1
");
$latest_entry_stmt->bindValue(':guestHash', $client_fingerprint, SQLITE3_TEXT);
$latest_entry_time = $latest_entry_stmt->execute()->fetchArray()['published'];
if ($_SERVER['REQUEST_TIME'] - $latest_entry_time <= GUESTBOOK_RATE_LIMIT_SECONDS) {
return SubmissionOutcome::RateLimit;
}
$entry_insert_stmt = $db->prepare("
insert into entry(guestHash, name, website, message)
values (:guestHash, :name, :website, :message)
");
$entry_insert_stmt->bindValue(':guestHash', $client_fingerprint, SQLITE3_TEXT);
$entry_insert_stmt->bindValue(':name', $_POST["name"], SQLITE3_TEXT);
$entry_insert_stmt->bindValue(':website', $_POST["website"], SQLITE3_TEXT);
$entry_insert_stmt->bindValue(':message', $_POST["message"], SQLITE3_TEXT);
if (!$entry_insert_stmt->execute()) {
return SubmissionOutcome::Failure;
}
return SubmissionOutcome::Success;
}
if ($_SERVER['REQUEST_METHOD'] == 'POST') {
$submission_outcome = handleEntrySubmission($conn);
}
?>
<!DOCTYPE html>
<html>
<head>
<title><?php echo GUESTBOOK_NAME ?></title>
<style>
h1, h2, h3, h4, h5, h6 {
font-family: "Lucida Console", Monaco, monospace;
}
main {
margin-left: 30%;
margin-right: 30%;
}
ul[role="navigation"] {
padding: 0;
list-style: none;
}
table.form-table {
padding-bottom: 10px;
}
input, textarea {
margin-bottom: 5px;
}
td {
padding-left: 0;
}
form {
border: 3px double black;
padding: 20px;
width: auto;
display: inline-block;
}
ol {
list-style: none;
padding-left: 0;
}
ol > li {
border-bottom: 3px double black;
}
.entry-header {
margin-bottom: 5px;
}
.entry-website {
font-family: "Lucida Console", Monaco, monospace;
margin-top: 0;
}
ul[role="navigation"] > li {
display: inline-block;
margin-right: 10px;
}
@media screen and (max-width: 1000px) {
main {
margin-left: 20%;
margin-right: 20%;
}
}
@media screen and (max-width: 700px) {
main {
margin-left: 5%;
margin-right: 5%;
}
}
</style>
</head>
<body>
<main>
<h1>Sign the guestbook</h1>
<?php if ($submission_outcome != SubmissionOutcome::NoSubmission) { ?>
<div>
<?php
switch ($submission_outcome) {
case SubmissionOutcome::Invalid:
?>
<h2>Your entry doesn't seem right</h2>
<p>
Double check that you inputted the right values,
and are meeting the form's requirements.
</p>
<?php
break;
case SubmissionOutcome::Failure:
?>
<h2>Something went wrong...</h2>
<p>
An error occured on the server. Reach out the the
administrator to see this fixed!
</p>
<?php
break;
case SubmissionOutcome::RateLimit:
?>
<h2>Too many submissions</h2>
<p>
To prevent spam, we only allow people to submit one entry every
<?php echo number_format(GUESTBOOK_RATE_LIMIT_SECONDS / 60, 2, '.', '') ?>
minutes. Try again later.
</p>
<?php
break;
default:
?>
<h2>Success!</h2>
<p>
Your entry has been saved to the guestbook.
</p>
<?php
}
?>
</div>
<?php } ?>
<form method="post" action="/guestbook.php">
<label for="name">Name:</label>
<input
name="name"
type="text"
autocomplete="username"
placeholder="Your Name"
maxlength="100"
minlength="1"
required
><br>
<label for="website">Website:</label>
<input name="website"
type="url"
autocomplete="url"
placeholder="example.com"
maxlength="1000"
minlength="3"
><br><br>
<label for="challengeQuestion">
<?php echo CHALLENGE_QUESTION; ?>
</label><br>
<input
name="challengeQuestion"
type="text"
maxlength="40"
required
><br>
<label for="message">Message:</label><br>
<textarea
name="message"
maxlength="1000"
placeholder="Share your thoughts..."
></textarea><br>
<input type="submit" value="Publish"/>
</form>
<ol>
<?php
$query_params = array();
parse_str($_SERVER['QUERY_STRING'], $query_params);
$current_page = $query_params['page']??0;
$entry_count_result = $conn->query('select count(*) as entryCount from entry');
$entry_count = $entry_count_result->fetchArray()['entryCount'];
$entry_list_stmt = $conn->prepare("
select id, name, website, message from entry
order by id desc
limit :limit offset :offset
");
$entry_list_stmt->bindValue('limit', GUESTBOOK_PAGE_SIZE, SQLITE3_INTEGER);
$entry_list_stmt->bindValue('offset', GUESTBOOK_PAGE_SIZE * $current_page, SQLITE3_INTEGER);
$entry_list_results = $entry_list_stmt->execute();
$next_entry = $entry_list_results->fetchArray();
while ($next_entry) {
?>
<li>
<h2 class="entry-header">
<?php echo htmlspecialchars($next_entry['name'], ENT_QUOTES, 'UTF-8') ?>
</h2>
<p class="entry-website">
<?php echo htmlspecialchars($next_entry['website'], ENT_QUOTES, 'UTF-8') ?>
</p>
<p class="entry-message">
<?php echo htmlspecialchars($next_entry['message'], ENT_QUOTES, 'UTF-8') ?><br>
<em>Published <?php echo date('Y-m-d', $next_entry['created']); ?></em>
</p>
</li>
<?php
$next_entry = $entry_list_results->fetchArray();
}
?>
</ol>
<ul role="navigation">
<?php if ($current_page > 0) { ?>
<li>
<a href="?page=<?php echo $current_page - 1 ?>">
&#8592; Previous
</a>
</li>
<li>
<a href="?page=0">
First
</a>
</li>
<?php } ?>
<?php if (GUESTBOOK_PAGE_SIZE * $current_page + 1 < $entry_count && $entry_count - GUESTBOOK_PAGE_SIZE * $current_page > GUESTBOOK_PAGE_SIZE) { ?>
<li>
<a href="?page=<?php echo $current_page + 1 ?>">
Next &#8594;
</a>
</li>
<?php } ?>
</ul>
<footer>
<details>
<summary>
Privacy
</summary>
<p>
To prevent spam, this guestbook will store a hash (i.e. otherwise unrecognizable code) of your IP address. If you make an entry, this hash can be used to identify whether or not you've already made an entry for a limited period of time (IP addresses change often), however, the hash alone cannot feasibly be used to determine your IP address. If you'd like to have an entry removed, please reach out to the website owner.
</p>
</details>
</footer>
</main>
</body>
</html>