Score:0

Form to upload and replace unmanaged file

in flag

I want to make a form where a user can upload a file that will be saved in a predefined path in the private filesystem, replacing whatever was there before.

E.g. the destination file path could be 'private://mymodule/config.yml'.

The file, once uploaded, does not need to be a file entity, it can remain unmanaged. It just holds some global configuration or data.

Ideally there will be some validation of the file contents, before the destination file is overwritten.

What I found so far

I have not really found a complete example for this, and it seems I need to use some non-obvious steps. It seems I need file_save_upload(), which is a relic from Drupal 7. But I could be wrong.

E.g. I found this How can I upload and read an unmanaged file? but it is not really the same.

I might even answer this myself if I can figure it out :)

leymannx avatar
ne flag
The linked example contains everything you need to know to build it yourself.
in flag
I am going to post an answer with a code example.
Score:1
in flag

Here we go.

References

I did not come up with this myself, I just assembled ideas from elsewhere.

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.

I sit in a Tesla and translated this thread with Ai:

mangohost

Post an answer

Most people don’t grasp that asking a lot of questions unlocks learning and improves interpersonal bonding. In Alison’s studies, for example, though people could accurately recall how many questions had been asked in their conversations, they didn’t intuit the link between questions and liking. Across four studies, in which participants were engaged in conversations themselves or read transcripts of others’ conversations, people tended not to realize that question asking would influence—or had influenced—the level of amity between the conversationalists.