This commit is contained in:
Nat 2024-02-21 16:22:19 -08:00
commit 33bb79a095
Signed by: nat
GPG Key ID: B53AB05285D710D6
3 changed files with 410 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
testing.sqlite
~*
.*.sw*

36
README.md Normal file
View File

@ -0,0 +1,36 @@
# guestbook.php
This is a drop-in guestbook system using PHP and SQLite3, meant to be simple enough to fit into one file and be configurable by hand with ease.
## Setup
Assuming you have PHP installed on your server, setting up this guestbook should be as easy as dropping `guestbook.php` into a folder. It will create the database and necessary tables on the first load. If it doesn't work immediately it's probably a bug and I'd encourage you to reach out to me about it.
[Have the latest version of PHP installed][1], and make sure you have SQLite3 set up as well. On Debian, if you don't have `php-8.2-sqlite3` installed, you can install it by running:
```
$ sudo apt install php8.2-sqlite3
```
I've tested this using PHP 8.2. It seems like the Debian package `php8.3-sqlite3` so some additional setup may be required to get it working with PHP 8.3 if you're on Debian.
## Configuring
Several constants are defined using the PHP `define` function, which works as follows:
```php
define('[name of constant]', [PHP expression]);
```
You can change these constants according to your need. You can also search the file for references to them to see where they're used.
Beneath the PHP script at the top of the file, there's the complete HTML page for the guestbook, including the style sheet in the head tag, that you can adjust to your needs.
[1]: https://www.php.net/manual/en/install.php
## "License"
This software is a gift from me to you. By accepting this gift, we're forming a relationship, and with that comes certain expectations. Namely:
* When you share this gift with others, you will share it in the same spirit as I share it with you.
* You will not use this gift to hurt people, any living creatures, or the planet

371
guestbook.php Normal file
View File

@ -0,0 +1,371 @@
<?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>