diff --git a/.changelogs/undo-media-protection.yml b/.changelogs/undo-media-protection.yml
new file mode 100644
index 0000000000..7fd4a63a14
--- /dev/null
+++ b/.changelogs/undo-media-protection.yml
@@ -0,0 +1,3 @@
+significance: minor
+type: added
+entry: "Added ability to undo a protected media file by clearing the selected course or membership. The file is moved back to its original public location and all protection metadata is removed."
diff --git a/assets/js/llms-admin-media-protection-attachment-settings.js b/assets/js/llms-admin-media-protection-attachment-settings.js
index 4c53539c9f..3ddb8f6024 100644
--- a/assets/js/llms-admin-media-protection-attachment-settings.js
+++ b/assets/js/llms-admin-media-protection-attachment-settings.js
@@ -33,7 +33,19 @@
const $select = jQuery( '.media-modal .llms-posts-select2' );
if ( $select.length && ! $select.data( 'select2' )) {
$select.llmsPostsSelect2();
+ $select.on( 'select2:select select2:clear', this.toggleUnprotectWarning );
}
+ this.toggleUnprotectWarning();
+ },
+
+ toggleUnprotectWarning: function() {
+ const $select = jQuery( '.media-modal .llms-posts-select2' );
+ const $warning = jQuery( '.media-modal .llms-media-protection-unprotect-warning' );
+ if ( ! $warning.length ) {
+ return;
+ }
+ const hasValue = $select.val() && '' !== $select.val();
+ $warning.toggle( ! hasValue );
},
refreshAttachmentUrl: function() {
diff --git a/includes/admin/class-llms-admin-media-protection-attachment-settings.php b/includes/admin/class-llms-admin-media-protection-attachment-settings.php
index 1a68b827ad..57700b3ca1 100644
--- a/includes/admin/class-llms-admin-media-protection-attachment-settings.php
+++ b/includes/admin/class-llms-admin-media-protection-attachment-settings.php
@@ -3,6 +3,9 @@
* LifterLMS Admin Media Protection Attachment Settings.
*
* @package LifterLMS/Classes/Admin
+ *
+ * @since 9.0.0
+ * @version [version]
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
@@ -26,7 +29,7 @@ public function __construct() {
*/
public function attachment_fields_to_edit( $form_fields, $post ) {
- $selected_product_html = $protection_warning_html = '';
+ $selected_product_html = $protection_warning_html = $original_location_html = $unprotect_warning_html = '';
$selected_product_id = get_post_meta( $post->ID, '_llms_media_protection_product_id', true );
if ( $selected_product_id && ( $selected_product = get_post( $selected_product_id ) ) ) {
$selected_product_html = sprintf( '', $selected_product->ID, $selected_product->post_title );
@@ -36,13 +39,29 @@ public function attachment_fields_to_edit( $form_fields, $post ) {
if ( ! $protector->is_media_protected( $post->ID ) ) {
// translators: %s is a link to the LifterLMS documentation.
$protection_warning_html = '
' . sprintf( __( 'This media is not protected. If you select a product here, the media will be moved to the protected uploads directory and existing links to the media will no longer work. %1$sLearn More%2$s', 'lifterlms' ), '', '' ) . '
' . sprintf(
+ // translators: %s is the original public location of the media file before it was protected.
+ esc_html__( 'Originally located in: %s', 'lifterlms' ),
+ 'wp-content/uploads/' . esc_html( $original_file ) . ''
+ ) . '
';
+
+ $unprotect_warning_html = '
' . sprintf(
+ // translators: %s is a link to the LifterLMS documentation.
+ esc_html__( 'Clearing the selected product will move this file back to its original public location. Existing protected links to this media will no longer work. %1$sLearn More%2$s', 'lifterlms' ),
+ '',
+ ''
+ ) . '
';
+ }
}
$form_fields['llms_media_protection_post'] = array(
'label' => __( 'LifterLMS Media Protection:', 'lifterlms' ),
'input' => 'html',
// TODO: Add selected course/membership to the select2 dropdown if known for this attachment post.
- 'html' => "$protection_warning_html",
+ 'html' => "$protection_warning_html$original_location_html$unprotect_warning_html",
'helps' => $protector->is_media_protected( $post->ID ) ? sprintf( __( 'Access is restricted to the selected course/membership. %1$sLearn More%2$s', 'lifterlms' ), '', '' ) : '',
);
@@ -69,7 +88,17 @@ public function attachment_fields_to_edit( $form_fields, $post ) {
*/
public function attachment_fields_to_save( $post, $attachment ) {
- if ( ! empty( $attachment['llms_media_protection_post'] ) ) {
+ $protector = new LLMS_Media_Protector();
+
+ if ( empty( $attachment['llms_media_protection_post'] ) ) {
+ // Empty value: unprotect the file if it is currently protected.
+ if ( $protector->is_media_protected( $post['ID'] ) ) {
+ $result = $this->move_attachment_to_public_dir( $post['ID'] );
+ if ( true === $result ) {
+ delete_post_meta( $post['ID'], '_llms_media_protection_product_id' );
+ }
+ }
+ } else {
if ( $this->move_attachment_to_protected_dir( $post['ID'] ) ) {
update_post_meta( $post['ID'], '_llms_media_protection_product_id', absint( $attachment['llms_media_protection_post'] ) );
}
@@ -151,6 +180,10 @@ function move_attachment_to_protected_dir( $attachment_id ) {
}
}
+ // Record the original public location before the metadata is updated, so that
+ // it can be used to restore the file to its original public path if it is later unprotected.
+ $this->store_original_attached_file( $attachment_id, $metadata, $file );
+
// Update attachment location in database.
update_attached_file( $attachment_id, $new_file );
@@ -168,6 +201,215 @@ function move_attachment_to_protected_dir( $attachment_id ) {
error_log( 'Unable to move protected file, check permissions on the protected directory or existing file with the same name: ' . $file );
return false;
}
+
+ /**
+ * Move a protected media attachment back to its original public location.
+ *
+ * Mirrors {@see LLMS_Admin_Media_Protection_Attachment_Settings::move_attachment_to_protected_dir()}.
+ * Requires the original `_wp_attached_file` relative path to have been recorded by
+ * {@see LLMS_Admin_Media_Protection_Attachment_Settings::store_original_attached_file()} at the time of protection.
+ *
+ * @since [version]
+ *
+ * @param int $attachment_id The attachment post ID.
+ * @return bool|WP_Error True on success, false on failure, or WP_Error on conflict.
+ */
+ public function move_attachment_to_public_dir( $attachment_id ) {
+ if ( ! is_numeric( $attachment_id ) || ! intval( $attachment_id ) ) {
+ return false;
+ }
+
+ $protector = new LLMS_Media_Protector();
+
+ // Only protected files can be unprotected.
+ if ( ! $protector->is_media_protected( $attachment_id ) ) {
+ return false;
+ }
+
+ // The original public location must have been recorded when the file was protected.
+ $original_relative = $protector->get_original_attached_file( $attachment_id );
+ if ( ! $original_relative ) {
+ error_log( 'Unable to unprotect attachment ' . $attachment_id . ': original location is not recorded.' );
+ return false;
+ }
+
+ $metadata = wp_get_attachment_metadata( $attachment_id );
+ $current_file = get_attached_file( $attachment_id );
+
+ $uploads = wp_upload_dir();
+ $target_file = $uploads['basedir'] . DIRECTORY_SEPARATOR . $original_relative;
+ $target_dir = dirname( $target_file );
+
+ global $wp_filesystem;
+ if ( empty( $wp_filesystem ) ) {
+ require_once ABSPATH . '/wp-admin/includes/file.php';
+ WP_Filesystem();
+ }
+
+ // Ensure the target public directory exists.
+ if ( ! $wp_filesystem->is_dir( $target_dir ) ) {
+ wp_mkdir_p( $target_dir );
+ }
+
+ // Handle a conflict if a file already exists at the public path.
+ if ( $wp_filesystem->exists( $target_file ) ) {
+ if ( ! $this->files_are_identical( $current_file, $target_file ) ) {
+ // translators: %s is the conflicting public file path.
+ error_log( sprintf( 'Unable to unprotect attachment %1$d: a different file already exists at %2$s.', $attachment_id, $target_file ) );
+ return new WP_Error(
+ 'llms_media_unprotect_conflict',
+ sprintf(
+ // translators: %s is the conflicting public file path.
+ __( 'A different file already exists at the original location (%s). Please rename or remove it before unprotecting this media.', 'lifterlms' ),
+ $target_file
+ )
+ );
+ }
+
+ // Same file: delete the protected copy. The public copy is already in place.
+ $wp_filesystem->delete( $current_file );
+ } else {
+ if ( ! $wp_filesystem->move( $current_file, $target_file ) ) {
+ // translators: %1$s is the current protected file, %2$s is the target public file.
+ error_log( sprintf( 'Unable to move protected file back to public dir: %1$s to %2$s', $current_file, $target_file ) );
+ return false;
+ }
+ }
+
+ // Move thumbnails back, if any.
+ if ( ! empty( $metadata['sizes'] ) ) {
+ $current_base_dir = dirname( $current_file );
+ $target_base_dir = dirname( $target_file );
+
+ // Track files that have already been moved or deleted so we can roll back on failure.
+ $restored_files = array();
+
+ foreach ( $metadata['sizes'] as $size => $size_info ) {
+ if ( in_array( $size_info['file'], $restored_files, true ) ) {
+ continue;
+ }
+
+ $protected_thumb = $current_base_dir . DIRECTORY_SEPARATOR . $size_info['file'];
+ $public_thumb = $target_base_dir . DIRECTORY_SEPARATOR . $size_info['file'];
+
+ if ( ! $wp_filesystem->exists( $protected_thumb ) ) {
+ error_log( 'Registered metadata thumbnail file does not exist. Skipping. ' . $protected_thumb );
+ continue;
+ }
+
+ if ( $wp_filesystem->exists( $public_thumb ) ) {
+ if ( $this->files_are_identical( $protected_thumb, $public_thumb ) ) {
+ $wp_filesystem->delete( $protected_thumb );
+ } else {
+ // Roll back: move the main file back to the protected dir.
+ $wp_filesystem->move( $target_file, $current_file );
+ foreach ( $restored_files as $restored ) {
+ $pt = $current_base_dir . DIRECTORY_SEPARATOR . $restored;
+ $tt = $target_base_dir . DIRECTORY_SEPARATOR . $restored;
+ if ( $wp_filesystem->exists( $tt ) ) {
+ $wp_filesystem->move( $tt, $pt );
+ }
+ }
+ // translators: %1$s is the conflicting thumbnail file, %2$d is the attachment ID.
+ error_log( sprintf( 'Unable to unprotect attachment %2$d: a different file already exists at %1$s.', $public_thumb, $attachment_id ) );
+ return new WP_Error(
+ 'llms_media_unprotect_conflict',
+ sprintf(
+ // translators: %s is the conflicting public thumbnail path.
+ __( 'A different file already exists at the original thumbnail location (%s). Please rename or remove it before unprotecting this media.', 'lifterlms' ),
+ $public_thumb
+ )
+ );
+ }
+ } else {
+ if ( ! $wp_filesystem->move( $protected_thumb, $public_thumb ) ) {
+ // Roll back: move the main file back to the protected dir.
+ $wp_filesystem->move( $target_file, $current_file );
+ foreach ( $restored_files as $restored ) {
+ $pt = $current_base_dir . DIRECTORY_SEPARATOR . $restored;
+ $tt = $target_base_dir . DIRECTORY_SEPARATOR . $restored;
+ if ( $wp_filesystem->exists( $tt ) ) {
+ $wp_filesystem->move( $tt, $pt );
+ }
+ }
+ // translators: %1$s is the protected thumbnail, %2$s is the target public thumbnail.
+ error_log( sprintf( 'Unable to move protected thumbnail back to public dir: %1$s to %2$s', $protected_thumb, $public_thumb ) );
+ return false;
+ }
+ }
+
+ $restored_files[] = $size_info['file'];
+ }
+ }
+
+ // Update attachment location in database.
+ update_attached_file( $attachment_id, $target_file );
+
+ // Restore the original metadata file path, if it was prefixed with the protected directory.
+ if ( ! empty( $metadata ) && array_key_exists( 'file', $metadata ) ) {
+ $protected_dir = $protector->get_upload_basedir();
+ $prefix = ltrim( $protected_dir, '/' );
+ if ( 0 === strpos( $metadata['file'], $prefix ) ) {
+ $metadata['file'] = substr( $metadata['file'], strlen( $prefix ) );
+ wp_update_attachment_metadata( $attachment_id, $metadata );
+ }
+ }
+
+ // Remove the authorization meta and clear the recorded original location.
+ $protector->remove_authorization_meta_from_media_post( $attachment_id );
+ delete_post_meta( $attachment_id, LLMS_Media_Protector::ORIGINAL_FILE_META_KEY );
+
+ return true;
+ }
+
+ /**
+ * Record the original public location of a media attachment before it is moved to the protected directory.
+ *
+ * @since [version]
+ *
+ * @param int $attachment_id The attachment post ID.
+ * @param array $metadata The current attachment metadata.
+ * @param string $file The current `_wp_attached_file` absolute path.
+ * @return void
+ */
+ protected function store_original_attached_file( $attachment_id, $metadata, $file ) {
+ $relative = '';
+
+ // Prefer the relative path stored in `_wp_attached_file` so it stays accurate even
+ // if the upload base directory has been customized.
+ $attached = get_post_meta( $attachment_id, '_wp_attached_file', true );
+ if ( $attached ) {
+ $relative = ltrim( $attached, '/' );
+ }
+
+ if ( ! $relative ) {
+ return;
+ }
+
+ update_post_meta( $attachment_id, LLMS_Media_Protector::ORIGINAL_FILE_META_KEY, $relative );
+ }
+
+ /**
+ * Returns true if the two files exist and have the same size and md5 hash.
+ *
+ * @since [version]
+ *
+ * @param string $file_a First file path.
+ * @param string $file_b Second file path.
+ * @return bool
+ */
+ protected function files_are_identical( $file_a, $file_b ) {
+ if ( ! file_exists( $file_a ) || ! file_exists( $file_b ) ) {
+ return false;
+ }
+
+ if ( filesize( $file_a ) !== filesize( $file_b ) ) {
+ return false;
+ }
+
+ // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_md5
+ return md5_file( $file_a ) === md5_file( $file_b );
+ }
}
new LLMS_Admin_Media_Protection_Attachment_Settings();
diff --git a/includes/class-llms-media-protector.php b/includes/class-llms-media-protector.php
index d4ec5981a3..4aa1633bcd 100644
--- a/includes/class-llms-media-protector.php
+++ b/includes/class-llms-media-protector.php
@@ -5,7 +5,7 @@
* @package LifterLMS/Classes
*
* @since 7.7.0
- * @version 7.7.0
+ * @version [version]
*/
defined( 'ABSPATH' ) || exit;
@@ -53,6 +53,22 @@ class LLMS_Media_Protector {
*/
public const AUTHORIZATION_FILTER_KEY = '_llms_media_authorization_filter';
+ /**
+ * The meta key used to record the original public location of a media file before it was moved to the protected
+ * uploads directory.
+ *
+ * The value is the original `_wp_attached_file` relative path (e.g. `2023/01/image.jpg`). It is used to restore the
+ * file's public location when the file is unprotected.
+ *
+ * The key is protected by prefixing it with an underscore '_', which causes WordPress to not display it in
+ * a custom fields interface. {@see is_protected_meta()}.
+ *
+ * @since [version]
+ *
+ * @var string
+ */
+ public const ORIGINAL_FILE_META_KEY = '_llms_media_protection_original_file';
+
/**
* Serve the media file by reading and outputting it with the readfile() function.
*
@@ -1041,4 +1057,40 @@ public function add_authorization_meta_to_media_post( $post_id, $hook_name = 'll
update_post_meta( $post_id, self::AUTHORIZATION_FILTER_KEY, $hook_name );
}
+
+ /**
+ * Remove the authorization meta from the post.
+ *
+ * Also clears the per-request media authorization cache for this attachment so that any URLs that
+ * were previously authorized are immediately invalidated for the current user.
+ *
+ * @since [version]
+ *
+ * @param int $post_id The attachment post ID.
+ * @return void
+ */
+ public function remove_authorization_meta_from_media_post( $post_id ) {
+ if ( ! is_numeric( $post_id ) ) {
+ return;
+ }
+
+ delete_post_meta( $post_id, self::AUTHORIZATION_FILTER_KEY );
+ wp_cache_delete( 'llms-media-authorization-' . $post_id . '-' . get_current_user_id(), 'llms_media_authorization' );
+ }
+
+ /**
+ * Returns the original public `_wp_attached_file` relative path recorded before the file was protected.
+ *
+ * @since [version]
+ *
+ * @param int $media_id The attachment post ID.
+ * @return string The original relative path, or an empty string if none was recorded.
+ */
+ public function get_original_attached_file( $media_id ) {
+ if ( ! is_numeric( $media_id ) || ! intval( $media_id ) ) {
+ return '';
+ }
+
+ return (string) get_post_meta( $media_id, self::ORIGINAL_FILE_META_KEY, true );
+ }
}
diff --git a/includes/class-llms-rest-fields.php b/includes/class-llms-rest-fields.php
index 986848eb29..4b0f71e5b5 100644
--- a/includes/class-llms-rest-fields.php
+++ b/includes/class-llms-rest-fields.php
@@ -118,26 +118,66 @@ private function register_fields_for_attachments() {
return get_post_meta( $object['id'], '_llms_media_protection_product_id', true );
},
'update_callback' => function ( $value, $object ) {
- $settings = new LLMS_Admin_Media_Protection_Attachment_Settings();
+ $settings = new LLMS_Admin_Media_Protection_Attachment_Settings();
$protector = new LLMS_Media_Protector();
+ // An empty value means "unprotect this media". The clear action is also
+ // available for already-unprotected files (which is a no-op).
+ if ( empty( $value ) ) {
+ if ( $protector->is_media_protected( $object->ID ) ) {
+ $result = $settings->move_attachment_to_public_dir( $object->ID );
+ if ( true === $result ) {
+ delete_post_meta( $object->ID, '_llms_media_protection_product_id' );
+ }
+ return $result;
+ }
+
+ delete_post_meta( $object->ID, '_llms_media_protection_product_id' );
+ return true;
+ }
+
if ( $protector->is_media_protected( $object->ID ) ) {
update_post_meta( $object->ID, '_llms_media_protection_product_id', absint( $value ) );
- return;
+ return true;
}
if ( $settings->move_attachment_to_protected_dir( $object->ID ) ) {
update_post_meta( $object->ID, '_llms_media_protection_product_id', absint( $value ) );
+ return true;
}
+
+ return new WP_Error(
+ 'llms_media_protection_failed',
+ __( 'Failed to move the media file to the protected directory.', 'lifterlms' )
+ );
},
'schema' => array(
- 'description' => __( 'The ID of the product that protects this media.', 'lifterlms' ),
+ 'description' => __( 'The ID of the product that protects this media. Set to an empty value to unprotect the media.', 'lifterlms' ),
'type' => 'integer',
'context' => array( 'view', 'edit' ),
),
)
);
+
+ // Read-only field exposing the media's original public location so that the block editor
+ // can render the "Originally located in" note without a second request.
+ register_rest_field(
+ 'attachment',
+ '_llms_media_protection_original_file',
+ array(
+ 'get_callback' => function ( $object ) {
+ $protector = new LLMS_Media_Protector();
+ return $protector->get_original_attached_file( $object['id'] );
+ },
+ 'schema' => array(
+ 'description' => __( 'The original public location of the media file before it was protected, relative to the uploads directory.', 'lifterlms' ),
+ 'type' => 'string',
+ 'context' => array( 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ )
+ );
}
/**
diff --git a/src/js/admin-media-protection-block-protect.js b/src/js/admin-media-protection-block-protect.js
index 2673b526e8..044e66c637 100644
--- a/src/js/admin-media-protection-block-protect.js
+++ b/src/js/admin-media-protection-block-protect.js
@@ -54,16 +54,30 @@
const [ isModalOpen, setModalOpen ] = useState( false );
const [ productTitle, setProductTitle ] = useState( null );
+ const [ originalFile, setOriginalFile ] = useState( '' );
+ const [ showUnprotectWarning, setShowUnprotectWarning ] = useState( false );
const selectRef = useRef( null );
useEffect( () => {
if ( isModalOpen && selectRef.current ) {
jQuery( selectRef.current ).llmsPostsSelect2();
+ const handleChange = function() {
+ const val = jQuery( this ).val();
+ setShowUnprotectWarning( ! val );
+ };
+ const $el = jQuery( selectRef.current );
+ $el.on( 'change', handleChange );
+ return () => {
+ $el.off( 'change', handleChange );
+ };
}
}, [ isModalOpen ] );
const handleProtectImage = () => {
- const selectedId = jQuery( selectRef.current ).val();
+ const rawValue = jQuery( selectRef.current ).val();
+ // An empty string from a cleared select2 means "unprotect this media".
+ // The REST update callback treats an empty value as a request to unprotect.
+ const selectedId = rawValue && '' !== rawValue ? rawValue : 0;
apiFetch( {
path: `/wp/v2/media/${ props.attributes.id }`,
@@ -75,6 +89,8 @@
const urlAttr = getUrlAttr( props.name );
props.setAttributes( { [ urlAttr ]: updatedMedia.source_url } );
setProductTitle( null );
+ setOriginalFile( '' );
+ setShowUnprotectWarning( false );
} ).catch( ( err ) => {
console.error( 'Error updating media meta:', err );
} );
@@ -93,9 +109,12 @@
const productId = media._llms_media_protection_product_id;
if ( ! productId ) {
setProductTitle( null ); // media isn’t protected yet
+ setOriginalFile( '' );
return;
}
+ setOriginalFile( media._llms_media_protection_original_file || '' );
+
const tryEndpoints = [ 'courses', 'memberships' ];
( async () => {
for ( const type of tryEndpoints ) {
@@ -115,6 +134,7 @@
.catch( ( er ) => {
console.error( 'Unable to read media meta', er );
setProductTitle( null );
+ setOriginalFile( '' );
} );
}, [ isModalOpen, props.attributes.id ] );
@@ -151,7 +171,7 @@
ref={ selectRef }
className='llms-block-protect llms-posts-select2'
data-no-view-button='true'
- data-allow_clear='false'
+ data-allow_clear='true'
data-post-type='course,llms_membership'
>
@@ -161,6 +181,19 @@
{ productTitle }
) }
+ { originalFile && (
+
+ { LLMS.l10n.translate( 'Originally located in:' ) }
+ wp-content/uploads/{ originalFile }
+
+ ) }
+ { showUnprotectWarning && originalFile && (
+
+
+ { LLMS.l10n.translate( 'Clearing the selected product will move this file back to its original public location. Existing protected links to this media will no longer work.' ) }
+
+
+ ) }
diff --git a/tests/phpunit/unit-tests/class-llms-test-admin-media-protection-attachment-settings.php b/tests/phpunit/unit-tests/class-llms-test-admin-media-protection-attachment-settings.php
index 28e1aed13e..fa5d4c0fa1 100644
--- a/tests/phpunit/unit-tests/class-llms-test-admin-media-protection-attachment-settings.php
+++ b/tests/phpunit/unit-tests/class-llms-test-admin-media-protection-attachment-settings.php
@@ -8,6 +8,7 @@
* @group media_protection
*
* @since 10.0.0
+ * @version [version]
*/
class LLMS_Test_Admin_Media_Protection_Attachment_Settings extends LLMS_UnitTestCase {
@@ -64,4 +65,191 @@ public function test_attachment_fields_to_edit_allows_media_protection_field_fil
}
+ /**
+ * Test that the select2 markup allows clearing and shows the original location when the file is protected.
+ *
+ * @since [version]
+ *
+ * @return void
+ */
+ public function test_attachment_fields_to_edit_allows_clearing_when_protected() {
+
+ $attachment_id = $this->factory->post->create( array( 'post_type' => 'attachment' ) );
+ $attachment = get_post( $attachment_id );
+
+ // Mark the attachment as protected with a recorded original location.
+ update_post_meta( $attachment_id, LLMS_Media_Protector::AUTHORIZATION_FILTER_KEY, 'llms_attachment_is_access_allowed' );
+ update_post_meta( $attachment_id, LLMS_Media_Protector::ORIGINAL_FILE_META_KEY, '2024/05/test.jpg' );
+
+ $settings = new LLMS_Admin_Media_Protection_Attachment_Settings();
+ $fields = $settings->attachment_fields_to_edit( array(), $attachment );
+
+ $this->assertStringContainsString( "data-allow_clear='true'", $fields['llms_media_protection_post']['html'] );
+ $this->assertStringContainsString( 'llms-media-protection-original-location', $fields['llms_media_protection_post']['html'] );
+ $this->assertStringContainsString( '2024/05/test.jpg', $fields['llms_media_protection_post']['html'] );
+ $this->assertStringContainsString( 'llms-media-protection-unprotect-warning', $fields['llms_media_protection_post']['html'] );
+
+ }
+
+ /**
+ * Test that the select2 markup does not include the original-location note when the file is not protected.
+ *
+ * @since [version]
+ *
+ * @return void
+ */
+ public function test_attachment_fields_to_edit_omits_original_location_when_unprotected() {
+
+ $attachment_id = $this->factory->post->create( array( 'post_type' => 'attachment' ) );
+ $attachment = get_post( $attachment_id );
+
+ $settings = new LLMS_Admin_Media_Protection_Attachment_Settings();
+ $fields = $settings->attachment_fields_to_edit( array(), $attachment );
+
+ $this->assertStringNotContainsString( 'llms-media-protection-original-location', $fields['llms_media_protection_post']['html'] );
+ $this->assertStringNotContainsString( 'llms-media-protection-unprotect-warning', $fields['llms_media_protection_post']['html'] );
+
+ }
+
+ /**
+ * Test that the unprotect method refuses to operate on an unprotected file.
+ *
+ * @since [version]
+ *
+ * @return void
+ */
+ public function test_move_attachment_to_public_dir_returns_false_for_unprotected_file() {
+
+ $attachment_id = $this->factory->post->create( array( 'post_type' => 'attachment' ) );
+
+ $settings = new LLMS_Admin_Media_Protection_Attachment_Settings();
+ $this->assertFalse( $settings->move_attachment_to_public_dir( $attachment_id ) );
+
+ }
+
+ /**
+ * Test that the unprotect method refuses to operate when no original location is recorded.
+ *
+ * @since [version]
+ *
+ * @return void
+ */
+ public function test_move_attachment_to_public_dir_returns_false_without_original_location() {
+
+ $attachment_id = $this->factory->post->create( array( 'post_type' => 'attachment' ) );
+ update_post_meta( $attachment_id, LLMS_Media_Protector::AUTHORIZATION_FILTER_KEY, 'llms_attachment_is_access_allowed' );
+
+ $settings = new LLMS_Admin_Media_Protection_Attachment_Settings();
+ $this->assertFalse( $settings->move_attachment_to_public_dir( $attachment_id ) );
+
+ }
+
+ /**
+ * Test the get_original_attached_file helper on the media protector class.
+ *
+ * @since [version]
+ *
+ * @return void
+ */
+ public function test_get_original_attached_file_returns_recorded_path() {
+
+ $attachment_id = $this->factory->post->create( array( 'post_type' => 'attachment' ) );
+ update_post_meta( $attachment_id, LLMS_Media_Protector::ORIGINAL_FILE_META_KEY, '2023/01/file.png' );
+
+ $protector = new LLMS_Media_Protector();
+ $this->assertEquals( '2023/01/file.png', $protector->get_original_attached_file( $attachment_id ) );
+
+ }
+
+ /**
+ * Test the get_original_attached_file helper returns an empty string when no path is recorded.
+ *
+ * @since [version]
+ *
+ * @return void
+ */
+ public function test_get_original_attached_file_returns_empty_when_missing() {
+
+ $attachment_id = $this->factory->post->create( array( 'post_type' => 'attachment' ) );
+
+ $protector = new LLMS_Media_Protector();
+ $this->assertEquals( '', $protector->get_original_attached_file( $attachment_id ) );
+
+ }
+
+ /**
+ * Test that the remove helper clears the authorization meta for a valid post ID.
+ *
+ * @since [version]
+ *
+ * @return void
+ */
+ public function test_remove_authorization_meta_clears_meta() {
+
+ $attachment_id = $this->factory->post->create( array( 'post_type' => 'attachment' ) );
+ update_post_meta( $attachment_id, LLMS_Media_Protector::AUTHORIZATION_FILTER_KEY, 'llms_attachment_is_access_allowed' );
+
+ $protector = new LLMS_Media_Protector();
+ $this->assertTrue( $protector->is_media_protected( $attachment_id ) );
+
+ $protector->remove_authorization_meta_from_media_post( $attachment_id );
+ $this->assertFalse( $protector->is_media_protected( $attachment_id ) );
+
+ }
+
+ /**
+ * Test that saving with an empty value unprotects the file when it was protected.
+ *
+ * @since [version]
+ *
+ * @return void
+ */
+ public function test_attachment_fields_to_save_unprotects_when_value_cleared() {
+
+ $attachment_id = $this->factory->post->create( array( 'post_type' => 'attachment' ) );
+
+ // Pre-mark the attachment as protected and set the original location meta.
+ update_post_meta( $attachment_id, LLMS_Media_Protector::AUTHORIZATION_FILTER_KEY, 'llms_attachment_is_access_allowed' );
+ update_post_meta( $attachment_id, LLMS_Media_Protector::ORIGINAL_FILE_META_KEY, '2024/01/file.jpg' );
+ update_post_meta( $attachment_id, '_llms_media_protection_product_id', 1 );
+
+ // Stub the unprotect method so we do not need an actual file on disk.
+ $stub = $this->getMockBuilder( 'LLMS_Admin_Media_Protection_Attachment_Settings' )
+ ->onlyMethods( array( 'move_attachment_to_public_dir' ) )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $stub->expects( $this->once() )
+ ->method( 'move_attachment_to_public_dir' )
+ ->with( $attachment_id )
+ ->willReturn( true );
+
+ $post = array( 'ID' => $attachment_id );
+ $attachment = array( 'llms_media_protection_post' => '' );
+ $stub->attachment_fields_to_save( $post, $attachment );
+
+ $product_meta = get_post_meta( $attachment_id, '_llms_media_protection_product_id', true );
+ $this->assertEmpty( $product_meta );
+
+ }
+
+ /**
+ * Test that saving with an empty value is a no-op for an already-unprotected file.
+ *
+ * @since [version]
+ *
+ * @return void
+ */
+ public function test_attachment_fields_to_save_does_nothing_when_unprotected_and_cleared() {
+
+ $attachment_id = $this->factory->post->create( array( 'post_type' => 'attachment' ) );
+
+ $settings = new LLMS_Admin_Media_Protection_Attachment_Settings();
+ $post = array( 'ID' => $attachment_id );
+ $result = $settings->attachment_fields_to_save( $post, array( 'llms_media_protection_post' => '' ) );
+
+ // Returns the post data untouched.
+ $this->assertEquals( $post, $result );
+
+ }
+
}