From c403243350981d45a561357f0bc26fcf81fbc582 Mon Sep 17 00:00:00 2001 From: alyx Date: Sat, 25 May 2024 14:40:40 -0400 Subject: Feature-complete --- visitors.php | 381 ++++++++++++++++++++++++++++++++++++++++++++++++++++------- 1 file 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 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'] ?> -' : - ''.(@$config['custom_css'] ? '' : '') ?> - - -

+function author_link($row) { + global $ed_1_or_3; + + if ($row['website']) + return ''.$row['name'].''; + else if ($row['email'] && $ed_1_or_3) + return ''.$row['name'].''; + 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 => 'e-mail icon', + 2, 3 => '[e-mail]', + 4 => 'mail: '.str_replace(['@', '.'], [' at ', ' dot '], $row['email']).'' + }; + else return ''; +} + +function render_visits() { + global $config, $db; ?>
- list_rows() as $row): - $author_link = $row['website'] ? - ''.$row['name'].'' : - (($row['email'] && $ed_1_or_3) ? - ''.$row['name'].'' : - $row['name']); - $email_element = $row['email'] && (($row['website'] && $ed_1_or_3) || !$ed_1_or_3) ? - match ($config['email_display']) { - 0, 1 => 'e-mail icon', - 2, 3 => '[e-mail]', - 4 => 'mail: '.str_replace(['@', '.'], [' at ', ' dot '], $row['email']).'' - } : ''; ?> + list_rows() as $row): ?>
data-visit-id= data-visit-name="" @@ -338,13 +558,90 @@ $ed_1_or_3 = $config['email_display'] == 1 || $config['email_display'] == 3; data-visit-email="" data-visit-website="">
-    - - +    +
+ +
+

+ +
+ + +
+ + +
+ +
+ +
+ + +
+
+ + +

+ + + +
+ + + + + + + + + + + <?= (isset($config['site_name']) ? $config['site_name'].' - ' : '').$config['title'] ?> + + + + + + + + + + + + + + +

+ + Jump to form + + + + + + + + -- cgit v1.2.3-54-g00ecf