Plugin to Backup Content Folder as .zip

Hi all.

I am trying to write a plugin that backups the entire content folder as a .zip-file upon the click of a button in the panel. I am fairly new to some of these concepts and it’s more of a learning exercise for me, so please bear with me.

In my plugin’s index.php I have registered a route to create-backup. I am triggering that route through XHR from the Vue component. Everything works and the backups folder and backup.zip are successfully created.
I am, however, getting a 404 – Not Found error in the console — which I guess makes sense, given that there is nothing actually there. It doesn’t break the script but I am wondering if there is a more elegant solution to this?

In a second step, I would like to register the created backups to a backups array which can then be used to display them on the page.
This works find after I click the Create Backup button but I am a little on over my head when it comes to making that data persistent. Is there a way to send the backups array to the pages .txt and auto-save it after the click of the backup button?

Here is my (slightly abbreviated) code so far:

index.php

Kirby::plugin("jonasfeige/backup", [
  "routes" => [
    [
      "pattern" => "create-backup",
      "action"  => function () {
        $kirby = Kirby::instance();
        $backup_folder = $kirby->root("index") . "/backups";
        $content_folder = $kirby->root("index") . "/content";
        $content = Dir::read($content_folder);

        $date = $_POST["date"];
        $zip_name = "backup_" . $date . ".zip";

        Dir::make($backup_folder);
        
        // ...
        // Create Zip
      },
      "method" => "POST"
    ]
],
  "fields" => [
    "backup" => [
      "props" => [
        "backups" => function ($backups) {
            return $backups;
        }
      ]
    ]
  ]
]);

Vue component

<template>
  <k-field :label="label">
    <k-button icon="check" @click="createBackup">Create Backup</k-button>
    <k-box class="backup__entry" v-for="backup in backups" :key="backup.id">{{ backup }}</k-box>
  </k-field>
</template>

<script>
import moment from 'moment'

  export default {
    data() {
      return {
        pathArr: location.pathname.split('/')
      }
    },
    props: {
      label: String,
      backups: {
        type: Array,
        default: () => []
      }
    },
    methods: {
      createBackup() {
        const self = this
        const zipRoute = document.location.origin + '/' + this.pathName + '/create-backup'
        const date = moment().format('YY-MM-DD_HH-mm-ss')
        const params = `date=${date}`

        var xhttp = new XMLHttpRequest();
        xhttp.onreadystatechange = function() {
          if (this.readyState == 4 && this.status == 200 || this.readyState == 4 && this.status == 404) {
            self.backups.push('backup_' + date)
            self.$emit('input', event)
          }
        }
        xhttp.open('POST', zipRoute, true);
        xhttp.setRequestHeader('Content-type', 'application/x-www-form-urlencoded');
        xhttp.send(params);
      }
    },
    computed: {
        pathName(){
          return this.pathArr.length > 1 ? `/${this.pathArr[1]}/${this.pathArr[2]}` : '/'
        }
    }
  }

</script>

to avoid the route mismatch you could:

  • generate the date in php so you do not need to forward that as a param from vue
  • move the route to the api routes to protect them from being called without auth
  • once you have done that you can use the panel api.get() to trigger the route like here

what is not clear from you screenshots is which folder is your root (KirbyToolbox/dist ?) and that might require having RewriteBase / in your .htaccess file.

you probably need a little bit more gui and can not use my janitor plugin directly but it also could help you how to implement getting the page object in the route.

You should have posted the full plugin code. What happens at the end of the route callback? If there’s nothing returned, perhaps that’s the reason you get a 404. It’s a request, after all, so the browser (and Kirby) expect a response. You create a zip file as a side effect, but you provide nothing back. If I recall correctly, simply adding return 200; will tell Kirby to respond with a HTTP status code 200 (OK). It really does nothing different than what you currently have, you just remove the error in the console. It’s still good practice to signal that the request was successful, so it’s nice to have. Take a look at other status codes too. If the date parameter is invalid, for example, you can send a status code 400 (Bad Request) to signal it.

