Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted. THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. */ $config = array(); define('VERSION', '0.0.1'); define('GIT_REPO', 'https://git.aleteoryx.me/cgit/visitors_dot_php/'); /* CONFIG OPTIONS - COSMETIC */ // Title: string // // 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: $config['date_format'] = 'Y/m/d H:i'; // Custom CSS: ?string // // 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_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 // // 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 // 1 - Ask for name and website // 2 - Ask for name, website, and email // 3 - Ask for name and (website or email) // // Any entries in one mode will display fine in any other. $config['form_mode'] = 2; // E-Mail display mode: choice // // 0 - Show an icon with a `mailto:` link // 1 - Show an icon with a `mailto:` link, or use the username text as the link if no website is present // 2 - Show the text `[e-mail]` with a `mailto:` link // 3 - Show the text `[e-mail]` with a `mailto:` link, or use the username text for the link if no website is present // 4 - Show the e-mail as escaped text, e.g. 'alyx at aleteoryx dot me' $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 `:`. // 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 ['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. // //$config['captcha_hook'] = fn() => ['prompt' => 'What's the year?', 'answer' => date('Y')]; /* --- END OF CONFIG, CODE BELOW, PROBABLY DON'T EDIT PAST THIS POINT --- */ /* CONFIG FIXING */ if (!isset($config['email_link'])) { // stolen from $config['email_icon'] ??= 'image/gif:R0lGODlhEAAQALMEAAAAADExY2NjMWNjzv///5ycY5yc/86cY87O///OnP//zv///wAAAAAAAAAAAAAAACH5BAEAAAQALAAAAAAQABAAAARfkJBCqy1CyrK690qmcUd5KKiSiNuSvio7LWVinqs2w2kuDaSbLTYgDAwcHmplCAwWBprplTA0nwiDspp1LhBZq9gKvn6z4DS6izWo1W6z+w2/Hsd46yBACAD+gIGABBEAOw=='; if (!$config['use_path_info']) { [$mime, $base64] = explode(':', $config['email_icon'], 2); $config['email_link'] = 'data:'.$mime.';base64,'.$base64; unset($config['email_icon']); } } 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', << h1, body > p, 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); /* PATH_INFO HANDLING */ if (@$_SERVER['PATH_INFO']) { switch ($_SERVER['PATH_INFO']) { case '/email_icon': [$mime, $base64] = explode(':', $config['email_icon'], 2); header('Content-Type: '.$mime); echo base64_decode($base64); break; case '/builtin_css': header('Content-Type: text/css'); echo BUILTIN_CSS; break; case '/custom_css': header('Content-Type: text/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'); header('Content-Type: text/html'); echo "

404: Asset Not Found.

