<?php
$config = array();
define('VERSION', '0.0.1');
define('GIT_REPO', 'https://git.aleteoryx.me/cgit/visitors_dot_php/');
/* CONFIG OPTIONS - COSMETIC */
// Custom CSS: ?string
//
// Will be injected after the builtin CSS. Respects `use_path_info` when being served.
//$config['custom_css'] = 'body { background: red; }';
// Use builtin CSS: bool
//
// Toggles the builtin CSS
$config['builtin_css'] = 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'] = 0;
// 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'] = 2;
// 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:...';
/* 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';
/* --- 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']);
}
}
/* DATABASES */
// db_row: ['id' => int (only for list_rows), 'name' => string, 'message' => string, 'email' => ?string, 'website' => ?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) {
$timestamp = date(DATE_ISO8601_EXPANDED);
$this->_append_row(compact('name', 'message', 'email', 'website', 'timestamp'));
}
public function list_rows(): array {
$data = $this->_list_rows();
foreach($data as &$row) {
$row['timestamp'] = DateTime::createFromFormat(DATE_ISO8601_EXPANDED, $row['timestamp']);
}
return $data;
}
public function delete_row(int $id): bool {
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', 'email', 'website', '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, email TEXT, website 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)');
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']);
//$db->append_row('foo', 'bar');
/* ACTUAL UI */
$ed_1_or_3 = $config['email_display'] == 1 || $config['email_display'] == 3;
?>
<!doctype html>
<html>
<head>
<?php ?>
</head>
<body>
<h1>Guestbook</h1>
<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']??'./email_icon').'" /></a>',
2, 3 => '<a href="mailto:'.$row['email'].'">[e-mail]</a>',
4 => 'mail: <u>'.str_replace(['@', '.'], [' at ', ' dot '], $row['email']).'</u>'
} : ''; ?>
<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 ?></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>
</div>
<q><?= $row['message'] ?></q>
</article>
<?php endforeach; ?>
</main>
</body>
</html>