aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authoralyx <alyx@aleteoryx.me>2024-05-25 14:40:40 -0400
committeralyx <alyx@aleteoryx.me>2024-05-25 14:40:40 -0400
commitc403243350981d45a561357f0bc26fcf81fbc582 (patch)
treef85e4b25029a71730cc8dd74d3005027269af957
parentdd2058442589e9285ba91f6abc587c79ddd97040 (diff)
downloadvisitors_dot_php-c403243350981d45a561357f0bc26fcf81fbc582.tar.gz
visitors_dot_php-c403243350981d45a561357f0bc26fcf81fbc582.tar.bz2
visitors_dot_php-c403243350981d45a561357f0bc26fcf81fbc582.zip
Feature-complete
-rw-r--r--visitors.php381
1 files changed, 339 insertions, 42 deletions
diff --git a/visitors.php b/visitors.php
index 92abedb..37859ff 100644
--- a/visitors.php
+++ b/visitors.php
@@ -9,25 +9,79 @@ define('GIT_REPO', 'https://git.aleteoryx.me/cgit/visitors_dot_php/');
// Title: string
//
-// The title for the page and <title> element.
+// The title for the page.
$config['title'] = 'Guestbook';
+// Site name: ?string
+//
+// The name of the larger website.
+
+//$config['site_name'] = "Jonny Sims's Magical Mystery Machine";
+
+
+// Blurb: ?string
+//
+// If set, will display under the main heading.
+
+//$config['blurb'] = 'Look what all these nice people said!';
+
+
+// Date format: string
+//
+// Format for the date displayed next to entries.
+// Format docs: <https://www.php.net/manual/en/datetime.format.php>
+
+$config['date_format'] = 'Y/m/d H:i';
+
+
// Custom CSS: ?string
//
-// Will be injected after the builtin CSS. Respects `use_path_info` when being served.
+// Data should be CSS code to inject into the page.
+// Link should be the URL of a stylesheet.
+// Only set one, or neither.
-//$config['custom_css'] = 'body { background: red; }';
+//$config['custom_css_data'] = 'body { background: red; }';
+//$config['custom_css_link'] = '/static/guestbook.css';
+
+
+// Custom JS: ?string
+//
+// Data should be JavaScript code to inject into the page.
+// Link should be the URL of a JS script.
+// Only set one, or neither.
+// Data is loaded into a normal script tag, with no module support.
+
+//$config['custom_js_data'] = 'alert("Custom JS!")';
+//$config['custom_js_link'] = '/static/guestbook.js';
// Use builtin CSS: bool
//
-// Toggles the builtin CSS
+// Enable builtin CSS?
$config['builtin_css'] = true;
+// Content Order: choice
+//
+// 0 - Visits, Form
+// 1 - Form, Visits
+
+$config['content_order'] = 0;
+
+
+/* CONFIG OPTIONS - FORM */
+
+// Use captcha: bool
+//
+// Enable the captcha?
+// Recommended.
+
+$config['captcha'] = true;
+
+
// Form mode: choice
//
// 0 - Only ask for name
@@ -37,7 +91,7 @@ $config['builtin_css'] = true;
//
// Any entries in one mode will display fine in any other.
-$config['form_mode'] = 0;
+$config['form_mode'] = 1;
// E-Mail display mode: choice
@@ -53,13 +107,36 @@ $config['email_display'] = 1;
// E-Mail icon/link: ?string
//
-// Link should be a link to an image for the email icon. Icon should be the icon's data, base64 encoded, prefixed with `<mimetype>:`.
+// Link should be a link to an image for the email icon.
+// Icon should be the icon's data, base64 encoded, prefixed with `<mimetype>:`.
// Only set one, or neither. This option only applies for email_display modes 0 and 1.
//$config['email_link'] = '/static/my_cool_email_icon.gif';
//$config['email_icon'] = 'image/png:...';
+// Message box dimensions: int, int
+//
+// Sets the rows= and cols= html attributes on the <textarea? for the message.
+
+$config['message_rows'] = 5;
+$config['message_cols'] = 60;
+
+
+// Form prompt: string
+//
+// Text for a header above the form
+
+$config['form_prompt'] = "Write something in my guestbook!";
+
+
+// Submit button text: string
+//
+// Sets the text of the submit button
+
+$config['submit_text'] = "Sign!";
+
+
/* CONFIG OPTIONS - BACKEND */
// Send assets via PATH_INFO: bool
@@ -68,7 +145,7 @@ $config['email_display'] = 1;
// Otherwise, inline them into a single HTML document.
// Only enable this if your webserver supports PATH_INFO.
-$config['use_path_info'] = true;
+$config['use_path_info'] = false;
// Database path: string
@@ -85,6 +162,17 @@ $config['use_path_info'] = true;
$config['db'] = 'visitors.csv';
+// Captcha hook: ?fn() => ['prompt' => string, 'answer' => string]
+//
+// A function used to generate captchas. If unset, will be a builtin function.
+// The answer is kept server-side, so it can be anything.
+//
+// Ensure you've taken adequate steps to ensure the session is kept secure.
+// <https://www.php.net/manual/en/features.session.security.management.php>
+
+//$config['captcha_hook'] = fn() => ['prompt' => 'What's the year?', 'answer' => date('Y')];
+
+
/* --- END OF CONFIG, CODE BELOW, PROBABLY DON'T EDIT PAST THIS POINT --- */
@@ -99,11 +187,77 @@ if (!isset($config['email_link'])) { // stolen from <https://forum.melonland.net
}
}
+if (!isset($config['custom_css_link'])) {
+ if ($config['use_path_info'] && isset($config['custom_css_data'])) {
+ $config['custom_css_link'] = $_SERVER['SCRIPT_NAME'].'/custom_css';
+ }
+}
+
+if (!isset($config['custom_js_link'])) {
+ if ($config['use_path_info'] && isset($config['custom_js_data'])) {
+ $config['custom_js_link'] = $_SERVER['SCRIPT_NAME'].'/custom_js';
+ }
+}
+
+$config['captcha_hook'] ??= 'builtin_captcha';
+
/* BUILTIN_CSS */
define('BUILTIN_CSS', <<<EOT
+body {
+ background: #666;
+
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding-left: min(30%, calc(50%) - 5cm);
+ padding-right: min(30%, calc(50%) - 5cm);
+}
+body > h1, footer {
+ color: white;
+}
+body > a, footer > a {
+ color: cyan;
+}
+body > a:visited, footer > a:visited {
+ color: pink;
+}
+main {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ width: 100%;
+}
+main > article {
+ background: #bbb;
+ width: 100%;
+ margin: 0.1cm;
+ padding: 0.1cm;
+ border-radius: 0.1cm;
+ border: 1px white solid;
+}
+form {
+ background: #bbb;
+ margin: 1cm;
+ padding: 0.1cm;
+ border-radius: 0.1cm;
+ border: 1px white solid;
+}
+.visit-info > time {
+ float: right;
+}
+#submission_error {
+ margin-bottom: 0.5cm;
+}
+#submission_error > span {
+ background: lightcoral;
+ color: black;
+ font-weight: bold;
+ padding: 0.1cm;
+ border-radius: 0.1cm;
+}
EOT);
@@ -122,7 +276,11 @@ if (@$_SERVER['PATH_INFO']) {
break;
case '/custom_css':
header('Content-Type: text/css');
- echo $config['custom_css']??'';
+ echo $config['custom_css_data']??'';
+ break;
+ case '/custom_js':
+ header('Content-Type: text/javascript');
+ echo $config['custom_js_data']??'';
break;
default:
http_response_code('404');
@@ -136,25 +294,34 @@ if (@$_SERVER['PATH_INFO']) {
/* DATABASES */
-// db_row: ['id' => int (only for list_rows), 'name' => string, 'message' => string, 'email' => ?string, 'website' => ?string, 'timestamp' => string (or DateTimeInterface for list_rows)]
+// db_row: ['id' => int (only for list_rows), 'name' => string, 'message' => string, 'website' => ?string, 'email' => ?string, 'timestamp' => string (or DateTimeInterface for list_rows)]
// message is pre-escaped at insert time.
// timestamp is DateTimeInterface::ISO8601_EXPANDED format.
abstract class Database {
- public function append_row(string $name, string $message, ?string $email = NULL, ?string $website = NULL) {
+ private ?array $data_cache;
+
+ public function append_row(string $name, string $message, ?string $website = NULL, ?string $email = NULL) {
+ unset($this->data_cache);
$timestamp = date(DATE_ISO8601_EXPANDED);
- $this->_append_row(compact('name', 'message', 'email', 'website', 'timestamp'));
+ $this->_append_row(compact('name', 'message', 'website', 'email', 'timestamp'));
}
public function list_rows(): array {
+ if (isset($this->data_cache))
+ return $this->data_cache;
+
$data = $this->_list_rows();
foreach($data as &$row) {
$row['timestamp'] = DateTime::createFromFormat(DATE_ISO8601_EXPANDED, $row['timestamp']);
}
+
+ $this->data_cache = $data;
return $data;
}
public function delete_row(int $id): bool {
+ unset($this->data_cache);
return $this->_delete_row($id);
}
@@ -233,7 +400,7 @@ final class JsonlDatabase extends FileDatabase {
// notes: `id` is not stable
final class CsvDatabase extends FileDatabase {
- private const array KEY_ORDER = ['id', 'name', 'message', 'email', 'website', 'timestamp'];
+ private const array KEY_ORDER = ['id', 'name', 'message', 'website', 'email', 'timestamp'];
protected function save() {
$fp = fopen($this->file, 'w');
@@ -254,11 +421,11 @@ final class SqliteDatabase extends Database {
public function __construct(string $file) {
$this->handle = new Sqlite3($file);
if (!$this->handle->querySingle('SELECT count(*) FROM sqlite_master'))
- $this->handle->exec('CREATE TABLE visitors (id INTEGER PRIMARY KEY, name TEXT NOT NULL, message TEXT NOT NULL, email TEXT, website TEXT, timestamp TEXT NOT NULL)');
+ $this->handle->exec('CREATE TABLE visitors (id INTEGER PRIMARY KEY, name TEXT NOT NULL, message TEXT NOT NULL, website TEXT, email TEXT, timestamp TEXT NOT NULL)');
}
protected function _append_row(array $db_row) {
- $stmt = $this->handle->prepare('INSERT INTO visitors (name, message, email, website, timestamp) VALUES (:name, :message, :email, :website, :timestamp)');
+ $stmt = $this->handle->prepare('INSERT INTO visitors (name, message, website, email, timestamp) VALUES (:name, :message, :website, :email, :timestamp)');
foreach($db_row as $key => $value)
$stmt->bindValue($key, $value);
$stmt->execute();
@@ -300,37 +467,90 @@ function get_database_for_file(string $file): Database {
$db = get_database_for_file($config['db']);
-//$db->append_row('foo', 'bar');
+
+/* POST HANDLING */
+
+function builtin_captcha(): array {
+ global $db;
+ return ['prompt' => 'What is the name of the most recent visitor?',
+ 'answer' => trim($db->list_rows()[0]['name'])];
+}
+
+header('Refresh: 1400');
+session_start(['cookie_lifetime' => 1440]);
+
+function cleanup_post() {
+ global $config;
+ if ($config['captcha'] && (@$_SESSION['captcha'] !== htmlentities($_POST['captcha']??''))) {
+ return "Invalid captcha!";
+ }
+ if (isset($_POST['site-or-email'])) {
+ $_POST['site-or-email'] = trim($_POST['site-or-email']);
+ if (preg_match('/^(?:https?|gopher|gemini):\/\/(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_\+.~#?&\/=]*)$/', $_POST['site-or-email']))
+ $_POST['website'] = htmlentities($_POST['site-or-email']);
+ else if (preg_match('/^[a-zA-Z0-9.!#$%&’*+\/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/', $_POST['site-or-email']))
+ $_POST['email'] = htmlentities($_POST['site-or-email']);
+ else return "Invalid Website URL or E-Mail!";
+ }
+ else {
+ if (isset($_POST['website'])) {
+ $_POST['website'] = trim($_POST['website']);
+ if (!preg_match('/^(?:https?|gopher|gemini):\/\/(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_\+.~#?&\/=]*)$/', $_POST['website']))
+ return "Invalid Website URL!";
+ $_POST['website'] = htmlentities($_POST['website']);
+ }
+ if (isset($_POST['email'])) {
+ $_POST['email'] = trim($_POST['email']);
+ if (!preg_match('/^[a-zA-Z0-9.!#$%&’*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/', $_POST['email']))
+ return "Invalid E-Mail!";
+ $_POST['email'] = htmlentities($_POST['email']);
+ }
+ }
+ $_POST['name'] = htmlentities($_POST['name']);
+ $_POST['message'] = htmlentities($_POST['message']);
+}
+
+$form_error;
+if ($_SERVER['REQUEST_METHOD'] == 'POST') {
+ $form_error = cleanup_post();
+ if (!$form_error) {
+ $db->append_row($_POST['name'], $_POST['message'], @$_POST['website'], @$_POST['email']);
+ }
+}
+
/* ACTUAL UI */
$ed_1_or_3 = $config['email_display'] == 1 || $config['email_display'] == 3;
-?>
-<!doctype html>
-<html>
-<head>
-<meta charset='utf-8' />
-<title><?= $config['title'] ?></title>
-<?= $config['use_path_info'] ?
- '<link rel=stylesheet href="'.$_SERVER['REQUEST_URI'].'/builtin_css"/><link rel=stylesheet href="'.$_SERVER['REQUEST_URI'].'/custom_css"/>' :
- '<style>'.BUILTIN_CSS.'</style>'.(@$config['custom_css'] ? '<style>'.$config['custom_css'].'</style>' : '') ?>
-</head>
-<body>
- <h1><?= $config['title'] ?></h1>
+function author_link($row) {
+ global $ed_1_or_3;
+
+ if ($row['website'])
+ return '<a href="'.$row['website'].'">'.$row['name'].'</a>';
+ else if ($row['email'] && $ed_1_or_3)
+ return '<a href="mailto:'.$row['email'].'">'.$row['name'].'</a>';
+ else return $row['name'];
+}
+
+function email_element($row) {
+ global $ed_1_or_3, $config;
+
+ if (!$row['website'] && $row['email'] && $ed_1_or_3)
+ return '';
+ else if ($row['email'])
+ return match($config['email_display']) {
+ 0, 1 => '<a href="mailto:'.$row['email'].'"><img alt="e-mail icon" src="'.($config['email_link']??$_SERVER['SCRIPT_NAME'].'/email_icon').'" /></a>',
+ 2, 3 => '<a href="mailto:'.$row['email'].'">[e-mail]</a>',
+ 4 => 'mail:&nbsp<u>'.str_replace(['@', '.'], [' at ', ' dot '], $row['email']).'</u>'
+ };
+ else return '';
+}
+
+function render_visits() {
+ global $config, $db; ?>
<main>
- <?php foreach($db->list_rows() as $row):
- $author_link = $row['website'] ?
- '<a href="'.$row['website'].'">'.$row['name'].'</a>' :
- (($row['email'] && $ed_1_or_3) ?
- '<a href="mailto:'.$row['email'].'">'.$row['name'].'</a>' :
- $row['name']);
- $email_element = $row['email'] && (($row['website'] && $ed_1_or_3) || !$ed_1_or_3) ?
- match ($config['email_display']) {
- 0, 1 => '<a href="mailto:'.$row['email'].'"><img alt="e-mail icon" src="'.($config['email_link']??$_SERVER['REQUEST_URI'].'/email_icon').'" /></a>',
- 2, 3 => '<a href="mailto:'.$row['email'].'">[e-mail]</a>',
- 4 => 'mail:&nbsp<u>'.str_replace(['@', '.'], [' at ', ' dot '], $row['email']).'</u>'
- } : ''; ?>
+ <?php foreach($db->list_rows() as $row): ?>
<article id=visit-<?= $row['id'] ?>
data-visit-id=<?= $row['id'] ?>
data-visit-name="<?= $row['name'] ?>"
@@ -338,13 +558,90 @@ $ed_1_or_3 = $config['email_display'] == 1 || $config['email_display'] == 3;
data-visit-email="<?= $row['email'] ?>"
data-visit-website="<?= $row['website'] ?>">
<div class='visit-info'>
- <span class='author-link'><?= $author_link ?></span>&nbsp;&nbsp;<span class='email'><?= $email_element ?></span>
- <span class='wide'>
- <time datetime="<?= $row['timestamp']->format(DATE_ISO8601) ?>"><?= $row['timestamp']->format("Y/m/d H:i") ?></time>
+ <span class='author-link'><?= author_link($row) ?></span>&nbsp;&nbsp;<span class='email'><?= email_element($row) ?></span>
+ <time datetime="<?= $row['timestamp']->format(DATE_ISO8601) ?>"><?= $row['timestamp']->format($config['date_format']) ?></time>
</div>
<q><?= $row['message'] ?></q>
</article>
<?php endforeach; ?>
</main>
+<?php }
+
+function render_form() {
+ global $config, $form_error; ?>
+ <form id=submission method=POST>
+ <h2><?= $config['form_prompt'] ?></h2>
+ <?php if (isset($form_error)): ?>
+ <div id=submission_error><span><?= $form_error ?></span></div>
+ <?php endif; ?>
+
+ <label for=name>Name:</label> <input type=text placeholder='Alice P. Hacker' name=name required /><br />
+
+ <?php if ($config['form_mode'] == 1 || $config['form_mode'] == 2): ?>
+ <label for=website>Website (optional):</label> <input type=url placeholder='https://example.com' name=website /><br />
+ <?php endif;
+ if ($config['form_mode'] == 2): ?>
+ <label for=email>E-Mail (optional):</label> <input type=email placeholder='ahacker@example.com' name=email /><br />
+ <?php endif;
+ if ($config['form_mode'] == 3): ?>
+ <label for=site-or-email>Website or E-Mail (optional):</label> <input type=text pattern='^(?:https?|gopher|gemini):\/\/(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_\+.~#?&\/=]*)$|^[a-zA-Z0-9.!#$%&’*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$' placeholder='...' name=site-or-email /><br />
+ <?php endif; ?>
+
+ <label for=message>Message:</label><br />
+ <textarea name=message placeholder='Write something...' rows="<?= $config['message_rows'] ?>" cols="<?= $config['message_cols'] ?>" required></textarea><br />
+
+ <?php if ($config['captcha']):
+ $captcha = $config['captcha_hook']();
+ $_SESSION['captcha'] = $captcha['answer']; ?>
+ <label for=captcha>Captcha: <?= $captcha['prompt'] ?></label><br /><input type=text name=captcha required /><br />
+ <?php endif; ?>
+
+ <button name=submit><?= $config['submit_text'] ?></button>
+ </form>
+<?php }
+
+?>
+<!doctype html>
+<html>
+<head>
+ <meta charset='utf-8' />
+ <meta name=description content="<?= @$config['blurb'] ?>" />
+ <meta name='og:name' content="<?= $config['title'] ?>">
+ <meta name='og:description' content="<?= @$config['blurb'] ?>">
+ <meta name='og:site_name' content="<?= @$config['site_name'] ?>">
+ <meta name='og:type' content=website>
+ <title><?= (isset($config['site_name']) ? $config['site_name'].' - ' : '').$config['title'] ?></title>
+
+ <?php if ($config['builtin_css']):
+ if ($config['use_path_info']): ?>
+ <link rel=stylesheet href="<?= $_SERVER['SCRIPT_NAME'] ?>/builtin_css"/>
+ <?php else: ?>
+ <style><?= BUILTIN_CSS ?></style>
+ <?php endif;
+ endif; ?>
+
+ <?php if (isset($config['custom_css_link'])): ?>
+ <link rel=stylesheet href="<?= $config['custom_css_link'] ?>"/>
+ <?php elseif (isset($config['custom_css_data'])): ?>
+ <style><?= $config['custom_css_data'] ?></style>
+ <?php endif; ?>
+</head>
+<body>
+ <h1><?= $config['title'] ?></h1>
+ <?php if ($config['content_order'] == 0): ?>
+ <a href='#submission'>Jump to form</a>
+ <?php render_visits();
+ render_form();
+ elseif ($config['content_order'] == 1):
+ render_form();
+ render_visits();
+ endif; ?>
+ <footer>Powered by <a href="<?= GIT_REPO ?>">visitors_dot_php</a> version <?= VERSION ?>.</footer>
+
+ <?php if (isset($config['custom_js_link'])): ?>
+ <script src="<?= $config['custom_js_link'] ?>"></script>
+ <?php elseif (isset($config['custom_js_data'])): ?>
+ <script><?= $config['custom_js_data'] ?></script>
+ <?php endif; ?>
</body>
</html>