HEX
Server: Apache
System: Linux web5.visualcom.es 4.18.0-477.21.1.lve.1.el8.x86_64 #1 SMP Tue Sep 5 23:08:35 UTC 2023 x86_64
User: ensaco.es (10019)
PHP: 8.3.32
Disabled: opcache_get_status
Upload Files
File: /var/www/vhosts/ensaco.es/httpdocs/wp-content/plugins/export-all-urls/classes/class-snapshots.php
<?php

namespace Export_All_URLs;

defined('ABSPATH') || exit;

require_once plugin_dir_path(__FILE__) . 'constants.php';

// This whole class is the custom-table data layer. Every query targets the
// plugin's own tables, the table identifiers come from internal constants (never
// user input), and all values are bound via $wpdb->prepare(). The direct-DB and
// caching sniffs are therefore intentionally suppressed for the file.
// phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL, PluginCheck.Security.DirectDB

/**
 * Stores and retrieves site "snapshots" — a lightweight fingerprint of every
 * post/page/CPT (id, type, status, author, slug, url, modified date, and a
 * SHA-256 hash of content+title+status). Comparing two snapshots reveals what
 * was added, removed or changed — useful for spotting unauthorized/injected
 * content.
 *
 * Only hashes are stored (never full content), so the tables stay small.
 */
class EAU_Snapshots
{
    private function snapshots_table()
    {
        global $wpdb;
        return $wpdb->prefix . Constants::SNAPSHOTS_TABLE;
    }

    private function items_table()
    {
        global $wpdb;
        return $wpdb->prefix . Constants::SNAPSHOT_ITEMS_TABLE;
    }

    /* ----------------------------- schema ------------------------------- */

    /**
     * Create/upgrade the custom tables. Safe to call repeatedly (dbDelta).
     */
    public static function create_tables()
    {
        global $wpdb;
        require_once ABSPATH . 'wp-admin/includes/upgrade.php';

        $charset_collate = $wpdb->get_charset_collate();
        $snapshots = $wpdb->prefix . Constants::SNAPSHOTS_TABLE;
        $items = $wpdb->prefix . Constants::SNAPSHOT_ITEMS_TABLE;

        $sql_snapshots = "CREATE TABLE $snapshots (
            id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
            created_at datetime NOT NULL,
            label varchar(191) NOT NULL DEFAULT '',
            post_count int(10) unsigned NOT NULL DEFAULT 0,
            fingerprint varchar(8) NOT NULL DEFAULT '1',
            PRIMARY KEY  (id),
            KEY created_at (created_at)
        ) $charset_collate;";

