Source Code for /public/inc/functions.php

<?php
// Load the syntax-highlighting library (it's a Composer package; don't think about it too much!):
require(__DIR__ . '/../../vendor/autoload.php');

// Define a constant for the site title.
define( 'SITE_TITLE', "Dan Q's PHP Cookery Class" );

// Load the code used for the Potion Finder game referenced in Lesson 6:
require('potion-finder.php');

/**
 * Renders the page header. Accepts the following parameters:
 * - $page_title: Optional, default null. The title of the page; used in the browser tab etc.
 * - $hide_navbar: Optional, default false. Whether to suppress the navigation bar.
 */
function add_header( $page_title = null, $hide_navbar = false ) {
  // We start by setting the full page title to the site title.
  $full_page_title = SITE_TITLE;
  // If a page title is provided, we prepend it to the full page title.
  if( ! empty( $page_title ) ) {
    $full_page_title = $page_title . ' - ' . $full_page_title;
  }
  // Now we load the header template:
  require('header.php');
}

/**
 * Renders the page footer.
 */
function add_footer() {
  // Load the footer template:
  require('footer.php');
}

/**
 * Renders the navigation bar.
 * It's generated from a list of links, and the one which is the current page is marked as "active"
 * (so that it can be styled differently).
 * Accepts the following parameters:
 * - $full_toc: Optional, default false. Set to true to render all sections in each link, even if that link's not open.
 */
function add_navbar($full_toc = false) {
  // Set up an array of links:
  $links = [
    [
      'title' => 'Home',
      'href'  => '/',
    ],
    [
      'title' => 'Introduction',
      'href'  => '/01-intro/',
    ],
    [
      'title' => 'Hello World',
      'href'  => '/02-hello-world/',
      'sections' => [
        [
          'id'    => 'strings-both-ways',
          'title' => 'Strings, forwards and backwards'
        ],
        [
          'id'    => 'today',
          'title' => 'What day is it?'
        ],
        [
          'id'    => 'conditional',
          'title' => 'Closed on Mondays!'
        ],
      ],
    ],
    [
      'title' => 'Lists and Loops',
      'href'  => '/03-lists-and-loops/',
      'sections' => [
        [
          'id'    => 'looping',
          'title' => 'The 7-times table'
        ],
        [
          'id'    => 'looping-through-a-list',
          'title' => 'A basic gallery'
        ],
        [
          'id'    => 'navigation-menu',
          'title' => 'Navigation menu'
        ],
        [
          'id'    => 'looping-through-files',
          'title' => 'Looping through files (automatic gallery)'
        ],
        [
          'id'    => 'selecting-from-an-array',
          'title' => 'Showing a random quote'
        ],
      ]
    ],
    [
      'title' => 'Includes and Functions',
      'href'  => '/04-includes-and-functions/',
      'sections' => [
        [
          'id'    => 'navigation-menu-include',
          'title' => 'Sharing your navbar across the site'
        ],
        [
          'id'    => 'functions',
          'title' => 'Reusing common patterns with functions'
        ],
        [
          'id'    => 'shared-function-file',
          'title' => 'Sharing a function across multiple pages'
        ],
      ],
    ],
    [
      'title' => 'Accepting Input',
      'href'  => '/05-accepting-input/',
      'sections' => [
        [
          'id'    => 'get-and-post',
          'title' => 'GET and POST requests'
        ],
        [
          'id'    => 'whats-your-name',
          'title' => "Asking the user's name"
        ],
        [
          'id'    => 'fruits-and-filters',
          'title' => 'Fruits and filters revisited'
        ],
        [
          'id'    => 'quiz',
          'title' => "Let's make a quiz!"
        ],
      ],
    ],
    [
      'title' => 'Sessions &amp; Cookies',
      'href'  => '/06-retaining-state/',
      'sections' => [
        [
          'id'    => 'what-are-cookies',
          'title' => 'What are cookies?'
        ],
        [
          'id'    => 'detecting-repeat-visitors',
          'title' => 'Detecting repeat visitors'
        ],
        [
          'id'    => 'sessions',
          'title' => 'Logging in (and staying logged in)'
        ],
        [
          'id'    => 'potion-finder',
          'title' => 'Potion Finder (game)'
        ],
      ],
    ],
    [
      'title' => 'Storing Data',
      'href'  => '/07-storing-data/',
      'sections' => [
        [
          'id'    => 'counter',
          'title' => 'Counting Clicks'
        ],
      ],
    ],
    [
      'title' => 'Beyond HTML',
      'href'  => '/08-beyond-html/',
      // Generating RSS, constructing a custom ZIP file, composing images, image hit counter?
    ],
    [
      'title' => 'Reaching Out',
      'href'  => '/09-reaching-out/',
      // Sending mail, downloading RSS, getting data from APIs?
    ],
    [
      'title' => 'Troubleshooting',
      'href'  => '/troubleshooting/',
    ],
    [
      'title' => 'Full table of contents',
      'href'  => '/toc/',
      'class' => [ 'no-number' ],
    ],
    [
      'title' => 'Appendix A: Further Examples',
      'href'  => '/appendix-samples/',
      'class' => [ 'no-number', 'appendix' ],
    ],
    [
      'title' => 'Appendix B: Security',
      'href'  => '/appendix-security/',
      'class' => [ 'no-number', 'appendix' ],
    ],
    [
      'title' => "Appendix C: This Site's Code",
      'href'  => '/appendix-quine/',
      'class' => [ 'no-number', 'appendix' ],
    ],
  ];
  ?>
  <nav>
    <ol start="0">
      <?php
      // Loop through each link:
      foreach( $links as $link ) {
        // Decide if this link is the current page:
        $is_active = $_SERVER['REQUEST_URI'] === $link['href'];
        // Come up with a class list for the link:
        $class_list = $link['class'] ?? [];
        if( $is_active ) {
          $class_list[] = 'active';
        }
        // Join the class list together into a string:
        $class_list = implode( ' ', $class_list );
        // Render the link:
        ?>
        <li class="<?php echo $class_list; ?>">
          <a href="<?php echo $link['href']; ?>"><?php echo $link['title']; ?></a>
          <?php if( $full_toc || ( $is_active && ! empty( $link['sections'] ) ) ) { ?>
            <ul>
              <?php foreach( $link['sections'] as $section ) { ?>
                <li><a href="<?php echo $link['href'] . '#' . $section['id']; ?>"><?php echo $section['title']; ?></a></li>
              <?php } ?>
            </ul>
          <?php } ?>
        </li>
        <?php
      }
      ?>
      <?php if( ! str_starts_with($_SERVER['REQUEST_URI'], '/search/') ) { ?>
        <li id="nav-search">
          <form action="/search/#search-results" method="get">
            <input type="search" aria-label="Search the site" autocomplete="off" name="q" placeholder="Search..." value="<?php echo htmlspecialchars($_GET['q'] ?? ''); ?>">
            <button type="submit">Search</button>
          </form>
        </li>
      <?php } ?>
    </ol>
  </nav>
  <?php
}

