$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'] = 1; // 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, 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; 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; 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