init
This commit is contained in:
commit
33bb79a095
|
@ -0,0 +1,3 @@
|
||||||
|
testing.sqlite
|
||||||
|
~*
|
||||||
|
.*.sw*
|
|
@ -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
|
|
@ -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 ?>">
|
||||||
|
← 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>
|
Loading…
Reference in New Issue