Importing New Files

This page assumes some familiarity with Single Pages - you can learn more about those here.

When working with blocks, it's typical for developers to hook into the File Manager to select files that exist. The File Manager provides upload functionality, after all, so developers with access to the block interface will be able to upload files and then choose the uploaded file.

This is not always the case, however. For example, what if you're developing a custom application or a custom Dashboard page, and need to store files as part of this process? You should definitely be using the Concrete CMS File manager for this, as it provides version control, searching, metadata support and more. But what if your custom interface just using a simple <input type="file" /> element? You need the ability to take a custom file upload and import that uploaded file into the File Manager. Fortunately this is pretty easy.

Consider this form, in a single page template. This will post back to the submit() method in the single page controller.

<form method="post" enctype="multipart/form-data" action="<?=$view->action('submit')?>">
    <div class="form-group">
        <label class="control-label">Upload Photo</label>
        <input type="file" name="photo">
    </div>
    <div class="form-group">
        <button type="submit" name="upload">Upload</button>
    </div>
</form>

This is a standard form for uploading files in PHP. This file will be posted in the $_FILES array to the submit() method, per the PHP file uploading guidelines. It is also available in our Concrete\Core\Http\Request object, which is available in controller methods automatically (via the $request parameter). This object provides some nice convenience objects when working with uploaded files.

Here is our submit method

public function submit()
{
    $file = $this->request->files->get('photo');
    $filename = $file->getClientOriginalName();
}

The $file object here is an instance of the Symfony\Component\HttpFoundation\File\UploadedFile method, which offers convenience method for retrieving a file's mime type, handling and describing errors, and more.

Normally, we would use something like move_uploaded_file() or UploadedFile::move to move the file to a file storage directory, then have to save the filename somewhere. And we wouldn't know anything about the file, or really be able to work with it in an extensible way. But if we import the file into the file manager, we'll get a Concrete\Core\Entity\File\Version object back, which gives us lots of functionality.

public function submit()
{
    $file = $this->request->files->get('photo');
    $filename = $file->getClientOriginalName();
    $importer = $this->app->make('\Concrete\Core\File\Import\FileImporter');
    $result = $importer->importUploadedFile($file, $filename);
}

That's it! Assuming a successful file upload, $result will be an instance of the \Concrete\Core\File\Version object for the newly uploaded file.

Handling Errors

Unfortunately, we can't always assume a successful file upload. Sometimes users attempt to upload files with disallowed file extensions, or something goes wrong on the server like we run out of space, or the file is too large. In these instances we need to provide feedback to the user.

First, before we even attempt to import the file, let's add some error handling to the upload process. Add the ImportException object to the header of the file so we can use its convenience methods:

use Concrete\Core\File\Import\ImportException;

Then modify submit to check the validity of the UploadedFile object:

public function submit()
{
    $error = $this->app->make('error');
    $file = $this->request->files->get('photo');
    if (!($file instanceof UploadedFile)) {
        $error->add(t('File not received'));
    }
    if (!$file->isValid()) {
        $error->add(ImportException::describeErrorCode($file->getError()));
    }

    if (!$error->has()) {
        $file = $this->request->files->get('photo');
        $filename = $file->getClientOriginalName();
        $importer = $this->app->make('\Concrete\Core\File\Import\FileImporter');
        try {
            $result = $importer->importUploadedFile($file, $filename);
        } catch (ImportException $e) {
            $error->add($e);
        }
    }

    $this->set('error', $error);

}

In this example we create a new object of the Concrete\Core\Error\ErrorList\ErrorList object, and we do some checks against the UploadedFile object to see if it's valid. If any problems arise, we add errors to our ErrorList object. Next we nest our import method within the a try/catch block that will add any import exceptions that arise to our ErrorList object. Finally, we send the $error object into the page, where any errors it contains can be output to end users.

Handling Permissions and Uploading into Folders.

If we want to handle upload permissions as well as errors, we need to add a little bit more code to our example. In this example, we're only going to allow file uploads if the user has access to the file manager.

First, in order to handle permissions, we'll need to determine which folder in the file manager the file is being uploaded to. Different folders may have different permissions - although in most cases they simply inherit from the root folder's permissions. Here's how to check that the user uploading the file has access to add files to the root folder. First, add the relevant classes to the header of the class:

