Skip to content

Protecting files behind a firewall

Whenever you upload a file to your content folder and then access this file via its URL in the browser, a copy of the file is created in the media folder for public access. Additionally, the Panel creates thumbs for display in sections and fields or in the file view. This approach has several advantages, one of which is that you can move your content folder out of the web root for an additional layer of security.

However, if we want to restrict access to certain pages of a site as explained in our Restricting access to your site recipe, any files attached to these pages will still be accessible to anyone who can guess their URL.

In this recipe, we will create a simple downloads page, where registered users can download files. The example will be based on Kirby's Starterkit.

Prerequisites

  • A fresh Kirby Starterkit running on your local computer
  • A code editor
  • Apache web server (or other web server with a configuration that makes sure that requests to the content folder are sent to Kirby's router)

Preps

Downloads.yml blueprint

For the new downloads page, we create a new page blueprint. It can have any content your like, but for our purposes, all we want is a files section:

/site/blueprints/pages/downloads.yml
title: Downloads

sections:
  files:
    type: files
    template: protected

All files we upload to this section get a template protected assigned to it that we can later use to filter files or create conditions.

Now modify the pages section in the site.yml file so that it looks like this:

/site/blueprints/site.yml
pages:
  type: pages
  templates:
    - about
    - home
    - default
    - downloads # Add downloads template as allowed template

We add the downloads template to the allowed templates and remove the existing create: default option.

Now open the Panel and create a new page with the downloads template (or create it manually in the file system if you prefer). Then upload some files (images and other) to this newly created page via the files section.

Make sure to upload the files via the Panel so that the protected template is assigned to the files. If you just put some files into the downloads page manually, you would also have to manually add the metadata file with the template information.

downloads.php template

We also want a downloads.php template to list the uploaded files.

/site/templates/downloads.php
<?php snippet('header') ?>

<section>
  <h1 class="h1"><?= $page->title()->html() ?></h1>
  <ul class="file-list">
    <?php foreach( $page->files()->template('download') as $file ) : ?>
      <li><a href="<?= $file->url() ?>"><?= $file->filename() ?></a></li>
    <?php endforeach ?>
  </ul>
</section>

<?php snippet('footer') ?>

When you now visit this page in the frontend and inspect the file URLs in your browser's dev tools, you can see how these point to the media folder.

And if you click on the links while watching the media folder, you can see how it fills up with copies of those files.

Let's prevent that.

file::url component

The first step to protecting the files in the downloads folder is a custom File::url component.

In your /site/plugins/ folder (create this folder if it doesn't exist), create a new folder files-firewall and add the obligatory index.php file inside it.

In this index.php file, add the component like this:

/site/plugins/files-firewall/index.php
<?php
Kirby::plugin('cookbook/files-firewall', [
  'components' => [
    'file::url' => function ($kirby, $file) {
      if ($file->template() === 'protected') {
        return $kirby->url() . '/' . $file->parent()->id() . '/' . $file->filename();
      }
      return $file->mediaUrl();
    },
  ],
]);

Inside the component we check the file's template, and if it has the protected template assigned, we change the URL of the file to a location of our liking, otherwise we return the default mediaUrl().

If you delete the media folder now, and click on the file links again, you will see that this time no file copies will be generated anymore.

However, the files will still be accessible via their standard URL (in this case http://localhost/downloads/filename.ext). A route to the rescue.

Route

Let's add a route to the plugin that sends users trying to access downloads/filename.ext to the error page:

/site/plugins/files-firewall/index.php
<?php
Kirby::plugin( 'cookbook/files-firewall', [
  // …
  'routes' => [
    [
      'pattern' => 'downloads/(:any)',
      'action'  => function() {
        return site()->errorPage();
      },
    ],
  ],
]);

With this route in place—and if our web server is an Apache server using our default .htaccess—our file is now fully protected, because a rewrite rule in our .htaccess file prevents direct access to the /content folder.

In other environments, where calls to files in the /content folder are not routed through Kirby's router or where the original rules from the Starterkit's .htaccess file have not been copied 1:1, files might still be accessible via their direct file path, e.g. http://localhost/content/downloads/myfile.pdf. So make sure that your configure your server correctly.

If you now you click on a file link, the file will no longer be accessible to anyone, and we can start opening up our configuration to authorized users.

Allowing authorized users back in

We can now adapt our route so that logged-in users would be able to download the files, while all other visitors are sent to the error page:

/site/plugins/files-firewall/index.php
<?php

use Kirby\Cms\Response;

Kirby::plugin('cookbook/files-firewall', [
  'routes' => [
   [
      'pattern' => 'downloads/(:any)',
      'action'  => function ($filename) {
        if (kirby()->user() && 
          ($page = page('downloads')) && 
          $file = $page->files()->findBy('filename', $filename)) {
          return $file->download();
        }
        return site()->errorPage();
      },
    ],
  ],
  // …
]);

Of course, instead of allowing access to logged-in users, your conditions here can be different. You could, for example, check if users send a specific token or if they are authorized in some other way.

Taking care of thumbs created via the Panel

The above already works very well for non-image files. However, the Panel automatically creates previews and thumbs of image files which will end up in the media folder despite our efforts from above.

The Kirby component responsible for thumbs is the File::version component. Let's modify this component in our plugin:

/site/plugins/files-firewall/index.php
<?php

use Kirby\Cms\Response;

Kirby::plugin('cookbook/files-firewall', [
  // …
  'components'   => [
    // …
    'file::version' => function ( $kirby, $file, array $options = []) {

      static $original;

      // if the file is protected, return the original file
      if ($file->template() === 'protected') {
        return $file;
      }
      // if static $original is null, get the original component
      if ($original === null) {
          $original = $kirby->nativeComponent('file::version');
      }

      // and return it with the given options
      return $original($kirby, $file, $options);

    }
  ],
]);

Again we check the file template and return the original file if the file has the protected template, otherwise return the original component with its options.

Complete index.php

To finish this up, here is the complete code again:

/site/plugins/files-firewall/index.php
<?php

use Kirby\Cms\Response;

Kirby::plugin('cookbook/files-firewall', [
  'routes'       => [
    [
      'pattern' => 'downloads/(:any)',
      'action'  => function ($filename) {
        if (kirby()->user() && 
          ($page = page('downloads')) && 
          $file = $page->files()->findBy('filename', $filename)) {
          return $file->download();
        }
        return site()->errorPage();
      },
    ],
  ],
  'components'   => [
    'file::url' => function ($kirby, $file) {
      if ($file->template() === 'protected') {
        return $kirby->url() . '/' . $file->parent()->id() . '/' . $file->filename();
      }
      return $file->mediaUrl();
    },
    'file::version' => function ($kirby, $file, array $options = []) {

      static $original;

      // if the file is protected, return the original file
      if ($file->template() === 'protected') {
        return $file;
      }
      // if static $original is null, get the original component
      if ($original === null) {
          $original = $kirby->nativeComponent('file::version');
      }

      // and return it with the given options
      return $original($kirby, $file, $options);
    }
  ],
]);

Conclusion

With a few lines of code, we were able to set up a basic files firewall where authorized users can download files while access to these file is prohibited for all other users.

As always, there are many ways to refine this basic setup with more sophisticated conditions or make it configurable via the Panel, but you now know where to start from. Happy coding!

Author