372 lines
8.6 KiB
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 ?>">
|
||
|
← 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 →
|
||
|
</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>
|