aboutsummaryrefslogblamecommitdiffstats
path: root/visitors.php
blob: 6242852f3589b5614144ce79e5523436b1efdb2d (plain) (tree)
1
2
3
4
5
6
7
8
9








                                                                      

                
                          



                               





















                                                                   
                      
  


                                                   
 












                                                                   



                        
                      
 
                              

 

















                             








                                                          
                         









                                                                                                                     
                             

 
                            
  

                                                                               
                                                                                      
 

                                                           

 











                                                                              
                                              








                                     



                                  
                                                                                              
                                                      
                                                         
 
                                 






                                                      
                                                           

                                          

                                                              



                               










                                                                                            


                                                                            










                                                                                                                                                                                                                                                                                         













                                                                       



                            





                         
 


                                           
                             









































                                      

















                                                              




                                            










                                          

               
                                                                                                                                                                                          



                                                           



                                                                                                             
                                             
                                                                                    


                                      


                                 



                                                                                               

                              



                                             
                             


                                   

                                                     




                                                         
                                                                                     


                                              


                                             
                                       





                            
                                                 
                                                                                                                                    





                                          



                                                 






                                            
   

































                                                                                
                                                                                             











                                                                                            
 






                                                                          
                                                                                                                                                                          


                                                 
                                                                                                                                                             

























                                                                                 












                                                           

                                           




                                   





                                                                       











































                                                                                                                                                                               




                                                                            

























                                                                                                                                                          
        
                                               





                                                         
                              

                                                                                                                                       




                                   






























































                                                                                                                                                                                                                                                                                                                                                



                                              













                                                                                                    

       
<?php

$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'] = 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 `<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'] = "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;
  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 '<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:&nbsp<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>&nbsp;&nbsp;<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 (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>