Plugin Directory

Changeset 3794790


Ignore:
Timestamp:
11/10/2026 04:14:48 PM (20 hours ago)
Author:
motov.net
Message:

6.30: make the 6.29 protected-PDF rebuild safe. Each copy records a keyed fingerprint of the restriction settings it was built with (<copy>.protection) instead of relying on file dates; copies are written to a temp file with a length check and renamed into place; TCPDF errors throw instead of die(); a rebuild only happens for an existing order with its stored password, in that order's writable protected directory, and on any failure (missing/corrupt source, unwritable directory, write error) the buyer keeps the existing protected copy. Admin resend never attaches the master; email-log Resend rebuilds outdated copies; plugin-order rebuild runs after the eligibility checks; restore_pdf_marker no longer clears a parallel build's result.

Location:
ebook-store/trunk
Files:
6 edited

Legend:

Unmodified
Added
Removed
  • ebook-store/trunk/EbookStoreEbook.class.php

    r3794705 r3794790  
    347347                        // cache hit can skip generating it entirely. A copy built with other
    348348                        // 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))) {
    351353                                return $ebook_pdf_path;
    352354                        }
    … …  
    408410
    409411                                $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);
    411414                        } 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                                 }
    416415                                if (function_exists('ebook_store_log')) {
    417416                                        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;
    418421                                }
    419422                                wp_die( esc_html( sprintf(
    … …  
    566569}
    567570/**
    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
    576581 */
    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;
     582function 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' ) );
    585591}
    586592
    587593/**
    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 */
     597function 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)?
    589604 *
    590605 * @param string $file Path of a per-order protected copy.
    … …  
    596611        }
    597612
    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 ) );
    601616}
    602617
    603618/**
    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.
    606629 */
    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 );
     630function 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 */
     671function 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 */
     694function 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}
    615743
    616744/**
  • ebook-store/trunk/ebook_store.php

    r3794705 r3794790  
    88Domain Path: /languages
    99Author URI:https://www.shopfiles.com/index.php/products/wordpress-ebook-store
    10 Version: 6.29
     10Version: 6.30
    1111Requires at least: 5.8
    1212Requires PHP: 7.4
  • ebook-store/trunk/fpdi/qrcode.class.php

    r3794705 r3794790  
    191191
    192192        /**
     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        /**
    193206         * Apply the reader restrictions and passwords, mapping the plugin's options
    194207         * to TCPDF's permission model.
    … …  
    215228                if (get_option('disable_annot-forms') == 1) {
    216229                        $blocked = array_merge($blocked, array('annot-forms', 'fill-forms'));
    217                 }
    218 
    219                 // Record the cache baseline before the first file is written, so a copy
    220                 // 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();
    223230                }
    224231
  • ebook-store/trunk/functions.php

    r3794705 r3794790  
    53455345                       
    53465346                }
    5347                 // A protected copy built with other restriction settings (every copy made
    5348                 // by 6.20-6.28, whose restrictions came out inverted) is rebuilt with the
    5349                 // 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                 }
    53545347                if (get_post_meta($order_id,'encrypted_pdf',true) != '' && $_REQUEST['type'] != 'bonus') {
    53555348                        $requested_file = get_post_meta($order_id,'encrypted_pdf',true);
    … …  
    54175410                if ($strtotime !== false && $strtotime > $gmt_timestamp && $strtotime < time()) {
    54185411                        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 );
    54195422                }
    54205423
    … …  
    68166819                $attachment_file = (string) get_post_meta( $order_id, 'encrypted_epub', true );
    68176820        }
    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 ) ) {
    68206827                $master_attachment = ebook_attachment( $ebook_id, true );
    68216828                $attachment_file = isset( $master_attachment[0]['file'] ) ? (string) $master_attachment[0]['file'] : '';
    … …  
    71027109                )
    71037110        );
     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        }
    71047121
    71057122        $mail_result = wp_mail( $recipient, $subject, $body_html, $headers, $attachments );
    … …  
    82478264
    82488265                $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);
    82508269                $generated = file_exists($destfile);
    82518270        } finally {
    … …  
    83028321        $previous = $GLOBALS['ebook_store_pdf_in_progress'][ $order_id ];
    83038322        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        }
    83048333        delete_post_meta( $order_id, 'encrypted_pdf_started' );
    8305         if ( $previous !== '' && $previous !== 'in_progress' ) {
     8334        if ( $previous !== '' ) {
    83068335                update_post_meta( $order_id, 'encrypted_pdf', wp_slash( $previous ) );
    83078336        } else {
  • ebook-store/trunk/includes/ebook-store-bootstrap.php

    r3774772 r3794790  
    141141        if ( ( $installed === '' || version_compare( $installed, '6.27', '<' ) ) && ! wp_next_scheduled( 'ebook_store_license_plan_refresh' ) ) {
    142142                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' );
    143149        }
    144150
  • ebook-store/trunk/readme.txt

    r3794705 r3794790  
    55Tested up to: 7.1.4
    66Requires PHP: 7.4
    7 Stable tag: 6.29
     7Stable tag: 6.30
    88License: GPLv2 or later
    99License URI: http://www.gnu.org/licenses/gpl-2.0.html
    … …  
    218218== Changelog ==
    219219
     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
    220226= 6.29 =
    221227* 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.
    … …  
    245251* 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.
    246252
    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 
    253253Earlier versions: see the full changelog at https://plugins.trac.wordpress.org/browser/ebook-store/tags/6.24/readme.txt
    254254
    255255== Upgrade Notice ==
     256
     257= 6.30 =
     258Makes 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.
    256259
    257260= 6.29 =
Note: See TracChangeset for help on using the changeset viewer.