diff options
-rw-r--r-- | visitors.php | 381 |
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: <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: <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> <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> <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> |