My suggestion would be to not use the date as the backup name. What if the user wants to call it Bob? In my opinion, you should have a text field for the backup name. If that field is empty, then use the current date as filename.

Also, you should generate the date at the back-end. Using a dependency (moment) on the front-end just for that is brainless.

I believe you don’t need:

$kirby = Kirby::instance();

You can simply use kirby() to get the Kirby instance:

$kirby = kirby();

Edit: To list the backups at the front-end, simply add another route that responds with the available backups as JSON. On your front-end, make a request to that endpoint and you’re ready.

Bonus points if your routes are RESTful. I.E. to create a backup, you have a POST request for /backup. To fetch all existing backups, you have a GET request /backup. This is better than having /create-backup and /get-backups, for example. So you’d have:

"routes" => [
  [
    "pattern" => "backup",
    "method" => "POST",
    "action"  => function () {}
  ],
  [
    "pattern" => "backup",
    "method" => "GET",
    "action"  => function () {}
  ]
]
2 Likes

Thank you both, this is definitely pointing me in the right direction.
I set the date in the Vue component because I thought that could help with mapping the backups if I later wanted to include a feature to delete a backup. But I guess that can be solved via routes just as well. Using a user input is also a good idea.

Lots of things to learn here, appreciate the advice!

Here is the complete PHP code, I can already see where your advice would come in handy.

<?php


Kirby::plugin('jonasfeige/backup', [
  'routes' => [
    [
      'pattern' => 'create-backup',
      'action'  => function () {
        $kirby = Kirby::instance();
        $backup_folder = $kirby->root('index') . '/backups';
        $content_folder = $kirby->root('index') . '/content';
        $content = Dir::read($content_folder);

        $date = $_POST['date'];
        $zip_name = "backup_" . $date . ".zip";

        Dir::make($backup_folder);
    
        function zipData($source, $destination) {
            if (extension_loaded('zip') === true) {
                if (file_exists($source) === true) {
                    $zip = new ZipArchive();
                    if ($zip->open($destination, ZIPARCHIVE::CREATE) === true) {
                        $source = realpath($source);
                        if (is_dir($source) === true) {
                            $files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($source), RecursiveIteratorIterator::SELF_FIRST);
                            foreach ($files as $file) {
                                $file = realpath($file);
                                if (is_dir($file) === true) {
                                    $zip->addEmptyDir(str_replace($source . '/', '', $file . '/'));
                                } else if (is_file($file) === true) {
                                    $zip->addFromString(str_replace($source . '/', '', $file), file_get_contents($file));
                                }
                            }
                        } else if (is_file($source) === true) {
                            $zip->addFromString(basename($source), file_get_contents($source));
                        }
                    }
                    return $zip->close();
                }
            }
            return false;
        }

        // Start the backup!
        zipData($content_folder, $backup_folder . '/' . $zip_name);
        
      },
      'method' => 'POST'
    ]
  ],
  'fields' => [
    'backup' => [
      'props' => [
        'backups' => function ($backups) {
            return $backups;
        }
      ]
    ]
  ]
]);
1 Like

You might want to use a library for creating the ZIP instead of using your own script. This one seems pretty solid and actively maintained.

If you don’t know what’s Composer, go learn it. Since Kirby supports Composer, you’d be able to add it to your plugin and publish it on packagist, which is Composer’s package repository. From there, people would be able to download the plugin with Composer, and track it as a dependency to their project. Also, you’d be able to specify your own dependencies for your plugin. If you decide to use the library I mentioned, you could list it as a dependency to your project and whenever someone downloads your plugin with Composer, they’ll download the library as well.

By the way, why do you need that backup field? You need that when creating custom fields for usage in blueprints (like custom textarea, text, etc.) I believe you don’t need that in this case.

Thanks again, good idea.
I’ve used composer before but this would definitely be good practice.

This worked out like a charm! Much cleaner and simpler.
However, I am a little confused as to where $api.get() in the Vue component actually comes from. If my Vue knowledge serves me right, it must be a Kirby Instance Property, right? I could not find any documentation on it but would love to have a closer look at its get()and post()methods.

1 Like