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