/* Adds a "back" link in a paragraph. The link only appears if JavaScript is enabled. */
function add_backlink() {
  ?>
  <p class="back"></p>
  <script>
    /* Add a JavaScript-powered "back" link, using JavaScript so it doesn't appear if JavaScript is disabled. */
    const backLink = document.createElement('a');
    backLink.href = '#';
    backLink.addEventListener('click', (e) => {
      e.preventDefault();
      window.history.back();
    });
    backLink.innerText = '🔙 Back to the site';
    document.querySelector('.back').appendChild(backLink);
  </script>
  <?php
}

/**
 * Begins a syntax-highlighted code block.
 * Accepts the following parameters:
 * - $language: Optional, default null. The language of the code. If not provided, no language will be assumed.
 */
function begin_code_block( $language = null ){
  global $current_code_block_language;
  $current_code_block_language = $language;
  ob_start();
}

/**
 * Ends a syntax-highlighted code block and renders the output
 */
function end_code_block(){
  global $current_code_block_language;
  $hl = new \Highlight\Highlighter();
  if( ! empty( $current_code_block_language ) ) {
    $hl->setAutodetectLanguages(array($current_code_block_language));
  } else {
    $hl->setAutodetectLanguages(array('php', 'html', 'css', 'javascript'));
  }
  $code = ob_get_clean();
  $highlighted = $hl->highlightAuto( htmlspecialchars_decode( $code ) );
  echo '<figure class="code-block-container"><pre class="code-block"><code class="hljs ' . $highlighted->language . '">' . $highlighted->value . '</code></pre></figure>';
}

