Here we go.
References
I did not come up with this myself, I just assembled ideas from elsewhere.
- How can I upload and read an unmanaged file?
- In core there is
Drupal\system\Form\ThemeSettingsForm
with upload for logo and favicon. It uses an internal function _file_save_upload_from_form()
which might be removed in a minor version, so we can't use that.
- I found this video about private unmanaged files. I am sure this info is available elsewhere, but it was the first thing I found.
Code example
In this example I make it about an image file instead of a config file.
Create entry in mymodule.routing.yml
mymodule.acme_logo_form:
path: '/admin/config/acme-logo'
defaults:
_form: '\Drupal\mymodule\Form\AcmeLogoForm'
_title: 'Replace ACME logo'
requirements:
# This permission needs to be defined somewhere.
_permission: 'manage acme logo'
Create the form
<?php
namespace Drupal\mymodule\Form;
use Drupal\Core\Extension\ExtensionPathResolver;
use Drupal\Core\File\FileSystemInterface;
use Drupal\Core\File\FileUrlGeneratorInterface;
use Drupal\Core\Form\FormBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\file\Entity\File;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Form to upload a custom acme-logo.png file.
*/
class AcmeLogoForm extends FormBase {
const CUSTOM_FILE_URI = 'private://path/to/acme-logo.png';
/**
* @var \Drupal\Core\File\FileUrlGeneratorInterface
*/
private FileUrlGeneratorInterface $fileUrlGenerator;
/**
* @var \Drupal\Core\File\FileSystemInterface
*/
private FileSystemInterface $fileSystem;
/**
* @var \Drupal\Core\Extension\ExtensionPathResolver
*/
private ExtensionPathResolver $extensionPathResolver;
/**
* Constructor.
*
* @param \Drupal\Core\File\FileUrlGeneratorInterface $file_url_generator
* File url generator.
* @param \Drupal\Core\File\FileSystemInterface $file_system
* File system.
*/
public function __construct(
// Depending on PHP version we could use constructor property promotion.
FileUrlGeneratorInterface $file_url_generator,
FileSystemInterface $file_system,
ExtensionPathResolver $extension_path_resolver
) {
$this->fileUrlGenerator = $file_url_generator;
$this->fileSystem = $file_system;
$this->extensionPathResolver = $extension_path_resolver;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('file_url_generator'),
$container->get('file_system'),
$container->get('extension.path.resolver')
);
}
/**
* {@inheritdoc}
*/
public function getFormId() {
return 'mymodule_acme_logo_form';
}
/**
* {@inheritdoc}
*/
public function buildForm(array $form, FormStateInterface $form_state) {
$form['#_attributes'] = [
'enctype' => 'multipart/form-data',
];
// Show a link to the default file that is tracked in version control.
$form['default_file_link'] = [
'link' => [
'#type' => 'link',
'#url' => $this->fileUrlGenerator->generate($this->extensionPathResolver->getPath('module', 'mymodule') . '/acme-logo-default.png'),
'#title' => 'default logo.png',
'#attributes' => ['target' => '_blank'],
],
'#theme_wrappers' => ['container'],
];
// Show a download link for the custom file, if exists.
if (is_file(self::CUSTOM_FILE_URI)) {
$form['custom_file_link'] = [
'link' => [
'#type' => 'link',
'#url' => $this->fileUrlGenerator->generate(self::CUSTOM_FILE_URI),
'#title' => 'custom acme-logo.png',
'#attributes' => ['target' => '_blank'],
],
'#theme_wrappers' => ['container'],
];
}
$form['file'] = [
'#title' => $this->t('New custom acme-logo.png'),
'#type' => 'file',
];
$form['submit'] = [
'#type' => 'submit',
'#value' => $this->t('Upload'),
];
return $form;
}
/**
* {@inheritdoc}
*/
public function validateForm(array &$form, FormStateInterface $form_state): void {
parent::validateForm($form, $form_state);
// Create a temporary file entity based on the file form element.
// @todo file_save_upload() might soon be deprecated.
// Unfortunately https://www.drupal.org/node/2244513 was closed without providing an alternative to file_save_upload().
// @todo Catch errors from this operation.
// @todo Find a way to avoid creating temporary file entities.
$tmp_file_entity = file_save_upload(
'file',
[
'file_validate_extensions' => ['png'],
],
FALSE,
0,
);
if (!$tmp_file_entity instanceof File) {
$form_state->setErrorByName('file', t('Upload failed.'));
return;
}
// @todo Validate file contents.
// Keep the temporary file entity available for submit handler.
$form_state->setValue('tmp_file', $tmp_file_entity);
}
/**
* {@inheritdoc}
*/
public function submitForm(array &$form, FormStateInterface $form_state) {
$values = $form_state->getValues();
/** @var \Drupal\file\Entity\File $tmp_file_entity */
$tmp_file_entity = $values['tmp_file'];
// Create parent directory, if it does not exist.
// @todo Handle errors from this operation.
$directory = dirname(self::CUSTOM_FILE_URI, 2);
$this->fileSystem->prepareDirectory(
$directory,
FileSystemInterface::CREATE_DIRECTORY | FileSystemInterface::MODIFY_PERMISSIONS);
// Copy the file as an unmanaged file in the private filesystem.
// @todo Handle errors from this operation.
$this->fileSystem->copy(
$tmp_file_entity->getFileUri(),
self::CUSTOM_FILE_URI,
FileSystemInterface::EXISTS_REPLACE);
}
}
Allow download access
Because this is in private filesystem, we need to grant download access explicitly.
/**
* Implements hook_file_download().
*/
function mymodule_file_download(string $uri) {
if ($uri !== AcmeLogoForm::CUSTOM_FILE_URI) {
// This is not the file we care about.
return NULL;
}
if (!\Drupal::currentUser()->hasPermission('view acme logo')) {
// No access.
return -1;
}
// Set some headers to tell Drupal that access is granted.
return [
'Content-Type' => 'image/png',
];
}
Notes
The file_save_upload()
always creates temporary file entities that "pollute" the database.
The same effect occurs when uploading logos or favicons in the core theme settings.
The file_managed
database tables will fill up with entries for temporary files.
This seems to be just how it works, until a replacement for or alternative to file_save_upload()
is found.
Perhaps Drupal\file\Upload\FileUploadHandler
could be used directly, but this also creates file entities afaik.