        $sql_items = "CREATE TABLE $items (
            id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
            snapshot_id bigint(20) unsigned NOT NULL,
            post_id bigint(20) unsigned NOT NULL,
            post_type varchar(20) NOT NULL DEFAULT '',
            post_status varchar(20) NOT NULL DEFAULT '',
            author_id bigint(20) unsigned NOT NULL DEFAULT 0,
            slug varchar(200) NOT NULL DEFAULT '',
            url text,
            title text,
            modified_gmt varchar(20) NOT NULL DEFAULT '',
            content_hash char(64) NOT NULL DEFAULT '',
            tax_hash char(64) NOT NULL DEFAULT '',
            meta_hash char(64) NOT NULL DEFAULT '',
            featured_id bigint(20) unsigned NOT NULL DEFAULT 0,
            PRIMARY KEY  (id),
            KEY snapshot_id (snapshot_id),
            KEY snapshot_post (snapshot_id,post_id)
        ) $charset_collate;";

        dbDelta($sql_snapshots);
        dbDelta($sql_items);

        update_option(Constants::DB_VERSION_OPTION, Constants::DB_VERSION);
    }

    /**
     * Run the schema installer when the stored DB version is out of date.
     */
    public static function maybe_upgrade()
    {
        if (get_option(Constants::DB_VERSION_OPTION) !== Constants::DB_VERSION) {
            self::create_tables();
        }
    }

    /**
     * Drop the custom tables and the version option (used on uninstall).
     */
    public static function drop_tables()
    {
        global $wpdb;
        $snapshots = $wpdb->prefix . Constants::SNAPSHOTS_TABLE;
        $items = $wpdb->prefix . Constants::SNAPSHOT_ITEMS_TABLE;
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        $wpdb->query("DROP TABLE IF EXISTS $items");
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        $wpdb->query("DROP TABLE IF EXISTS $snapshots");
        delete_option(Constants::DB_VERSION_OPTION);
    }

    /* --------------------------- snapshots ------------------------------ */

    /**
     * Capture a new snapshot of the whole site.
     *
     * @param string $label Optional human label.
     * @return int New snapshot id, or 0 on failure.
     */
    public function create($label)
    {
        global $wpdb;
        self::maybe_upgrade();

        $inserted = $wpdb->insert(
            $this->snapshots_table(),
            array(
                'created_at'  => current_time('mysql', true),
                'label'       => $label,
                'post_count'  => 0,
                'fingerprint' => Constants::SNAPSHOT_FINGERPRINT,
            ),
            array('%s', '%s', '%d', '%s')
        );
        if (!$inserted) {
            return 0;
        }
        $snapshot_id = (int) $wpdb->insert_id;

        $buffer = array();
        $total = 0;
        $this->walk_posts(function ($item) use (&$buffer, &$total, $snapshot_id) {
            $buffer[] = $item;
            $total++;
            if (count($buffer) >= Constants::SNAPSHOT_INSERT_CHUNK) {
                $this->insert_items($snapshot_id, $buffer);
                $buffer = array();
            }
        });
        if (!empty($buffer)) {
            $this->insert_items($snapshot_id, $buffer);
        }

        $wpdb->update(
            $this->snapshots_table(),
            array('post_count' => $total),
            array('id' => $snapshot_id),
            array('%d'),
            array('%d')
        );

        return $snapshot_id;
    }

    /**
     * Build the current live set of items, keyed by post id (not stored).
     * Used for "compare latest snapshot vs the live site".
     *
     * @return array post_id => item
     */
    public function build_live_set()
    {
        $set = array();
        $this->walk_posts(function ($item) use (&$set) {
            $set[$item['post_id']] = $item;
        });
        return $set;
    }

    /**
     * All snapshots, newest first.
     *
     * @return array Array of row objects.
     */
    public function get_all()
    {
        global $wpdb;
        $table = $this->snapshots_table();
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        return $wpdb->get_results("SELECT * FROM $table ORDER BY created_at DESC");
    }

    /**
     * One snapshot row, or null.
     */
    public function get($id)
    {
        global $wpdb;
        $table = $this->snapshots_table();
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        return $wpdb->get_row($wpdb->prepare("SELECT * FROM $table WHERE id = %d", $id));
    }

    /**
     * Delete a snapshot and its items.
     */
    public function delete($id)
    {
        global $wpdb;
        $id = (int) $id;
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $wpdb->delete($this->items_table(), array('snapshot_id' => $id), array('%d'));
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $wpdb->delete($this->snapshots_table(), array('id' => $id), array('%d'));
    }

    /**
     * A snapshot's items, keyed by post id.
     *
     * @return array post_id => item (associative array)
     */
    public function get_items($snapshot_id)
    {
        global $wpdb;
        $table = $this->items_table();
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        $rows = $wpdb->get_results($wpdb->prepare("SELECT * FROM $table WHERE snapshot_id = %d", (int) $snapshot_id), ARRAY_A);

        $set = array();
        if ($rows) {
            foreach ($rows as $row) {
                $set[(int) $row['post_id']] = $row;
            }
        }
        return $set;
    }

    /* ----------------------------- internals ---------------------------- */

    /**
     * Iterate every public post (all languages, real statuses) in batches and
     * invoke $callback($item) for each.
     */
    private function walk_posts($callback)
    {
        global $wpdb;

        $post_types = array_values(array_diff(array_keys(get_post_types(array('public' => true))), array('attachment')));
        if (empty($post_types)) {
            return;
        }
        $statuses = array('publish', 'future', 'draft', 'pending', 'private');

        $type_ph = implode(',', array_fill(0, count($post_types), '%s'));
        $status_ph = implode(',', array_fill(0, count($statuses), '%s'));

        $batch = Constants::BATCH_SIZE;
        $last_id = 0;

        while (true) {
            // Keyset pagination (WHERE ID > last) — an index seek per batch, unlike
            // OFFSET which re-scans all skipped rows and degrades on large tables.
            $params = array_merge($post_types, $statuses, array($last_id, $batch));
            $id_sql = "SELECT ID FROM {$wpdb->posts} WHERE post_type IN ($type_ph) AND post_status IN ($status_ph) AND ID > %d ORDER BY ID ASC LIMIT %d";
            $ids = $wpdb->get_col($wpdb->prepare($id_sql, $params));
            if (empty($ids)) {
                break;
            }
            $ids = array_map('intval', $ids);

            // Load this batch through WP_Query so post, term and meta caches are
            // primed in bulk — build_item()'s get_the_terms()/get_post_meta()/
            // get_permalink() then hit cache instead of querying per post.
            $query = new \WP_Query(array(
                'post_type'              => $post_types,
                'post_status'            => $statuses,
                'post__in'               => $ids,
                'orderby'                => 'post__in',
                'posts_per_page'         => count($ids),
                'no_found_rows'          => true,
                'update_post_term_cache' => true,
                'update_post_meta_cache' => true,
                // Bypass WPML/Polylang language filtering so the snapshot always
                // captures every post of every type in every language.
                // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.SuppressFilters_suppress_filters -- deliberate: an integrity snapshot must include every language, which means bypassing the multilingual plugins' query filters.
                'suppress_filters'       => true,
            ));

            foreach ($query->posts as $post) {
                call_user_func($callback, $this->build_item($post));
            }
            wp_reset_postdata();

            $last_id = (int) end($ids);
            if (count($ids) < $batch) {
                break;
            }
        }
    }

    /**
     * Build a fingerprint item from a post object.
     *
     * IMPORTANT: the *_hash fields below ARE the snapshot fingerprint. If you change
     * how any of them is computed (content_hash, taxonomy_hash, meta_hash, featured_id),
     * existing snapshots become incomparable with new captures and would report
     * spurious "modified" rows. When you make such a change you MUST bump
     * Constants::SNAPSHOT_FINGERPRINT — the compare screen then warns the user to take a
     * fresh snapshot. See ARCHITECTURE.md ("Maintaining the snapshot feature").
     */
    private function build_item($post)
    {
        $modified = (string) $post->post_modified_gmt;
        if ('0000' === substr($modified, 0, 4)) {
            $modified = '';
        }

        return array(
            'post_id'      => (int) $post->ID,
            'post_type'    => $post->post_type,
            'post_status'  => $post->post_status,
            'author_id'    => (int) $post->post_author,
            'slug'         => $post->post_name,
            'url'          => get_permalink($post),
            'title'        => $post->post_title,
            'modified_gmt' => $modified,
            'content_hash' => hash('sha256', $post->post_content . '|' . $post->post_title . '|' . $post->post_status),
            'tax_hash'     => $this->taxonomy_hash($post),
            'meta_hash'    => $this->meta_hash($post->ID),
            'featured_id'  => (int) get_post_thumbnail_id($post->ID),
        );
    }

    /**
     * Stable hash of every term (across all taxonomies) attached to the post.
     */
    private function taxonomy_hash($post)
    {
        $parts = array();
        foreach (get_object_taxonomies($post->post_type) as $taxonomy) {
            $terms = get_the_terms($post->ID, $taxonomy);
            if ($terms && !is_wp_error($terms)) {
                $ids = array();
                foreach ($terms as $term) {
                    $ids[] = (int) $term->term_id;
                }
                sort($ids);
                $parts[] = $taxonomy . ':' . implode(',', $ids);
            }
        }
        sort($parts);
        return empty($parts) ? '' : hash('sha256', implode('|', $parts));
    }

    /**
     * Stable hash of the post's user custom fields.
     *
     * WordPress "protected" meta (keys starting with "_") is skipped: that is where
     * plugins and the editor keep internal, auto-changing data — page-builder CSS
     * caches (e.g. _elementor_css), SEO scores, edit locks, view counters, the
     * featured-image id, etc. Hashing those produced false "custom fields changed"
     * reports. User-defined custom fields (and ACF values) are not prefixed, so they
     * are still tracked.
     */
    private function meta_hash($post_id)
    {
        $meta = get_post_meta($post_id);
        $parts = array();
        if (is_array($meta)) {
            foreach ($meta as $key => $values) {
                if ('' === $key || '_' === $key[0]) {
                    continue;
                }
                $parts[] = $key . '=' . implode(',', array_map('strval', (array) $values));
            }
        }
        sort($parts);
        return empty($parts) ? '' : hash('sha256', implode('|', $parts));
    }

    /**
     * Insert a chunk of items with a single multi-row INSERT.
     */
    private function insert_items($snapshot_id, $items)
    {
        global $wpdb;
        if (empty($items)) {
            return;
        }

        $table = $this->items_table();
        $placeholders = array();
        $values = array();

        foreach ($items as $it) {
            $placeholders[] = '(%d,%d,%s,%s,%d,%s,%s,%s,%s,%s,%s,%s,%d)';
            $values[] = $snapshot_id;
            $values[] = $it['post_id'];
            $values[] = $it['post_type'];
            $values[] = $it['post_status'];
            $values[] = $it['author_id'];
            $values[] = $it['slug'];
            $values[] = (string) $it['url'];
            $values[] = $it['title'];
            $values[] = $it['modified_gmt'];
            $values[] = $it['content_hash'];
            $values[] = $it['tax_hash'];
            $values[] = $it['meta_hash'];
            $values[] = $it['featured_id'];
        }

        $sql = "INSERT INTO $table (snapshot_id, post_id, post_type, post_status, author_id, slug, url, title, modified_gmt, content_hash, tax_hash, meta_hash, featured_id) VALUES "
            . implode(',', $placeholders);

        // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- values are bound via prepare(); only the internal table name is interpolated.
        $wpdb->query($wpdb->prepare($sql, $values));
    }
}