/**
 * Renders a code block from a file.
 * Accepts the following parameters:
 * - $file: The relative path to the file to render.
 * - $language: Optional, default null. The language of the code. If not provided, no language will be assumed.
 * - $link: Optional, default true. Should there be a link to the URL of the file (to demo it).
 * - $download: Optional, default true. Whether to provide a link to download the file.
 * - $demo_in_full_page: Optional, default false. Set to true to wrap the code in a full HTML page when trying/demoing it.
 *                       Set to a string to specify a CSS file to <link>
 */
function code_block_from_file( $file, $language = null, $link = true, $download = true, $demo_in_full_page = false ) {
  $code = file_get_contents( __DIR__ . '/../' . $file );
  $hl = new \Highlight\Highlighter();
  if( ! empty( $language ) ) {
    $highlighted = $hl->highlight($language, $code);
  } else {
    $hl->setAutodetectLanguages(array('php', 'html', 'css', 'javascript'));
    $highlighted = $hl->highlightAuto( htmlspecialchars_decode( $code ) );
  }
  $highlighted = $hl->highlightAuto( htmlspecialchars_decode( $code ) );
  $try_link = $demo_in_full_page ?
    '/demo-harness.php?file=public/' . $file . '&demo_in_full_page=' . $demo_in_full_page : // 👈 demo link URL if Demo In Full Page is set
    '/' . $file;                                                                            // 👈 raw demo link URL
  echo '<figure class="code-block-container"><pre class="code-block"><code class="hljs ' . $highlighted->language . '">' . $highlighted->value . '</code></pre>';
  if( $link || $download ) {
    echo '<figcaption class="code-block-actions"><ul>';
    if( $link ) {
      echo '<li><a href="' . $try_link . '">👁️ Try it!</a></li>';
    }
    if( $download ) {
      echo '<li><a href="/source-viewer.php?file=public/' . $file . '&download=true" download>💾 Download</a></li>';
    }
    echo '</ul></figcaption>';
  }
  echo '</figure>';
}

/**
 * Given a relative or absolute file path, returns a sanitized absolute version of the path.
 * Forbids access to files outside of this application's directory structure.
 */
function sanitize_file_path( $file_path ) {
  // The allowed directory (from which files can be requested) is the parent directory of this file.
  $allowed_directory = realpath( __DIR__ . '/../../' );
  
  // If they didn't specify a file, give them a 404 error:
  if( empty( $file_path ) ) {
    header('HTTP/1.1 404 Missing file parameter');
    die('Missing file parameter');
  }
  
  // Move to the allowed directory so that we treat paths as "relative" to that:
  chdir( $allowed_directory );
  
  // Determine the real path of the file they're requesting:
  $real_file_path = realpath( $file_path );

  // If the file doesn't exist, give them a 404 error:
  if( $real_file_path === false ) {
    header('HTTP/1.1 404 Requested file not found');
    die('Requested file not found');
  }

  // Ensure that the file they're requesting is within the allowed directory so they can't read ANY file on this server!
  // If the file they want is not in the allowed directory, give them a 403 error:
  if( strpos( $real_file_path, $allowed_directory ) !== 0 ) {
    header('HTTP/1.1 403 Requested file forbidden');
    die('Requested file forbidden');
  }

  return $real_file_path;
}

/**
 * Some pages (the source viewer and demo harness) will show the source code of or render a specified file.
 * This convenience function works out what file they've requested (from the $_GET['file'] parameter),
 * checks that it's a legitimate request (a real file, found within this application's directory structure),
 * kills the request if it's not, and returns the real path to the file if it is.
 */
function get_requested_file() {
// Find out what file the user wants to view the source code for:
  return sanitize_file_path( $_GET['file'] );
}

/**
 * Given a PHP file path, returns the path to the associated CSS file if it exists.
 */
function get_associated_css_file( $file ) {
  $file = sanitize_file_path( $file );
  $css_file_path = str_replace('.php', '.css', $file );
  if( file_exists( $css_file_path ) ) {
    $public_dir = realpath( __DIR__ . '/../' );
    return str_replace( $public_dir, '', $css_file_path );
  }
  return null;
}