commit 37bedc086cd9c4e47e81b2c1829371c1570c5104 Author: astanley Date: Wed Aug 26 15:25:43 2026 -0300 initial commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..c5f0935 --- /dev/null +++ b/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. + diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..bebdb46 --- /dev/null +++ b/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": { + } +} diff --git a/config/install/system.action.media_file_privacy_move_published_public.yml b/config/install/system.action.media_file_privacy_move_published_public.yml new file mode 100644 index 0000000..9807ec8 --- /dev/null +++ b/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: { } diff --git a/config/install/system.action.media_file_privacy_move_unpublished_private.yml b/config/install/system.action.media_file_privacy_move_unpublished_private.yml new file mode 100644 index 0000000..035f5a6 --- /dev/null +++ b/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: { } diff --git a/media_file_privacy.info.yml b/media_file_privacy.info.yml new file mode 100644 index 0000000..b848932 --- /dev/null +++ b/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 diff --git a/src/Plugin/Action/MediaFileMoveActionBase.php b/src/Plugin/Action/MediaFileMoveActionBase.php new file mode 100644 index 0000000..88bd587 --- /dev/null +++ b/src/Plugin/Action/MediaFileMoveActionBase.php @@ -0,0 +1,206 @@ +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); + } + +} diff --git a/src/Plugin/Action/MovePublishedMediaFilePublic.php b/src/Plugin/Action/MovePublishedMediaFilePublic.php new file mode 100644 index 0000000..bc756c7 --- /dev/null +++ b/src/Plugin/Action/MovePublishedMediaFilePublic.php @@ -0,0 +1,40 @@ +isPublished(); + } + +} diff --git a/src/Plugin/Action/MoveUnpublishedMediaFilePrivate.php b/src/Plugin/Action/MoveUnpublishedMediaFilePrivate.php new file mode 100644 index 0000000..2a07dfb --- /dev/null +++ b/src/Plugin/Action/MoveUnpublishedMediaFilePrivate.php @@ -0,0 +1,40 @@ +isPublished(); + } + +}