Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 30 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ Submit bug reports and feature suggestions, or track changes in the
## Table of contents

- Requirements
- Recommended modules
- Configuration
- Features
- FAQ
Expand All @@ -30,24 +31,33 @@ Submit bug reports and feature suggestions, or track changes in the

## Requirements

This module requires the following modules:
This module requires the Drupal core File module.

- [Pathauto](https://www.drupal.org/project/pathauto)
- [Redirect](https://www.drupal.org/project/redirect)
- [Token](https://www.drupal.org/project/token)

## Recommended modules

These modules are optional, and are used when present:

- [Pathauto](https://www.drupal.org/project/pathauto) - for filename/path
cleanup options.
- [Redirect](https://www.drupal.org/project/redirect) - to create a redirect
when a previously uploaded file is moved.
- [Token](https://www.drupal.org/project/token) - for the token browser when
building path/filename patterns.


## Configuration

Once installed, File (Field) Paths needs to be configured for each file field
you wish to use. Settings can be found on the settings form of any supported
file based field.
you wish to use, on that field's settings form. For example, for an Image
field on the Article content type:

*Example:*

Administration > Structure > Content types > Article > Manage fields > Image
http://example.com/admin/structure/types/manage/article/fields/field_image
(admin/structure/types/manage/article/fields/field_image)

Module-wide settings, such as the temporary upload location, are at
Administration > Configuration > Media > File system > File (Field) Paths
(admin/config/media/file-system/filefield-paths).

## Features

Expand Down Expand Up @@ -98,6 +108,17 @@ http://example.com/admin/structure/types/manage/article/fields/field_image
servers so that you can make sure not to introduce any linking issues.


**Q: How do I disable File (Field) Paths?**

**A:** At three levels: uncheck "Enable File (Field) Paths?" on a field's settings
form to disable it for that field, uncheck "Enable File (Field) Paths" on the
module's settings form to disable it site-wide, or set the
`filefield_paths_settings` property on an entity before saving it to disable
(or otherwise override) processing for that one save only, without changing
any configuration. See `filefield_paths.api.php` for the per-save override's
full API.



## Maintainers

Expand Down
1 change: 1 addition & 0 deletions config/install/filefield_paths.settings.yml
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
enabled: true
temp_location: 'public://filefield_paths'
3 changes: 3 additions & 0 deletions config/schema/filefield_paths.schema.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ filefield_paths.settings:
type: config_object
label: 'File (Field) Paths configuration settings'
mapping:
enabled:
type: boolean
label: 'Enabled'
temp_location:
type: string
label: 'Temporary file location'
Expand Down
36 changes: 36 additions & 0 deletions filefield_paths.api.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,42 @@
/**
* @file
* Hooks provided by the File (Field) Paths module.
*
* @section sec_disabling Disabling processing
*
* File (Field) Paths offers three ways to suppress processing, at different
* levels of granularity:
* - Site-wide: disable the "Enable File (Field) Paths" checkbox on the
* module's settings form (filefield_paths.admin_settings), or set the
* `enabled` key of the `filefield_paths.settings` config object to FALSE.
* A persistent config change affecting every field on the site.
* - Per field, persistent: disable the "Enable File (Field) Paths?" checkbox
* on a specific field's settings form. A persistent config change
* affecting every entity that uses that field.
* - Per entity, transient: set the `filefield_paths_settings` property on an
* entity before saving it, to suppress (or otherwise override) processing
* for that single save only. This is a plain runtime property, never
* persisted to storage, and is the recommended approach for migrations and
* other programmatic imports where you don't want to touch field
* configuration (which would invalidate site-wide caches) just to skip
* processing for the rows being imported. For example:
* @code
* // Suppress every file field on this entity for this save only.
* $entity->filefield_paths_settings = ['enabled' => FALSE];
*
* // Suppress just one field, leaving others on the entity untouched.
* $entity->filefield_paths_settings = ['field_image' => ['enabled' => FALSE]];
*
* // Override any other settings key the same way, not just 'enabled' -
* // here the file still gets moved/renamed, but no redirect is created
* // for this save.
* $entity->filefield_paths_settings = ['redirect' => FALSE];
* @endcode
* A key matching a field name on the entity is treated as a field-specific
* override (and takes precedence); any other key is applied to every
* field. This can only ever suppress or modify processing that is already
* enabled via field configuration - it cannot enable File (Field) Paths on
* a field where it isn't configured.
*/

declare(strict_types=1);
Expand Down
60 changes: 38 additions & 22 deletions filefield_paths.install
Original file line number Diff line number Diff line change
Expand Up @@ -73,33 +73,39 @@ function filefield_paths_requirements(string $phase): array {
$requirements = [];

if ($phase === 'runtime') {
$temporary_path = \Drupal::config('filefield_paths.settings')->get('temp_location');
$config = \Drupal::config('filefield_paths.settings');
$temporary_path = $config->get('temp_location');
$temporary_scheme = $temporary_path ? substr((string) $temporary_path, 0, 9) : NULL;

// If it's not set, we don't need to do anything because the default will
// be secure.
if (!$temporary_path) {
return [];
}

$temporary_scheme = substr((string) $temporary_path, 0, 9);
if ($temporary_scheme !== 'public://') {
return [];
}
// If private files are supported, and the temporary scheme is 'public://'
// then let the user know they need to change the temporary scheme in order
// to be secure.
$wrappers = \Drupal::service('stream_wrapper_manager')->getWrappers();
$recommended_wrappers = ['private', 'temporary'];
foreach ($recommended_wrappers as $recommended_wrapper) {
if (in_array($recommended_wrapper, array_keys($wrappers), TRUE)) {
$requirements['filefield_paths'] = [
'title' => t('File (Field) Paths temporary path'),
'value' => t('Insecure!'),
'description' => t('This site supports private files but the File (Field) Paths temporary path is under public:// which could lead to private files being temporarily exposed publicly. <a href=":url">Change the temporary path</a> to be under temporary:// or private:// in order to secure your files.', [':url' => Url::fromRoute('filefield_paths.admin_settings')->toString()]),
'severity' => DeprecationHelper::backwardsCompatibleCall(\Drupal::VERSION, '11.2.0', fn(): RequirementSeverity => RequirementSeverity::Error, fn() => REQUIREMENT_ERROR),
];
// to be secure. If temp_location is unset, the default will be secure.
if ($temporary_scheme === 'public://') {
$wrappers = \Drupal::service('stream_wrapper_manager')->getWrappers();
$recommended_wrappers = ['private', 'temporary'];
foreach ($recommended_wrappers as $recommended_wrapper) {
if (in_array($recommended_wrapper, array_keys($wrappers), TRUE)) {
$requirements['filefield_paths'] = [
'title' => t('File (Field) Paths temporary path'),
'value' => t('Insecure!'),
'description' => t('This site supports private files but the File (Field) Paths temporary path is under public:// which could lead to private files being temporarily exposed publicly. <a href=":url">Change the temporary path</a> to be under temporary:// or private:// in order to secure your files.', [':url' => Url::fromRoute('filefield_paths.admin_settings')->toString()]),
'severity' => DeprecationHelper::backwardsCompatibleCall(\Drupal::VERSION, '11.2.0', fn(): RequirementSeverity => RequirementSeverity::Error, fn() => REQUIREMENT_ERROR),
];
}
}
}

// Use a strict comparison: an unset value means the site predates the
// 'enabled' setting and an update hook has not run yet, not that it was
// deliberately disabled.
if ($config->get('enabled') === FALSE) {
$requirements['filefield_paths_enabled'] = [
'title' => t('File (Field) Paths'),
'value' => t('Disabled'),
'description' => t('File (Field) Paths processing is disabled site-wide. If you do not intend to use it, consider uninstalling the module instead of leaving it disabled. <a href=":url">Re-enable it</a> if this was not intentional.', [':url' => Url::fromRoute('filefield_paths.admin_settings')->toString()]),
'severity' => DeprecationHelper::backwardsCompatibleCall(\Drupal::VERSION, '11.2.0', fn(): RequirementSeverity => RequirementSeverity::Warning, fn() => REQUIREMENT_WARNING),
];
}
}

return $requirements;
Expand All @@ -123,3 +129,13 @@ function filefield_paths_update_8001(): void {
function filefield_paths_update_9001(): void {
filefield_paths_update_temporary_location_configuration();
}

/**
* Add the 'enabled' setting, defaulting existing sites to enabled.
*/
function filefield_paths_update_9002(): void {
$config = \Drupal::configFactory()->getEditable('filefield_paths.settings');
if ($config->get('enabled') === NULL) {
$config->set('enabled', TRUE)->save();
}
}
12 changes: 12 additions & 0 deletions phpstan.neon
Original file line number Diff line number Diff line change
Expand Up @@ -129,4 +129,16 @@ parameters:
- '#Call to deprecated function _filefield_paths_#'
reportUnmatched: false

-
# filefield_paths_settings is a plain runtime property (not a field), set directly on the
# entity per filefield_paths.api.php. PHPStan's Drupal extension assumes every property on a
# ContentEntityInterface is a field access and infers FieldItemListInterface for it.
#
# See the note above on ModuleDeprecatedWrappersTest.php for why this
# uses a leading wildcard instead of a path rooted at the module.
path: '*/EntityWithFileFieldOverrideTest.php'
messages:
- '#Property Drupal\\entity_test\\Entity\\EntityTest::\$filefield_paths_settings \(Drupal\\Core\\Field\\FieldItemListInterface\) does not accept#'
reportUnmatched: false

reportUnmatchedIgnoredErrors: false
8 changes: 8 additions & 0 deletions src/Form/SettingsForm.php
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,13 @@ protected function getEditableConfigNames(): array {
*/
#[\Override]
public function buildForm(array $form, FormStateInterface $form_state, ?Request $request = NULL) {
$form['enabled'] = [
'#title' => $this->t('Enable File (Field) Paths'),
'#type' => 'checkbox',
'#default_value' => $this->config('filefield_paths.settings')->get('enabled') ?? TRUE,
'#description' => $this->t('Disable this to skip processing on all fields, site-wide. This can also be configured per field. Uninstall the module instead if you do not intend to use it.'),
];

$description = $this->t('The location that unprocessed files will be uploaded prior to being processed by File (Field) Paths.');
$description .= '<br />';
$description .= $this->t('It is recommended to use the temporary file system (temporary://) whenever possible, especially for files that do not require previewing before form submission. Alternatively, if your server configuration permits, the private file system (private://) is preferred for situations where file previews — such as image previews — are needed before the form is submitted, as it provides secure and appropriate access for this functionality.');
Expand Down Expand Up @@ -128,6 +135,7 @@ public function validateForm(array &$form, FormStateInterface $form_state): void
public function submitForm(array &$form, FormStateInterface $form_state): void {
$values = $form_state->getValues();
$this->config('filefield_paths.settings')
->set('enabled', $values['enabled'])
->set('temp_location', $values['temp_location'])
->save();
}
Expand Down
60 changes: 52 additions & 8 deletions src/Hook/EntityWithFileField.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,15 @@

declare(strict_types=1);

/**
* @file
* Hook implementation that processes an entity's file fields on save.
*/

namespace Drupal\filefield_paths\Hook;

use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Config\ImmutableConfig;
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Extension\ModuleHandlerInterface;
Expand All @@ -21,10 +28,14 @@
*
* @param \Closure $moduleHandlerClosure
* The module handler closure.
* @param \Closure $configFactoryClosure
* The config factory closure.
*/
public function __construct(
#[AutowireServiceClosure(ModuleHandlerInterface::class)]
private \Closure $moduleHandlerClosure,
#[AutowireServiceClosure(ConfigFactoryInterface::class)]
private \Closure $configFactoryClosure,
) {}

/**
Expand All @@ -33,19 +44,42 @@ public function __construct(
// @phpstan-ignore-next-line
#[Hook('entity_insert'), Hook('entity_update')]
public function handleProcessFile(EntityInterface $entity): void {// phpcs:ignore Squiz.WhiteSpace.FunctionSpacing.Before
if (!($this->getSettings()->get('enabled') ?? TRUE)) {
return;
}
if (!$entity instanceof ContentEntityInterface) {
return;
}
$module_handler = $this->getModuleHandler();
foreach ($entity->getFields() as $field) {
if (FieldItem::hasConfigurationEnabled($field)) {
$settings = FieldItem::getConfiguration($field);
// Invoke hook_filefield_paths_process_file().
$module_handler->invokeAll(
'filefield_paths_process_file',
[$entity, $field, &$settings]
);
$fields = $entity->getFields();
// Lets calling code skip (or otherwise tweak) processing for a single
// save without touching field config, which would invalidate caches.
// See filefield_paths.api.php for the accepted shapes of this property.
$override = $entity->filefield_paths_settings ?? [];
// The property is untyped, so guard against a caller setting something
// other than an array.
if (!is_array($override)) {
$override = [];
}
// Anything that isn't a field name applies to every field; a field name
// key scopes the override to that field only and wins if both are set.
$flat_override = array_diff_key($override, $fields);
foreach ($fields as $field_name => $field) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (!FieldItem::hasConfigurationEnabled($field)) {
continue;
}
$settings = $flat_override + FieldItem::getConfiguration($field);
if (is_array($override[$field_name] ?? NULL)) {
$settings = $override[$field_name] + $settings;
}
if (empty($settings['enabled'])) {
continue;
}
// Invoke hook_filefield_paths_process_file().
$module_handler->invokeAll(
'filefield_paths_process_file',
[$entity, $field, &$settings]
);
}
}

Expand All @@ -59,4 +93,14 @@ private function getModuleHandler(): ModuleHandlerInterface {
return ($this->moduleHandlerClosure)();
}

/**
* Retrieves the configuration settings for filefield_paths.
*
* @return \Drupal\Core\Config\ImmutableConfig
* The configuration settings object.
*/
private function getSettings(): ImmutableConfig {
return ($this->configFactoryClosure)()->get('filefield_paths.settings');
}

}
7 changes: 6 additions & 1 deletion src/Hook/FieldWidgetSingleElementForm.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,12 @@ public function __construct(
#[Hook('field_widget_single_element_form_alter')]
public function formAlter(array &$element, FormStateInterface $form_state, array $context): void {// phpcs:ignore Squiz.WhiteSpace.FunctionSpacing.Before
// Force all File (Field) Paths uploads to go to the temporary file system
// prior to being processed.
// prior to being processed. Skipped entirely when disabled site-wide, so
// uploads fall back to the field's own upload location instead of being
// staged somewhere they will never be moved out of.
if (!($this->getSettings()->get('enabled') ?? TRUE)) {
return;
}
if (FieldItem::hasConfigurationEnabled(FieldItem::getFromSupportedWidget($element, $context))) {
$settings = $context['items']->getFieldDefinition()
->getThirdPartySettings('filefield_paths');
Expand Down
Loading