"; break; } exit(); } /* DATABASES */ // 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 { 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', '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); } abstract public function __construct(string $file); abstract protected function _append_row(array $db_row); abstract protected function _list_rows(): array; abstract protected function _delete_row(int $id): bool; } // sigh, refactor later, it wont be a perf issue for any realistic number of entries. abstract class FileDatabase extends Database { protected string $file; protected array $data; public function __construct(string $file) { if (file_exists($file)) $this->data = $this->load($file); else $this->data = array(); $this->file = $file; } protected function _append_row(array $db_row) { array_unshift($this->data, array(...$db_row, 'id' => count($this->data) ? max(array_map(fn($a) => $a['id'], $this->data)) : 0)); $this->save(); } protected function _list_rows(): array { $data = $this->data; return $data; } protected function _delete_row(int $id): bool { foreach(array_keys($this->data) as $key) if ($this->data[$key]['id'] === $id) { unset($this->data[$key]); $this->save(); return true; } return false; } abstract protected function save(); abstract protected function load(string $file): array; } // notes: `id` is not stable final class JsonDatabase extends FileDatabase { protected function save() { file_put_contents($this->file, json_encode($this->data)); } protected function load(string $file): array { return json_decode(file_get_contents($file), associative: true); } } // notes: `id` is not stable final class JsonlDatabase extends FileDatabase { protected function save() { $fp = fopen($this->file, 'w'); foreach($data as $row) { fwrite($fp, json_encode($row)."\n"); } fclose($fp); } protected function load(string $file): array { return array_map(fn($s) => json_decode($s, associative: true), file($file)); } } // notes: `id` is not stable final class CsvDatabase extends FileDatabase { private const array KEY_ORDER = ['id', 'name', 'message', 'website', 'email', 'timestamp']; protected function save() { $fp = fopen($this->file, 'w'); foreach($this->data as $row) { fputcsv($fp, array_map(fn($k) => $row[$k]??'', self::KEY_ORDER)); } fclose($fp); } protected function load(string $file): array { return array_map(fn($s) => array_combine(self::KEY_ORDER, str_getcsv($s)), file($file)); } } final class SqliteDatabase extends Database { private Sqlite3 $handle; 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, website TEXT, email TEXT, timestamp TEXT NOT NULL)'); } protected function _append_row(array $db_row) { $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(); } protected function _list_rows(): array { $rows = array(); $res = $this->handle->query('SELECT * FROM visitors ORDER BY id DESC'); while ($row = $res->fetchArray(SQLITE3_ASSOC)) $rows[] = $row; return $rows; } protected function _delete_row(int $id): bool { $stmt = $this->handle->prepare('SELECT count(*) FROM visitors WHERE id=:id'); $stmt->bindValue('id', $id); if (!$stmt->execute()->fetchArray(SQLITE3_NUM)[0]) return false;; $stmt = $this->handle->prepare('DELETE FROM visitors WHERE id=:id'); $stmt->bindValue('id', $id); $stmt->execute(); return true; } } function get_database_for_file(string $file): Database { $ext = preg_replace('/.+\.([^.]+)$/', '${1}', $file); return match ($ext) { 'db', 'sqlite', 'sqlite3' => new SqliteDatabase($file), 'csv' => new CsvDatabase($file), 'json' => new JsonDatabase($file), 'jsonl' => new JsonlDatabase($file), default => new CsvDatabase($file) }; } $db = get_database_for_file($config['db']); /* POST HANDLING */ function builtin_captcha(): array { global $db; $rows = $db->list_rows(); if (isset($rows[0]['name'])) return ['prompt' => 'What is the name of the most recent visitor?', 'answer' => trim($rows[0]['name'])]; else return ['prompt' => 'What is the year?', 'answer' => date('Y')]; } header('Refresh: 1400'); session_start(['cookie_lifetime' => 1440]); function cleanup_post() { global $config; $ws = ["\r", "\n"]; // error_log(var_export($_POST, true)); 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'] = str_replace(["\r", "\n"], "", 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'] = str_replace(["\r", "\n"], "", htmlentities($_POST['site-or-email'])); else return "Invalid Website URL or E-Mail!"; } else { if (isset($_POST['website']) && $_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']); $_POST['website'] = str_replace(["\r", "\n"], "", $_POST['email']); } if (isset($_POST['email']) && $_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['email'] = str_replace(["\r", "\n"], "", $_POST['email']); } } $_POST['name'] = str_replace(["\r", "\n"], "", htmlentities($_POST['name'])); $_POST['message'] = implode('
', explode("\n", htmlentities($_POST['message']))); if (mb_strlen($_POST['name']) > 128) return 'Name too long!'; if (mb_strlen($_POST['message']) > $config['message_length']) return 'Message too long!'; if (mb_strlen($_POST['website']) > 2048) return 'Website too long!'; if (mb_strlen($_POST['email']) > 2048) return 'Email too long!'; } $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; 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): ?>
data-visit-id= data-visit-name="" data-visit-message="" data-visit-email="" data-visit-website="">
  









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

Jump to form