Browse Source

initial commit

main
astanley 3 weeks ago
commit
37bedc086c
  1. 43
      README.md
  2. 9
      composer.json
  3. 11
      config/install/system.action.media_file_privacy_move_published_public.yml
  4. 11
      config/install/system.action.media_file_privacy_move_unpublished_private.yml
  5. 8
      media_file_privacy.info.yml
  6. 206
      src/Plugin/Action/MediaFileMoveActionBase.php
  7. 40
      src/Plugin/Action/MovePublishedMediaFilePublic.php
  8. 40
      src/Plugin/Action/MoveUnpublishedMediaFilePrivate.php

43
README.md

@ -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.

9
composer.json

@ -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": {
}
}

11
config/install/system.action.media_file_privacy_move_published_public.yml

@ -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: { }

11
config/install/system.action.media_file_privacy_move_unpublished_private.yml

@ -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: { }

8
media_file_privacy.info.yml

@ -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

206
src/Plugin/Action/MediaFileMoveActionBase.php

@ -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);
}
}

40
src/Plugin/Action/MovePublishedMediaFilePublic.php

@ -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();
}
}

40
src/Plugin/Action/MoveUnpublishedMediaFilePrivate.php

@ -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…
Cancel
Save