Skip to content

Kirby 3.9.8

Uploading files from frontend

In this recipe we will create a basic file upload form where users can upload files from the frontend. What we need:

  • a page called upload where we put the form
  • a page called storage where the files will be uploaded to
  • a template with the form
  • a file blueprint with rules
  • a controller with the form logic

Allowing users to upload files from the frontend without authentication is not without risks. You should be very careful what file types you allow, where you store them and how you name them. Ideally, you prevent access to those files before you have checked they are safe.

In this example, we will upload the files to the content folder, but if your use case or hosting allows it, consider uploading files to a location outside the web root.

The upload and storage pages

Create an upload page with an upload.txt content file. For our means, we only need a title in the content file, the rest is up to you. You could, for example, store an introductory text.

If you want to access the page in the Panel, you also need to create a blueprint for the page. We will skip this step here.

Additionally, create a storage page. This is the page to which we upload the files.

The upload template

Our upload.php template contains the form and will display error messages if something goes wrong or a success message if the form was successfully submitted.

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

<?php if ($success): ?>
  <div class="alert success">
    <p><?= $success ?></p>
  </div>
<?php else: ?>
  <?php if (empty($alerts) === false): ?>
    <ul>
      <?php foreach ($alerts as $alert): ?>
        <li><?= $alert ?></li>
      <?php endforeach ?>
    </ul>
  <?php endif ?>

  <form action="" method="post" enctype="multipart/form-data">

    <div class="honeypot">
      <label for="website">Website <abbr title="required">*</abbr></label>
      <input type="website" id="website" name="website">
    </div>

    <div class="form-field">
      <label for="file">Select files</label>
      <input name="file[]" type="file" multiple>
      <div class="help">You can upload up to 3 files. Each file may not be larger than 3 MB.</div>
    </div>

    <input type="submit" name="submit" value="Submit" class="button">

  </form>
<?php endif ?>

<?php snippet('footer') ?>

The form is displayed by default and hidden once the upload was successful. We also include a honeypot field to ensure a minimum level of spam bot protection.

The honeypot field needs to be positioned off-screen, so we need some styles for it. Add this to your stylesheet (you can also change the class and styling, of course).

.honeypot {
  position: absolute;
  left: -9999px;
}

The file blueprint

To easily validate if the uploaded files conform to what we want to allow, we set up a files blueprint with the accept option:

/site/blueprints/files/upload.yml
title: Frontend file uploads

accept:
  mime: image/*, application/pdf
  maxsize: 3000000 # size in byte = 3 MB

The upload controller

All the form and upload logic is handled by our controller:

/site/controllers/upload.php
<?php

return function ($kirby, $page) {

  $alerts  = [];
  $success = '';

  if ($kirby->request()->is('post') === true && get('submit')) {

    // check the honeypot
    if (empty(get('website')) === false) {
      go($page->url());
      exit();
    }

    $uploads = $kirby->request()->files()->get('file');

    // we only want 3 files
    if (count($uploads) > 3) {
      $alerts['exceedMax'] = 'You may only upload 3 files.';
      return compact('alerts', 'success');
    }

    // authenticate as almighty
    $kirby->impersonate('kirby');

    foreach ($uploads as $upload) {

      // check for duplicate
      $files      = page('storage')->files();
      $duplicates = $files->filter(function ($file) use ($upload) {
        // get original safename without prefix
        $pos              = strpos($file->filename(), '_');
        $originalSafename = substr($file->filename(), $pos + 1);

        return $originalSafename === F::safeName($upload['name']) &&
                $file->mime() === $upload['type'] &&
                $file->size() === $upload['size'];
      });

      if ($duplicates->count() > 0) {
        $alerts[$upload['name']] = "The file already exists";
        continue;
      }

      try {
        $name = crc32($upload['name'].microtime()). '_' . $upload['name'];
        $file = page('storage')->createFile([
          'source'   => $upload['tmp_name'],
          'filename' => $name,
          'template' => 'upload',
          'content' => [
              'date' => date('Y-m-d h:m')
          ]
        ]);
        $success = 'Your file upload was successful';
      } catch (Exception $e) {
        $alerts[$upload['name']] = $e->getMessage();
      }
    }
  }

  return compact('alerts', 'success');
};

Let's break this down step by step:

First we set up our variables $alerts and $success which we will display in our template by returning them from our controller:

$alerts  = [];
$success = '';
// ...
return compact('alerts', 'success');

Checking for the right request

To make sure we are responding to the right request, we listen for a POST request and whether the request came from our form. Then, we check if a bot got trapped in our honeypot. In this case, we send him back to the page and stop script execution.

if ($kirby->request()->is('post') === true && get('submit')) {

  if (empty(get('website')) === false) {
    go($page->url());
    exit();
  }

Checking for limits

In our example, we want to limit uploads to a maximum of three files per upload. If this limit is exceeded we add an error message and return all messages to end the controller:

if (count($uploads) > 3) {
  $alerts['exceedMax'] = 'You may only upload 3 files.';
  return compact('alerts', 'success');
}

Checking for duplicates

Then we loop through $uploads to check each uploaded file individually. We want to prevent that the same file gets uploaded again and again. To do so, we compare the safe name of the uploaded file to the filename of existing files in the folder minus the prefix (explained below), the mime type and the size.

$files      = page('storage')->files();
$duplicates = $files->filter(function ($file) use ($upload) {
  $pos              = strpos($file->filename(), '_');
  $originalSafename = substr($file->filename(), $pos + 1);
  return $originalSafename === F::safeName($upload['name']) &&
          $file->mime() === $upload['type'] &&
          $file->size() === $upload['size'];
});

if ($duplicates->count() > 0) {
  $alerts[$upload['name']] = "The file already exists";
  continue;
}

If a duplicate is found, we add an error message for the current file and continue in the loop with the next uploaded file.

Creating the file

If all went well so far, we try to upload and create the file in a try - catch block using Kirby's $page->createFile() method.

try {
  $name = crc32($upload['name'].microtime()). '_' . $upload['name'];
  $file = page('storage')->createFile([
    'source'   => $upload['tmp_name'],
    'filename' => $name,
    'template' => 'upload',
    'content' => [
        'date' => date('Y-m-d h:m')
    ]
  ]);
  $success = 'Your file upload was successful';

The $page->createFile() method requires the source attribute, all other parameters are optional. However, we use the template option here to assign the file blueprint we created earlier. It does all the checking for the allowed mime types and sizes for us.

To obfuscate the file name, we add a prefix to the original filename. Kirby automatically takes care of converting the filename to a safe name. Prefixing the filename makes it hard for the user to guess the filename and opening the file in the browser after upload. This makes it much harder for users to upload a malicious file and call it directly afterwards since the exact name will not be known to them.

We also store the upload date and time as meta data in the content array. If you want, you can also store additional information.

If everything was successful, we set the success message.

As an additonal security measure, we can prohibit access to the storage page using a route.

Catching additional errors

If the $page->createFile() method encounters any errors or rule violations, it will throw an error. By wrapping our code in a try - catch block, we make sure to get the error message and add it to our alerts array:

} catch (Exception $e) {
  $alerts[$upload['name']] = $e->getMessage();
}

Next steps

This recipes presented a basic way to implement file uploads from the frontend. Of course, there are more steps that could follow this:

  • Add additional fields to the form
  • Combine it with a user registration form
  • Add access restrictions to the storage page