diff --git a/includes/class-post-collection-integration.php b/includes/class-post-collection-integration.php index d8251f4..f64c668 100644 --- a/includes/class-post-collection-integration.php +++ b/includes/class-post-collection-integration.php @@ -391,18 +391,27 @@ private function render_list( $app, $collection, $compact = false ) { */ private function get_download_request() { $url_var = $this->send_to_e_reader->get_download_url_var(); + // The initial picker URL, and a POST with no checked boxes, carry the selection in GET. if ( ! isset( $_GET[ $url_var ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- public download URL with the password in the parameter name. return false; } $value = wp_unslash( $_GET[ $url_var ] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- validated below. - if ( is_array( $value ) ) { - $ids = array_values( array_filter( array_map( 'intval', $value ) ) ); - - return empty( $ids ) ? false : array( $ids, null ); + if ( ! is_string( $value ) ) { + return false; } $value = sanitize_key( $value ); + // Checked boxes are submitted in POST while the picker URL stays in GET. + if ( in_array( $value, array( 'list', 'compact' ), true ) && isset( $_POST[ $url_var ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- password in URL authorizes this download. + $posted_ids = wp_unslash( $_POST[ $url_var ] ); // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- cast to integers below. + if ( is_array( $posted_ids ) ) { + $ids = array_values( array_filter( array_map( 'intval', $posted_ids ) ) ); + if ( $ids ) { + return array( $ids, null ); + } + } + } $limit = null; if ( preg_match( '/^([a-z]+)-([0-9]+)$/', $value, $matches ) ) { diff --git a/includes/class-send-to-e-reader.php b/includes/class-send-to-e-reader.php index 2d4f893..254b07a 100644 --- a/includes/class-send-to-e-reader.php +++ b/includes/class-send-to-e-reader.php @@ -1112,27 +1112,26 @@ public function get_download_url_var() { public function enable_download_via_url( $viewable ) { $ereader_url_var = $this->get_download_url_var(); + // The initial picker URL, and a POST with no checked boxes, carry the selection in GET. if ( ! isset( $_GET[ $ereader_url_var ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- public download URL with password in parameter name. return $viewable; } $request_value = wp_unslash( $_GET[ $ereader_url_var ] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- validated via allowlist below. - if ( - ! is_array( $request_value ) - && ! in_array( - $request_value, - array( - 'new', - 'all', - 'last', - 'list', - 'compact', - ), - true - ) - ) { + if ( ! in_array( $request_value, array( 'new', 'all', 'last', 'list', 'compact' ), true ) ) { return $viewable; } + // Checked boxes are submitted in POST while the picker URL stays in GET. + if ( in_array( $request_value, array( 'list', 'compact' ), true ) && isset( $_POST[ $ereader_url_var ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- password in URL authorizes this download. + $posted_ids = wp_unslash( $_POST[ $ereader_url_var ] ); // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- cast to integers below. + if ( is_array( $posted_ids ) ) { + $ids = array_values( array_filter( array_map( 'intval', $posted_ids ) ) ); + if ( $ids ) { + $request_value = $ids; + } + } + } + self::prevent_response_caching(); $this->download_request = $request_value; return true; diff --git a/plain-list.js b/plain-list.js index 78d470e..9037dda 100644 --- a/plain-list.js +++ b/plain-list.js @@ -12,6 +12,11 @@ var count = document.querySelector('[data-send-to-e-reader-selection-count]'); var checkboxes = document.querySelectorAll('input[type="checkbox"]'); var selected = document.querySelectorAll('input[type="checkbox"]:checked').length; + var downloadButtons = document.querySelectorAll('form button[type="submit"]'); + + downloadButtons.forEach(function (button) { + button.disabled = selected === 0; + }); if (!count) { return; @@ -87,5 +92,11 @@ updateSelectionCount(); }); + document.addEventListener('submit', function (event) { + if (event.target.matches('form') && !event.target.querySelector('input[type="checkbox"]:checked')) { + event.preventDefault(); + } + }); + updateSelectionCount(); }()); diff --git a/templates/plain-list.php b/templates/plain-list.php index bc2cff6..787fea3 100644 --- a/templates/plain-list.php +++ b/templates/plain-list.php @@ -95,6 +95,11 @@ padding: 8px 14px; } + button:disabled { + cursor: default; + opacity: .5; + } + .header-controls, .list-actions { display: flex; @@ -108,6 +113,18 @@ flex: 0 1 360px; } + .list-actions { + gap: 0; + } + + .list-actions a { + margin-right: 10px; + } + + .list-actions a:last-child { + margin-right: 0; + } + .header-download { flex: 0 0 auto; } @@ -264,7 +281,7 @@
-
+

diff --git a/tests/Test_Post_Collection_Integration.php b/tests/Test_Post_Collection_Integration.php index fb06e62..0022c69 100644 --- a/tests/Test_Post_Collection_Integration.php +++ b/tests/Test_Post_Collection_Integration.php @@ -30,6 +30,7 @@ public function tearDown(): void { unset( $GLOBALS['send_to_e_reader_test_caps'] ); unset( $GLOBALS['send_to_e_reader_test_posts'] ); unset( $_GET['epubsecret'] ); + unset( $_POST['epubsecret'] ); parent::tearDown(); } @@ -243,8 +244,14 @@ public function test_the_download_url_only_accepts_known_selections() { $_GET['epubsecret'] = 'everything-3'; $this->assertFalse( $this->call( $integration, 'get_download_request' ) ); - $_GET['epubsecret'] = array( '4', '0', 'seven', '9' ); + $_GET['epubsecret'] = 'list'; + $this->assertSame( array( 'list', null ), $this->call( $integration, 'get_download_request' ) ); + $_POST['epubsecret'] = array( '4', '9' ); $this->assertSame( array( array( 4, 9 ), null ), $this->call( $integration, 'get_download_request' ) ); + unset( $_POST['epubsecret'] ); + + $_GET['epubsecret'] = array( '4', '0', 'seven', '9' ); + $this->assertFalse( $this->call( $integration, 'get_download_request' ) ); } /** diff --git a/tests/Test_Send_To_E_Reader.php b/tests/Test_Send_To_E_Reader.php index c601999..04c5927 100644 --- a/tests/Test_Send_To_E_Reader.php +++ b/tests/Test_Send_To_E_Reader.php @@ -30,6 +30,7 @@ public function tearDown(): void { unset( $GLOBALS['send_to_e_reader_test_scripts'] ); unset( $GLOBALS['send_to_e_reader_test_nocache_headers_sent'] ); unset( $_GET['epubsecret'] ); + unset( $_POST['epubsecret'] ); parent::tearDown(); } @@ -265,6 +266,8 @@ public function test_plain_list_shows_original_article_date() { $this->assertStringContainsString( 'Collected Link', $output ); $this->assertStringContainsString( 'June 29, 2026', $output ); $this->assertStringContainsString( '1 articles (1 selected)', $output ); + $this->assertStringContainsString( '', $output ); + $this->assertStringContainsString( 'name="epubsecret[]" value="456"', $output ); } /** @@ -305,6 +308,27 @@ public function test_compact_list_download_url_is_accepted() { $this->assertTrue( $send_to_e_reader->enable_download_via_url( false ) ); } + /** + * Test that a POST to the password-backed picker downloads selected posts only. + */ + public function test_picker_post_keeps_an_empty_selection_on_the_list() { + update_option( Send_To_E_Reader::DOWNLOAD_PASSWORD_OPTION, 'secret' ); + $_GET['epubsecret'] = 'list'; + $send_to_e_reader = new Send_To_E_Reader( null ); + $request = new \ReflectionProperty( Send_To_E_Reader::class, 'download_request' ); + $request->setAccessible( true ); + + $this->assertTrue( $send_to_e_reader->enable_download_via_url( false ) ); + $this->assertSame( 'list', $request->getValue( $send_to_e_reader ) ); + + $_POST['epubsecret'] = array( '4', '9' ); + $this->assertTrue( $send_to_e_reader->enable_download_via_url( false ) ); + $this->assertSame( array( 4, 9 ), $request->getValue( $send_to_e_reader ) ); + + $_GET['epubsecret'] = array( '4', '9' ); + $this->assertFalse( $send_to_e_reader->enable_download_via_url( false ) ); + } + /** * Test get_post_author_name returns author name for a post. */