use Concrete\Core\File\Filesystem;
use Concrete\Core\Permission\Checker;

Then modify the submit method:

public function submit()
{
    $folder = $this->app->make(Filesystem::class)->getRootFolder();
    $permissions = new Checker($folder);
    $error = $this->app->make('error');
    if ($permissions->canAddFiles()) {
        $file = $this->request->files->get('photo');
        if (!($file instanceof UploadedFile)) {
            $error->add(t('File not received'));
        }
        if (!$file->isValid()) {
            $error->add(ImportException::describeErrorCode($file->getError()));
        }

        if (!$error->has()) {
            $file = $this->request->files->get('photo');
            $filename = $file->getClientOriginalName();
            $importer = $this->app->make('\Concrete\Core\File\Import\FileImporter');
            try {
                $result = $importer->importUploadedFile($file, $filename);
            } catch (ImportException $e) {
                $error->add($e);
            }
        }
    } else {
        $error->add(t('You do not have access to add files to this folder.'));
    }

    $this->set('error', $error);

}

First, we use the Filesystem object to retrieve the root folder of the file manager, then we check its permissions. If the user has the add_file permission on the folder, the code proceeds, otherwise the error is added to the ErrorList object.

Importing Files into Folders

What if you have a special folder you'd like to import into? You'll want to modify the permission routine to retrieve this folder by its ID, and you'll want to tell the import routine to import into that folder.

First, you'll need to import some new classes:

use Concrete\Core\File\Import\ImportOptions;

Then modify the submit method like so:

public function submit()
{
    $folderID = 108; // Whatever it may be
    $folder = $this-app->make(Filesystem::class)->getFolder($folderID);
    $permissions = new Checker($folder);
    $error = $this->app->make('error');
    if ($permissions->canAddFiles()) {
        $file = $this->request->files->get('photo');
        if (!($file instanceof UploadedFile)) {
            $error->add(t('File not received'));
        }
        if (!$file->isValid()) {
            $error->add(ImportException::describeErrorCode($file->getError()));
        }

        if (!$error->has()) {
            $file = $this->request->files->get('photo');
            $filename = $file->getClientOriginalName();
            $options = $this->app->make(ImportOptions::class);
            $options->setImportToFolder($folder);
            $importer = $this->app->make('\Concrete\Core\File\Import\FileImporter');
            try {
                $result = $importer->importUploadedFile($file, $filename, $options);
            } catch (ImportException $e) {
                $error->add($e);
            }
        }
    } else {
        $error->add(t('You do not have access to add files to this folder.'));
    }

    $this->set('error', $error);

}

And that's it! We've added the file to the specific folder, as well as checked it for the proper permissions.

Validating files

In the past, we had file inspectors which would let you parse PDFs on upload/replace, or run special functions based on any kind of file that was uploaded. In fact, they’re still in the core even though they’re not really being used – they need to removed or officially marked as deprecated. Unfortunately, those only let you run logic or set attributes or do additional processing. They could not validate and discard a file, for example.

Thankfully, we have since overhauled that, thanks to members of the community who wanted to have better validation of problematic files like SVGs. So now in recent versions of the core (it’s present in 8.5.5 and perhaps several point releases earlier) we have “file processors”. You configure them through your application/config/app.php file, by defining custom classes within the import_processors array. So to do something like scan every PDF which is uploaded to ensure it contains readable text, you’d do something like this:

return [
    'import_processors' => [
        'ccm.pdf.customvalidator' => My\Custom\Class\In\Some\Package\File\Import\Processor\IsPdfReadableValidator::class,
    ]
  ];

The key of the array doesn’t matter; it just needs something that doesn’t clash with what the core is doing. You can see what the core is doing by checking the contents of the import_processors array in the concrete/config/app.php directory.

Within the IsPdfReadableValidator class, you’d make it implement the Concrete\Core\File\Import\Processor\ValidatorInterface.

Using the Concrete\Core\File\Import\Processor\FileExtensionValidator in the core as a guide is probably good. Just make the custom validator only fire when PDF files are uploaded, and put your custom logic within the validate method.

All these methods fire automatically on new uploads as well as replace, so implementing this validator in this one spot should be enough to validate the files both when they are uploaded for the first time as a new file, or when creating a new version of an existing file.