Changeset 3794790
- Timestamp:
- 11/10/2026 04:14:48 PM (20 hours ago)
- Location:
- ebook-store/trunk
- Files:
-
- 6 edited
-
EbookStoreEbook.class.php (modified) (4 diffs)
-
ebook_store.php (modified) (1 diff)
-
fpdi/qrcode.class.php (modified) (2 diffs)
-
functions.php (modified) (6 diffs)
-
includes/ebook-store-bootstrap.php (modified) (1 diff)
-
readme.txt (modified) (3 diffs)
Legend:
- Unmodified
- Added
- Removed
-
ebook-store/trunk/EbookStoreEbook.class.php
r3794705 r3794790 347 347 // cache hit can skip generating it entirely. A copy built with other 348 348 // restriction settings is rebuilt instead (same password: it is 349 // stored on the order). 350 if (file_exists($ebook_pdf_path) && ! ebook_store_pdf_cache_is_stale($ebook_pdf_path)) { 349 // stored on the order), unless the source is gone, in which case the 350 // buyer keeps the copy they have. 351 $had_copy = file_exists($ebook_pdf_path); 352 if ($had_copy && (! ebook_store_pdf_cache_is_stale($ebook_pdf_path) || ! is_readable($this->files[$format]) || ! wp_is_writable($cache_dir))) { 351 353 return $ebook_pdf_path; 352 354 } … … 408 410 409 411 $pdf->applyProtection($password, $owner_password); 410 $pdf->Output($ebook_pdf_path, 'F'); 412 // Atomic: a failure leaves any previous copy untouched. 413 ebook_store_write_protected_pdf($pdf, $ebook_pdf_path); 411 414 } catch (\Throwable $e) { 412 // A half-written file must not be served as a cache hit next time.413 if (file_exists($ebook_pdf_path)) {414 @unlink($ebook_pdf_path);415 }416 415 if (function_exists('ebook_store_log')) { 417 416 ebook_store_log('pdf', 'PDF protection failed: ' . $e->getMessage(), array('order_id' => $order_id, 'ebook_id' => $this->ebook_id, 'file' => $this->files[$format])); 417 } 418 // Rebuilding an existing copy failed: the buyer keeps that copy. 419 if ($had_copy && file_exists($ebook_pdf_path)) { 420 return $ebook_pdf_path; 418 421 } 419 422 wp_die( esc_html( sprintf( … … 566 569 } 567 570 /** 568 * Protected PDF copies older than this timestamp are rebuilt on their next 569 * download. 570 * 571 * It is set the first time 6.29+ runs, because every copy made by 6.20-6.28 572 * carries inverted restrictions, and moved forward whenever a restriction 573 * setting changes, so a new setting reaches buyers who already have a copy. 574 * 575 * @return int Unix timestamp. 571 * A keyed fingerprint of the settings applyProtection() writes into a PDF: the 572 * four reader restrictions and the owner password. 573 * 574 * Every protected copy is stored with the fingerprint it was built with (a 575 * "<file>.protection" record next to it), so a copy is rebuilt exactly when 576 * these settings differ from the ones it carries. Copies made by 6.20-6.29 577 * have no record and are rebuilt once: up to 6.28 their restrictions came out 578 * inverted. 579 * 580 * @return string 576 581 */ 577 function ebook_store_get_pdf_cache_valid_after() { 578 $valid_after = get_option( 'ebook_store_pdf_cache_valid_after', null ); 579 if ( $valid_after === null || $valid_after === false || $valid_after === '' ) { 580 $valid_after = time(); 581 add_option( 'ebook_store_pdf_cache_valid_after', $valid_after ); 582 } 583 584 return (int) $valid_after; 582 function ebook_store_pdf_protection_fingerprint() { 583 $parts = array( 'ebook-store-pdf-protection-2' ); 584 foreach ( array( 'disable_pdf_printing', 'disable_pdf_copy', 'disable_pdf_modify', 'disable_annot-forms' ) as $option ) { 585 $parts[] = get_option( $option ) == 1 ? '1' : '0'; 586 } 587 $parts[] = (string) get_option( 'ebook_store_owner_password' ); 588 589 // Keyed, so the record does not expose the owner password to offline guessing. 590 return hash_hmac( 'sha256', implode( '|', $parts ), wp_salt( 'auth' ) ); 585 591 } 586 592 587 593 /** 588 * Was this cached protected PDF built before the current restriction settings? 594 * @param string $file Protected PDF copy. 595 * @return string Path of the record holding the fingerprint it was built with. 596 */ 597 function ebook_store_pdf_fingerprint_path( $file ) { 598 return $file . '.protection'; 599 } 600 601 /** 602 * Was this protected copy built with other restriction settings than the 603 * current ones (or before copies recorded them)? 589 604 * 590 605 * @param string $file Path of a per-order protected copy. … … 596 611 } 597 612 598 $ built = @filemtime( $file);599 600 return $built !== false && $built < ebook_store_get_pdf_cache_valid_after();613 $recorded = @file_get_contents( ebook_store_pdf_fingerprint_path( $file ) ); 614 615 return ! is_string( $recorded ) || ! hash_equals( ebook_store_pdf_protection_fingerprint(), trim( $recorded ) ); 601 616 } 602 617 603 618 /** 604 * Mark every cached protected PDF as outdated. Hooked to the restriction and 605 * owner-password settings. 619 * Write a finished protected PDF to its final path atomically and record the 620 * settings it was built with. 621 * 622 * The file is written next to the destination and renamed over it, so a 623 * download that is streaming the previous copy, or arrives meanwhile, never 624 * sees a half-written file, and a failed build leaves the previous copy intact. 625 * 626 * @param QRPDF $pdf Document with protection applied. 627 * @param string $dest Final path. 628 * @throws RuntimeException When the file cannot be written or moved into place. 606 629 */ 607 function ebook_store_invalidate_pdf_cache() { 608 update_option( 'ebook_store_pdf_cache_valid_after', time() ); 609 } 610 foreach ( array( 'disable_pdf_printing', 'disable_pdf_copy', 'disable_pdf_modify', 'disable_annot-forms', 'ebook_store_owner_password' ) as $ebook_store_protection_option ) { 611 add_action( 'update_option_' . $ebook_store_protection_option, 'ebook_store_invalidate_pdf_cache' ); 612 add_action( 'add_option_' . $ebook_store_protection_option, 'ebook_store_invalidate_pdf_cache' ); 613 } 614 unset( $ebook_store_protection_option ); 630 function ebook_store_write_protected_pdf( $pdf, $dest ) { 631 // Fixed-length name: prefixing the destination's basename could exceed the 632 // file-name limit for books whose source PDF has a long name. 633 $tmp = trailingslashit( dirname( $dest ) ) . '.tmp-' . wp_generate_password( 12, false, false ) . '.pdf'; 634 635 try { 636 // Write the bytes here rather than through Output( 'F' ), which ignores a 637 // short write: on a full disk a truncated file would replace a good copy. 638 $bytes = (string) $pdf->Output( '', 'S' ); 639 if ( strncmp( $bytes, '%PDF-', 5 ) !== 0 ) { 640 throw new RuntimeException( 'The protected PDF could not be generated.' ); 641 } 642 $written = @file_put_contents( $tmp, $bytes, LOCK_EX ); 643 if ( $written !== strlen( $bytes ) ) { 644 throw new RuntimeException( 'The protected PDF could not be written completely.' ); 645 } 646 // rename() replaces the destination atomically, also on Windows. If it 647 // fails, the previous copy stays exactly as it was. 648 if ( ! @rename( $tmp, $dest ) ) { 649 throw new RuntimeException( 'The protected PDF could not be moved into place.' ); 650 } 651 } finally { 652 if ( file_exists( $tmp ) ) { 653 @unlink( $tmp ); 654 } 655 } 656 657 $fingerprint = ebook_store_pdf_protection_fingerprint(); 658 if ( @file_put_contents( ebook_store_pdf_fingerprint_path( $dest ), $fingerprint, LOCK_EX ) !== strlen( $fingerprint ) && function_exists( 'ebook_store_log' ) ) { 659 // The copy is fine, only unrecorded: it will be rebuilt on its next use. 660 ebook_store_log( 'pdf', 'Could not record the protection settings of a PDF copy.', array( 'file' => basename( $dest ) ) ); 661 } 662 } 663 664 /** 665 * Unprotected source PDF of an ebook, or '' when it is missing or cannot be 666 * processed for protection. 667 * 668 * @param int $ebook_id Ebook post ID. 669 * @return string 670 */ 671 function ebook_store_get_rebuildable_master_pdf( $ebook_id ) { 672 $attachment = get_post_meta( absint( $ebook_id ), 'ebook_wp_custom_attachment', true ); 673 $master = ( is_array( $attachment ) && ! empty( $attachment['file'] ) ) ? (string) $attachment['file'] : ''; 674 675 if ( $master === '' || ! is_readable( $master ) || ! function_exists( 'ebook_store_encryptable' ) || ! ebook_store_encryptable( $master ) ) { 676 return ''; 677 } 678 679 return $master; 680 } 681 682 /** 683 * The protected copy to deliver: rebuilt with the current settings when it 684 * was made with other ones, otherwise (or if the rebuild is impossible or 685 * fails) the existing protected copy. Never the unprotected master. 686 * 687 * With PDF protection switched off nothing is rebuilt, as before. 688 * 689 * @param string $file Existing per-order protected copy. 690 * @param int $order_id ebook_order post ID or WooCommerce order ID. 691 * @param int $ebook_id Ebook post ID. 692 * @return string 693 */ 694 function ebook_store_refresh_protected_pdf( $file, $order_id, $ebook_id ) { 695 if ( ! get_option( 'encrypt_pdf' ) || ! ebook_store_pdf_cache_is_stale( $file ) ) { 696 return $file; 697 } 698 699 $order_id = absint( $order_id ); 700 $keep = function ( $reason ) use ( $file, $order_id, $ebook_id ) { 701 if ( function_exists( 'ebook_store_log' ) ) { 702 ebook_store_log( 'pdf', 'Outdated protected copy kept: ' . $reason, array( 'order_id' => $order_id, 'ebook_id' => $ebook_id ) ); 703 } 704 return $file; 705 }; 706 707 // Rebuild only for an order that still exists and carries its password, so 708 // the new copy has the buyer's details and opens with the same password. 709 $is_plugin_order = get_post_type( $order_id ) === 'ebook_order'; 710 $is_wc_order = ! $is_plugin_order && function_exists( 'wc_get_order' ) && is_a( wc_get_order( $order_id ), 'WC_Order' ); 711 if ( ( ! $is_plugin_order && ! $is_wc_order ) || ! metadata_exists( 'post', $order_id, 'password' ) ) { 712 return $keep( 'the order or its stored password no longer exists.' ); 713 } 714 715 // Only the order's own protected copy is replaced, in its own directory. 716 $cache_dir = ebook_store_get_cache_dir( $order_id ); 717 if ( $cache_dir === '' || wp_normalize_path( dirname( $file ) ) !== wp_normalize_path( $cache_dir ) || ! wp_is_writable( $cache_dir ) ) { 718 return $keep( 'the protected directory is unavailable or not writable.' ); 719 } 720 721 $master = ebook_store_get_rebuildable_master_pdf( $ebook_id ); 722 if ( $master === '' ) { 723 return $keep( 'the source PDF is missing or cannot be processed.' ); 724 } 725 726 try { 727 if ( $is_plugin_order ) { 728 $rebuilt = ebook_store_rebuild_order_pdf( $order_id, $master ); 729 } else { 730 $ebook = new EbookStoreEbook( $ebook_id ); 731 $rebuilt = $ebook->encrypted( $order_id, 'pdf' ); 732 } 733 } catch ( \Throwable $e ) { 734 return $keep( 'rebuild failed: ' . $e->getMessage() ); 735 } 736 737 if ( ! is_string( $rebuilt ) || $rebuilt === '' || ! file_exists( $rebuilt ) || wp_normalize_path( dirname( $rebuilt ) ) !== wp_normalize_path( $cache_dir ) ) { 738 return $keep( 'the rebuild did not produce a copy in the protected directory.' ); 739 } 740 741 return $rebuilt; 742 } 615 743 616 744 /** -
ebook-store/trunk/ebook_store.php
r3794705 r3794790 8 8 Domain Path: /languages 9 9 Author URI:https://www.shopfiles.com/index.php/products/wordpress-ebook-store 10 Version: 6. 2910 Version: 6.30 11 11 Requires at least: 5.8 12 12 Requires PHP: 7.4 -
ebook-store/trunk/fpdi/qrcode.class.php
r3794705 r3794790 191 191 192 192 /** 193 * Throw instead of die(). The bundled TCPDF config sets 194 * K_TCPDF_THROW_EXCEPTION_ERROR to false, so its errors ended the request 195 * and no caller could fall back to the buyer's existing copy. 196 * 197 * @param string $msg Error message. 198 * @throws RuntimeException Always. 199 */ 200 public function Error($msg) { 201 $this->_destroy(true); 202 throw new RuntimeException('TCPDF ERROR: ' . $msg); 203 } 204 205 /** 193 206 * Apply the reader restrictions and passwords, mapping the plugin's options 194 207 * to TCPDF's permission model. … … 215 228 if (get_option('disable_annot-forms') == 1) { 216 229 $blocked = array_merge($blocked, array('annot-forms', 'fill-forms')); 217 }218 219 // Record the cache baseline before the first file is written, so a copy220 // built now is not mistaken for one made with older settings.221 if (function_exists('ebook_store_get_pdf_cache_valid_after')) {222 ebook_store_get_pdf_cache_valid_after();223 230 } 224 231 -
ebook-store/trunk/functions.php
r3794705 r3794790 5345 5345 5346 5346 } 5347 // A protected copy built with other restriction settings (every copy made5348 // by 6.20-6.28, whose restrictions came out inverted) is rebuilt with the5349 // current ones. The buyer keeps the same password: it is stored on the order.5350 if ( $wants_order_pdf && get_post_type( $order_id ) === 'ebook_order' && get_option( 'encrypt_pdf' )5351 && ebook_store_pdf_cache_is_stale( (string) get_post_meta( $order_id, 'encrypted_pdf', true ) ) ) {5352 ebook_store_rebuild_order_pdf( $order_id, $requested_file );5353 }5354 5347 if (get_post_meta($order_id,'encrypted_pdf',true) != '' && $_REQUEST['type'] != 'bonus') { 5355 5348 $requested_file = get_post_meta($order_id,'encrypted_pdf',true); … … 5417 5410 if ($strtotime !== false && $strtotime > $gmt_timestamp && $strtotime < time()) { 5418 5411 wp_die('Oops, the link you are using has expired, you need to order again.'); 5412 } 5413 5414 // A plugin order's protected copy made with other restriction settings 5415 // (every copy made by 6.20-6.28, whose restrictions came out inverted) is 5416 // rebuilt with the current ones, now that the order is known to be 5417 // eligible. The password stays the one stored on the order, and if the 5418 // rebuild is impossible the existing protected copy is served. 5419 if ( $wants_order_pdf && get_post_type( $order_id ) === 'ebook_order' 5420 && $requested_file !== '' && $requested_file === (string) get_post_meta( $order_id, 'encrypted_pdf', true ) ) { 5421 $requested_file = ebook_store_refresh_protected_pdf( $requested_file, $order_id, $ebook ); 5419 5422 } 5420 5423 … … 6816 6819 $attachment_file = (string) get_post_meta( $order_id, 'encrypted_epub', true ); 6817 6820 } 6818 // An outdated protected copy (other restriction settings) is rebuilt below too. 6819 if ( $attachment_file === '' || ! file_exists( $attachment_file ) || ebook_store_pdf_cache_is_stale( $attachment_file ) ) { 6821 // An existing protected copy made with other restriction settings is rebuilt; 6822 // if that is impossible the existing protected copy is sent, never the master. 6823 if ( $attachment_file !== '' && file_exists( $attachment_file ) ) { 6824 $attachment_file = ebook_store_refresh_protected_pdf( $attachment_file, $order_id, $ebook_id ); 6825 } 6826 if ( $attachment_file === '' || ! file_exists( $attachment_file ) ) { 6820 6827 $master_attachment = ebook_attachment( $ebook_id, true ); 6821 6828 $attachment_file = isset( $master_attachment[0]['file'] ) ? (string) $master_attachment[0]['file'] : ''; … … 7102 7109 ) 7103 7110 ); 7111 7112 // A protected copy made with other restriction settings is rebuilt before 7113 // it is sent again; if that is impossible the logged copy is sent as before. 7114 $log_order_id = absint( get_post_meta( $log_id, 'related_order_id', true ) ); 7115 $log_ebook_id = absint( get_post_meta( $log_id, 'ebook_id', true ) ); 7116 if ( $log_order_id && $log_ebook_id ) { 7117 foreach ( $attachments as $index => $path ) { 7118 $attachments[ $index ] = ebook_store_refresh_protected_pdf( $path, $log_order_id, $log_ebook_id ); 7119 } 7120 } 7104 7121 7105 7122 $mail_result = wp_mail( $recipient, $subject, $body_html, $headers, $attachments ); … … 8247 8264 8248 8265 $pdf->applyProtection($password, $owner_password); 8249 $pdf->Output($destfile, 'F'); 8266 // Atomic: a download streaming the previous copy, or a failed build, 8267 // never leaves a half-written file at $destfile. 8268 ebook_store_write_protected_pdf($pdf, $destfile); 8250 8269 $generated = file_exists($destfile); 8251 8270 } finally { … … 8302 8321 $previous = $GLOBALS['ebook_store_pdf_in_progress'][ $order_id ]; 8303 8322 unset( $GLOBALS['ebook_store_pdf_in_progress'][ $order_id ] ); 8323 if ( $previous === 'in_progress' ) { 8324 // Another request was already building this copy when this one began. 8325 // That request owns the marker and the result; clearing them here could 8326 // leave the order with no protected copy at all. 8327 return; 8328 } 8329 if ( (string) get_post_meta( $order_id, 'encrypted_pdf', true ) !== 'in_progress' ) { 8330 // A parallel build finished meanwhile and recorded its copy: keep it. 8331 return; 8332 } 8304 8333 delete_post_meta( $order_id, 'encrypted_pdf_started' ); 8305 if ( $previous !== '' && $previous !== 'in_progress') {8334 if ( $previous !== '' ) { 8306 8335 update_post_meta( $order_id, 'encrypted_pdf', wp_slash( $previous ) ); 8307 8336 } else { -
ebook-store/trunk/includes/ebook-store-bootstrap.php
r3774772 r3794790 141 141 if ( ( $installed === '' || version_compare( $installed, '6.27', '<' ) ) && ! wp_next_scheduled( 'ebook_store_license_plan_refresh' ) ) { 142 142 wp_schedule_single_event( time() + 5, 'ebook_store_license_plan_refresh' ); 143 } 144 145 // 6.29 dated protected copies with this option; 6.30 records the settings 146 // next to each copy instead. 147 if ( $installed !== '' && version_compare( $installed, '6.30', '<' ) ) { 148 delete_option( 'ebook_store_pdf_cache_valid_after' ); 143 149 } 144 150 -
ebook-store/trunk/readme.txt
r3794705 r3794790 5 5 Tested up to: 7.1.4 6 6 Requires PHP: 7.4 7 Stable tag: 6. 297 Stable tag: 6.30 8 8 License: GPLv2 or later 9 9 License URI: http://www.gnu.org/licenses/gpl-2.0.html … … 218 218 == Changelog == 219 219 220 = 6.30 = 221 * Follow-up to 6.29. If an outdated protected copy cannot be rebuilt (for example, the ebook's original PDF was removed or replaced with one that cannot be processed), the buyer now keeps receiving the copy they already had. In 6.29 the download failed, and on WooCommerce stores that copy was deleted. 222 * Protected copies are now written to a temporary file and swapped in, so two downloads at the same moment can no longer receive a broken PDF. 223 * Each protected copy records the restriction settings it was made with, so it is rebuilt exactly when those settings change, including after a site move that resets file dates. Resending from the email log now rebuilds outdated copies too. 224 * An admin resend never attaches the unprotected original instead of the buyer's copy. Nothing is rebuilt while PDF protection is switched off. 225 220 226 = 6.29 = 221 227 * Fixed: PDF reader restrictions were applied in reverse since 6.20. With every restriction switched off, buyers received a PDF that could not be printed, copied, edited or annotated; with every restriction switched on, the PDF had no restrictions at all. Each setting now does what it says. … … 245 251 * PayPal: new REST API checkout with OAuth, signed webhooks and refunds from the order screen. PayPal is retiring the Buy Now form and IPN in January 2027. Stores without REST credentials keep using the old form until they switch. 246 252 247 = 6.24 =248 * Stripe: one order per payment even when the thank-you page and the webhook arrive together; the checkout session is created on click, not on page load.249 * Adyen: recoverable webhook failures are redelivered instead of dropped; payment-link records live as long as the link.250 * WooCommerce: download allowance counted per ebook, "Reset downloads" order action, "Read online" serves the buyer's protected copy.251 * PayPal (legacy form): price, currency and tax are signed and verified. Free-download email form enforced server-side. Free and donation ebooks are no longer switched to paid by a licence-check hiccup.252 253 253 Earlier versions: see the full changelog at https://plugins.trac.wordpress.org/browser/ebook-store/tags/6.24/readme.txt 254 254 255 255 == Upgrade Notice == 256 257 = 6.30 = 258 Makes the 6.29 PDF restriction fix safe in rare cases: a copy that cannot be rebuilt is kept instead of lost, and simultaneous downloads can no longer get a broken file. Recommended for everyone on 6.29. 256 259 257 260 = 6.29 =
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)