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.