commit
37bedc086c
8 changed files with 368 additions and 0 deletions
@ -0,0 +1,43 @@ |
|||||||
|
# Media File Privacy |
||||||
|
|
||||||
|
Drupal 10/11 module providing two Media actions: |
||||||
|
|
||||||
|
1. **Move unpublished media files to private storage** |
||||||
|
- Runs only on unpublished Media entities. |
||||||
|
- Moves a source file only when its URI begins with `public://`. |
||||||
|
- Example: `public://2026-08/example.jpg` -> `private://2026-08/example.jpg`. |
||||||
|
|
||||||
|
2. **Move published media files to public storage** |
||||||
|
- Runs only on published Media entities. |
||||||
|
- Moves a source file only when its URI begins with `private://`. |
||||||
|
- Example: `private://2026-08/example.jpg` -> `public://2026-08/example.jpg`. |
||||||
|
|
||||||
|
## Installation |
||||||
|
|
||||||
|
Place the module at: |
||||||
|
|
||||||
|
web/modules/custom/media_file_privacy |
||||||
|
|
||||||
|
Enable it: |
||||||
|
|
||||||
|
drush en media_file_privacy -y |
||||||
|
drush cr |
||||||
|
|
||||||
|
The actions should then be available to Media Views Bulk Operations / the |
||||||
|
standard Views bulk form, depending on the View configuration. |
||||||
|
|
||||||
|
## Collision handling |
||||||
|
|
||||||
|
If a file already exists at the destination URI, the action logs an error and |
||||||
|
does not overwrite or rename either file. Errors are written to the |
||||||
|
`media_file_privacy` log channel. |
||||||
|
|
||||||
|
## Private file system |
||||||
|
|
||||||
|
Drupal must have a working private file path configured, for example in |
||||||
|
settings.php: |
||||||
|
|
||||||
|
$settings['file_private_path'] = '/var/www/site-files/private'; |
||||||
|
|
||||||
|
The directory must be writable by the web/PHP user. |
||||||
|
|
||||||
@ -0,0 +1,9 @@ |
|||||||
|
{ |
||||||
|
"name": "roblib/media_file_privacy", |
||||||
|
"description": "Actions to move files between private and public storage", |
||||||
|
"type": "drupal-custom-module", |
||||||
|
"minimum-stability": "dev", |
||||||
|
"license": "GPL-2.0-or-later", |
||||||
|
"require": { |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,11 @@ |
|||||||
|
langcode: en |
||||||
|
status: true |
||||||
|
dependencies: |
||||||
|
module: |
||||||
|
- media |
||||||
|
- media_file_privacy |
||||||
|
id: media_file_privacy_move_published_public |
||||||
|
label: 'Move published media files to public storage' |
||||||
|
type: media |
||||||
|
plugin: media_file_privacy_move_published_public |
||||||
|
configuration: { } |
||||||
@ -0,0 +1,11 @@ |
|||||||
|
langcode: en |
||||||
|
status: true |
||||||
|
dependencies: |
||||||
|
module: |
||||||
|
- media |
||||||
|
- media_file_privacy |
||||||
|
id: media_file_privacy_move_unpublished_private |
||||||
|
label: 'Move unpublished media files to private storage' |
||||||
|
type: media |
||||||
|
plugin: media_file_privacy_move_unpublished_private |
||||||
|
configuration: { } |
||||||
@ -0,0 +1,8 @@ |
|||||||
|
name: Media File Privacy |
||||||
|
type: module |
||||||
|
description: 'Provides actions to move media source files between public and private storage based on media publication status.' |
||||||
|
package: Custom |
||||||
|
core_version_requirement: ^10.2 || ^11 |
||||||
|
dependencies: |
||||||
|
- drupal:file |
||||||
|
- drupal:media |
||||||
@ -0,0 +1,206 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
namespace Drupal\media_file_privacy\Plugin\Action; |
||||||
|
|
||||||
|
use Drupal\Core\Action\ActionBase; |
||||||
|
use Drupal\Core\Entity\EntityTypeManagerInterface; |
||||||
|
use Drupal\Core\File\FileExists; |
||||||
|
use Drupal\Core\File\FileSystemInterface; |
||||||
|
use Drupal\Core\Logger\LoggerChannelFactoryInterface; |
||||||
|
use Drupal\Core\Session\AccountInterface; |
||||||
|
use Drupal\Core\Plugin\ContainerFactoryPluginInterface; |
||||||
|
use Drupal\file\FileInterface; |
||||||
|
use Drupal\file\FileRepositoryInterface; |
||||||
|
use Drupal\media\MediaInterface; |
||||||
|
use Drupal\media\MediaTypeInterface; |
||||||
|
use Symfony\Component\DependencyInjection\ContainerInterface; |
||||||
|
|
||||||
|
/** |
||||||
|
* Base class for actions that move a media source file between schemes. |
||||||
|
*/ |
||||||
|
abstract class MediaFileMoveActionBase extends ActionBase implements ContainerFactoryPluginInterface { |
||||||
|
|
||||||
|
/** |
||||||
|
* Constructs a MediaFileMoveActionBase object. |
||||||
|
*/ |
||||||
|
public function __construct( |
||||||
|
array $configuration, |
||||||
|
$plugin_id, |
||||||
|
$plugin_definition, |
||||||
|
protected FileRepositoryInterface $fileRepository, |
||||||
|
protected FileSystemInterface $fileSystem, |
||||||
|
protected EntityTypeManagerInterface $entityTypeManager, |
||||||
|
protected LoggerChannelFactoryInterface $loggerFactory, |
||||||
|
) { |
||||||
|
parent::__construct($configuration, $plugin_id, $plugin_definition); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* {@inheritdoc} |
||||||
|
*/ |
||||||
|
public static function create( |
||||||
|
ContainerInterface $container, |
||||||
|
array $configuration, |
||||||
|
$plugin_id, |
||||||
|
$plugin_definition, |
||||||
|
): static { |
||||||
|
return new static( |
||||||
|
$configuration, |
||||||
|
$plugin_id, |
||||||
|
$plugin_definition, |
||||||
|
$container->get('file.repository'), |
||||||
|
$container->get('file_system'), |
||||||
|
$container->get('entity_type.manager'), |
||||||
|
$container->get('logger.factory'), |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* The source scheme this action accepts, without ://. |
||||||
|
*/ |
||||||
|
abstract protected function sourceScheme(): string; |
||||||
|
|
||||||
|
/** |
||||||
|
* The destination scheme, without ://. |
||||||
|
*/ |
||||||
|
abstract protected function destinationScheme(): string; |
||||||
|
|
||||||
|
/** |
||||||
|
* Whether the media publication state is appropriate for this action. |
||||||
|
*/ |
||||||
|
abstract protected function mediaStateMatches(MediaInterface $media): bool; |
||||||
|
|
||||||
|
/** |
||||||
|
* {@inheritdoc} |
||||||
|
*/ |
||||||
|
public function execute($entity = NULL): void { |
||||||
|
if (!$entity instanceof MediaInterface) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
// Do not move anything if this action does not match the publication state. |
||||||
|
if (!$this->mediaStateMatches($entity)) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
$media_type = $this->entityTypeManager |
||||||
|
->getStorage('media_type') |
||||||
|
->load($entity->bundle()); |
||||||
|
|
||||||
|
if (!$media_type instanceof MediaTypeInterface) { |
||||||
|
$this->logError( |
||||||
|
'Could not load media type @bundle for media @mid.', |
||||||
|
[ |
||||||
|
'@bundle' => $entity->bundle(), |
||||||
|
'@mid' => $entity->id(), |
||||||
|
], |
||||||
|
); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
$source = $entity->getSource(); |
||||||
|
$source_field_definition = $source->getSourceFieldDefinition($media_type); |
||||||
|
|
||||||
|
if (!$source_field_definition) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
$source_field_name = $source_field_definition->getName(); |
||||||
|
|
||||||
|
if (!$entity->hasField($source_field_name) || $entity->get($source_field_name)->isEmpty()) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
// File- and image-based media source fields are entity references to files. |
||||||
|
// Remote media types may have a string/URL source field; those are skipped. |
||||||
|
$field = $entity->get($source_field_name); |
||||||
|
if (!method_exists($field, 'referencedEntities')) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
foreach ($field->referencedEntities() as $file) { |
||||||
|
if ($file instanceof FileInterface) { |
||||||
|
$this->moveFile($entity, $file); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Moves one managed file while preserving its path beneath the scheme. |
||||||
|
*/ |
||||||
|
protected function moveFile(MediaInterface $media, FileInterface $file): void { |
||||||
|
$source_uri = $file->getFileUri(); |
||||||
|
$source_prefix = $this->sourceScheme() . '://'; |
||||||
|
|
||||||
|
if (!str_starts_with($source_uri, $source_prefix)) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
$relative_path = substr($source_uri, strlen($source_prefix)); |
||||||
|
if ($relative_path === '' || $relative_path === FALSE) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
$destination_uri = $this->destinationScheme() . '://' . $relative_path; |
||||||
|
$destination_directory = dirname($destination_uri); |
||||||
|
|
||||||
|
// Ensure the corresponding directory exists in the destination scheme. |
||||||
|
if (!$this->fileSystem->prepareDirectory( |
||||||
|
$destination_directory, |
||||||
|
FileSystemInterface::CREATE_DIRECTORY | FileSystemInterface::MODIFY_PERMISSIONS, |
||||||
|
)) { |
||||||
|
$this->logError( |
||||||
|
'Could not prepare destination directory @directory for media @mid, file @fid.', |
||||||
|
[ |
||||||
|
'@directory' => $destination_directory, |
||||||
|
'@mid' => $media->id(), |
||||||
|
'@fid' => $file->id(), |
||||||
|
], |
||||||
|
); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
// Error rather than overwrite/rename if the destination already exists. |
||||||
|
// This avoids silently overwriting a different managed file. |
||||||
|
$this->fileRepository->move( |
||||||
|
$file, |
||||||
|
$destination_uri, |
||||||
|
FileExists::Error, |
||||||
|
); |
||||||
|
} |
||||||
|
catch (\Throwable $e) { |
||||||
|
$this->logError( |
||||||
|
'Could not move file @fid for media @mid from @source to @destination: @message', |
||||||
|
[ |
||||||
|
'@fid' => $file->id(), |
||||||
|
'@mid' => $media->id(), |
||||||
|
'@source' => $source_uri, |
||||||
|
'@destination' => $destination_uri, |
||||||
|
'@message' => $e->getMessage(), |
||||||
|
], |
||||||
|
); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Logs a move error. |
||||||
|
*/ |
||||||
|
protected function logError(string $message, array $context = []): void { |
||||||
|
$this->loggerFactory |
||||||
|
->get('media_file_privacy') |
||||||
|
->error($message, $context); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* {@inheritdoc} |
||||||
|
*/ |
||||||
|
public function access($object, AccountInterface $account = NULL, $return_as_object = FALSE) { |
||||||
|
if (!$object instanceof MediaInterface) { |
||||||
|
return FALSE; |
||||||
|
} |
||||||
|
|
||||||
|
return $object->access('update', $account, $return_as_object); |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
@ -0,0 +1,40 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
namespace Drupal\media_file_privacy\Plugin\Action; |
||||||
|
|
||||||
|
use Drupal\Core\Action\Attribute\Action; |
||||||
|
use Drupal\Core\StringTranslation\TranslatableMarkup; |
||||||
|
use Drupal\media\MediaInterface; |
||||||
|
|
||||||
|
/** |
||||||
|
* Moves private source files for published media to public storage. |
||||||
|
*/ |
||||||
|
#[Action( |
||||||
|
id: 'media_file_privacy_move_published_public', |
||||||
|
label: new TranslatableMarkup('Move published media files to public storage'), |
||||||
|
type: 'media', |
||||||
|
)] |
||||||
|
final class MovePublishedMediaFilePublic extends MediaFileMoveActionBase { |
||||||
|
|
||||||
|
/** |
||||||
|
* {@inheritdoc} |
||||||
|
*/ |
||||||
|
protected function sourceScheme(): string { |
||||||
|
return 'private'; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* {@inheritdoc} |
||||||
|
*/ |
||||||
|
protected function destinationScheme(): string { |
||||||
|
return 'public'; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* {@inheritdoc} |
||||||
|
*/ |
||||||
|
protected function mediaStateMatches(MediaInterface $media): bool { |
||||||
|
return $media->isPublished(); |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
@ -0,0 +1,40 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
namespace Drupal\media_file_privacy\Plugin\Action; |
||||||
|
|
||||||
|
use Drupal\Core\Action\Attribute\Action; |
||||||
|
use Drupal\Core\StringTranslation\TranslatableMarkup; |
||||||
|
use Drupal\media\MediaInterface; |
||||||
|
|
||||||
|
/** |
||||||
|
* Moves public source files for unpublished media to private storage. |
||||||
|
*/ |
||||||
|
#[Action( |
||||||
|
id: 'media_file_privacy_move_unpublished_private', |
||||||
|
label: new TranslatableMarkup('Move unpublished media files to private storage'), |
||||||
|
type: 'media', |
||||||
|
)] |
||||||
|
final class MoveUnpublishedMediaFilePrivate extends MediaFileMoveActionBase { |
||||||
|
|
||||||
|
/** |
||||||
|
* {@inheritdoc} |
||||||
|
*/ |
||||||
|
protected function sourceScheme(): string { |
||||||
|
return 'public'; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* {@inheritdoc} |
||||||
|
*/ |
||||||
|
protected function destinationScheme(): string { |
||||||
|
return 'private'; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* {@inheritdoc} |
||||||
|
*/ |
||||||
|
protected function mediaStateMatches(MediaInterface $media): bool { |
||||||
|
return !$media->isPublished(); |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
Loading…
Reference in new issue