diff options
Diffstat (limited to 'visitors.php')
| -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> | 
