<?php
/* Copyright (C) 2024 by Aleteoryx <alyx@aleteoryx.me>
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: <https://www.php.net/manual/en/datetime.format.php>
$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 `<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;
// Message length limit: int
//
// Caps the length of the message.
$config['message_length'] = 2048;
// Form prompt: string
//
// Text for a header above the form
$config['form_prompt'] = "Sign 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
//
// If true, assets like CSS, GIFs, etc, will be served from URLs like `/visitors.php/foo.css`.
// Otherwise, inline them into a single HTML document.
// Only enable this if your webserver supports PATH_INFO.
$config['use_path_info'] = false;
// Database path: string
//
// The path to the database file, in one of 3 formats.
//
// *.json - use a JSON array
// *.jsonl - use newline-delimited JSON objects (jsonlines)
// *.csv - use CSV
// *.db, *.sqlite, *.sqlite3 - use Sqlite3
//
// If the file extension is unknown, CSV is picked as default.
$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 --- */
/* CONFIG FIXING */
if (!isset($config['email_link'])) { // stolen from <https://forum.melonland.net/Themes/pimp-my-classic/images/email_sm.gif>
$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', <<<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, 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 "<h1>404: Asset Not Found.</h1>";
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;
// 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'] = 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'] !== '') {
$_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'] !== '') {
$_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'] = implode('<br />', 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 '<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): ?>
<article id=visit-<?= $row['id'] ?>
data-visit-id=<?= $row['id'] ?>
data-visit-name="<?= $row['name'] ?>"
data-visit-message="<?= $row['message'] ?>"
data-visit-email="<?= $row['email'] ?>"
data-visit-website="<?= $row['website'] ?>">
<div class='visit-info'>
<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 maxlength=128 /><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 maxlength=2048 /><br />
<?php endif;
if ($config['form_mode'] == 2): ?>
<label for=email>E-Mail (optional):</label> <input type=email placeholder='ahacker@example.com' name=email maxlength=2048 /><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 maxlength=2048 /><br />
<?php endif; ?>
<label for=message>Message:</label><br />
<textarea name=message placeholder='Write something...' rows="<?= $config['message_rows'] ?>" cols="<?= $config['message_cols'] ?>" required maxlength=<?= $config['message_length'] ?>></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="<?= htmlentities(@$config['blurb']) ?>" />
<meta name='og:name' content="<?= htmlentities($config['title']) ?>">
<meta name='og:description' content="<?= htmlentities(@$config['blurb']) ?>">
<meta name='og:site_name' content="<?= htmlentities(@$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 (isset($config['blurb'])): ?>
<p><?= $config['blurb'] ?></p>
<?php endif;
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>