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' ), '', '' ) . '
'; + } else { + $original_file = $protector->get_original_attached_file( $post->ID ); + if ( $original_file ) { + $original_location_html = '
' . 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 = ''; + } } $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 ); + + } + }