avatar-privacy/includes/avatar-privacy/data-storage/database/class-comment-author-table.php

455 lines
14 KiB
PHP

<?php
/**
* This file is part of Avatar Privacy.
*
* Copyright 2018-2022 Peter Putzer.
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; either version 2
* of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*
* ***
*
* @package mundschenk-at/avatar-privacy
* @license http://www.gnu.org/licenses/gpl-2.0.html
*/
namespace Avatar_Privacy\Data_Storage\Database;
use Avatar_Privacy\Data_Storage\Database\Table;
use Avatar_Privacy\Data_Storage\Network_Options;
/**
* The database table used for storing (anonymous) comment author data.
*
* @since 2.4.0 Extracted from Avatar_Privacy\Data_Storage\Database\Table.
*
* @author Peter Putzer <github@mundschenk.at>
*/
class Comment_Author_Table extends Table {
/**
* The table basename without the prefix.
*
* @var string
*/
const TABLE_BASENAME = 'avatar_privacy';
/**
* The minimum version not needing a table update.
*
* @var string
*/
const LAST_UPDATED = '2.4.0';
/**
* A column/field to placeholder mapping.
*
* @since 2.3.0
*
* @var string[]
*/
const COLUMN_FORMATS = [
'id' => '%d',
'email' => '%s',
'use_gravatar' => '%d',
'last_updated' => '%s',
'log_message' => '%s',
];
/**
* A list auto-update columns (e.g. date-/timestamps).
*
* @since 2.6.0
*
* @var string[]
*/
const AUTO_UPDATE_COLS = [
'last_updated',
];
/**
* The options handler.
*
* @var Network_Options
*/
private $network_options;
/**
* Creates a new instance.
*
* @since 2.3.0 Parameter $core added.
* @since 2.4.0 Parameter $core removed.
*
* @param Network_Options $network_options The network options handler.
*/
public function __construct( Network_Options $network_options ) {
parent::__construct( self::TABLE_BASENAME, self::LAST_UPDATED, self::COLUMN_FORMATS, self::AUTO_UPDATE_COLS );
$this->network_options = $network_options;
}
/**
* Sets up the table, including necessary data upgrades. The method is called
* on every page load.
*
* @since 2.4.0
*
* @param string $previous_version The previously installed plugin version.
*
* @return void
*/
public function setup( $previous_version ) {
parent::setup( $previous_version );
// The table is set up correctly, but maybe we need to migrate some data
// from the global table on network installations.
$this->maybe_prepare_migration_queue();
$this->maybe_migrate_from_global_table();
}
/**
* Determines whether this (multisite) installation uses the global table.
* Result is ignored for single-site installations.
*
* @since 2.3.0 Visibility changed to public.
*
* @return bool
*/
public function use_global_table() {
$global_table = (bool) $this->network_options->get( Network_Options::USE_GLOBAL_TABLE, false );
/**
* Filters whether a global table should be enabled for multisite installations.
*
* @since 1.0.0
*
* @param bool $enable Default false, unless this is a multisite installation
* upgraded from version 0.4 or earlier.
*/
return \apply_filters( 'avatar_privacy_enable_global_table', $global_table );
}
/**
* Retrieves the CREATE TABLE definition formatted for use by \db_delta(),
* without the charset collate clause.
*
* Example:
* `CREATE TABLE some_table (
* id mediumint(9) NOT NULL AUTO_INCREMENT,
* some_column varchar(100) NOT NULL,
* PRIMARY KEY (id)
* )`
*
* @since 2.4.0
*
* @param string $table_name The table name including any prefixes.
*
* @return string
*/
protected function get_table_definition( $table_name ) {
return "CREATE TABLE {$table_name} (
id mediumint(9) NOT NULL AUTO_INCREMENT,
email varchar(100) NOT NULL,
use_gravatar tinyint(1) DEFAULT NULL,
last_updated datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP NOT NULL,
log_message varchar(255),
PRIMARY KEY (id),
UNIQUE KEY email (email)
)";
}
/**
* Tries set up the migration queue if the trigger is set.
*
* @since 2.4.0 Moved to class Comment_Author_Table.
*
* @return void
*/
protected function maybe_prepare_migration_queue() {
$queue = $this->network_options->get( Network_Options::START_GLOBAL_TABLE_MIGRATION );
if ( \is_array( $queue ) && $this->network_options->lock( Network_Options::GLOBAL_TABLE_MIGRATION ) ) {
if ( ! empty( $queue ) ) {
// Store new queue, overwriting any existing queue (since this per network
// and we already got all sites currently in the network).
$this->network_options->set( Network_Options::GLOBAL_TABLE_MIGRATION, $queue );
} else {
// The "start queue" is empty, which means we should cease the migration efforts.
$this->network_options->delete( Network_Options::GLOBAL_TABLE_MIGRATION );
}
// Unlock queue and delete trigger.
$this->network_options->unlock( Network_Options::GLOBAL_TABLE_MIGRATION );
$this->network_options->delete( Network_Options::START_GLOBAL_TABLE_MIGRATION );
}
}
/**
* Tries to migrate global table data if the current site is queued.
*
* @since 2.4.0 Moved to class Comment_Author_Table.
*
* @return void
*/
protected function maybe_migrate_from_global_table() {
if (
// The plugin is not network-activated (or not on a multisite installation).
! \is_plugin_active_for_network( \plugin_basename( \AVATAR_PRIVACY_PLUGIN_FILE ) ) ||
// The queue is empty.
! $this->network_options->get( Network_Options::GLOBAL_TABLE_MIGRATION ) ||
// The queue is locked. Try again next time.
! $this->network_options->lock( Network_Options::GLOBAL_TABLE_MIGRATION )
) {
// Nothing to see here.
return;
}
// Check if we are scheduled to migrate data from the global table.
$site_id = \get_current_blog_id();
$queue = $this->network_options->get( Network_Options::GLOBAL_TABLE_MIGRATION, [] );
if ( \is_array( $queue ) && ! empty( $queue[ $site_id ] ) ) {
// Migrate the data.
$this->migrate_from_global_table( $site_id );
// Mark this site as done.
unset( $queue[ $site_id ] );
if ( ! empty( $queue ) ) {
// Save the new queue.
$this->network_options->set( Network_Options::GLOBAL_TABLE_MIGRATION, $queue );
} else {
// Delete it.
$this->network_options->delete( Network_Options::GLOBAL_TABLE_MIGRATION );
}
}
// Unlock the queue again.
$this->network_options->unlock( Network_Options::GLOBAL_TABLE_MIGRATION );
}
/**
* Migrates data from the global database to the given site database.
*
* @since 2.4.0 Parameter $site_id made mandatory.
*
* @global \wpdb $wpdb The WordPress Database Access Abstraction.
*
* @param int|null $site_id The site ID. Null means the current $blog_id.
*
* @return int|false The number of migrated rows or false on error.
*/
public function migrate_from_global_table( $site_id ) {
global $wpdb;
// Get table names.
$global_table_name = $this->get_table_name( \get_main_site_id() );
$site_table_name = $this->get_table_name( $site_id );
// Either we are on the main site or the "use global table" option is enabled.
if ( $global_table_name === $site_table_name ) {
return false;
}
// Select the rows to migrate.
/**
* Rows to delete indexed by the ID column in the global table.
*
* @var \stdClass[]
*/
$rows_to_delete = [];
/**
* Rows to migrate indexed by the ID column in the global table.
*
* @var \stdClass[]
*/
$rows_to_migrate = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
$wpdb->prepare(
"SELECT * FROM `{$global_table_name}` WHERE log_message LIKE %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
"set with comment % (site: %, blog: {$wpdb->esc_like( $site_id )})"
),
\OBJECT_K
);
// Check for existing rows for the same email addresses.
$emails = \wp_list_pluck( $rows_to_migrate, 'email', 'id' );
$emails_to_ids = \array_flip( $emails );
$existing_rows = (array) $wpdb->get_results( $this->prepare_email_query( $emails, $site_table_name ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery
foreach ( $existing_rows as $row ) {
$global_row_id = $emails_to_ids[ $row->email ];
if ( ! empty( $rows_to_migrate[ $global_row_id ] ) ) {
$global_row = $rows_to_migrate[ $global_row_id ];
if ( (int) \strtotime( $row->last_updated ) >= (int) \strtotime( $global_row->last_updated ) ) {
unset( $rows_to_migrate[ $global_row_id ] );
// Just delete this row.
$rows_to_delete[ $global_row_id ] = $global_row;
}
}
}
// Migrated rows need to be deleted, too.
$rows_to_delete = $rows_to_delete + $rows_to_migrate;
// Do INSERTs and UPDATEs in one query.
$migrated = $this->insert_or_update( [ 'email', 'hash', 'use_gravatar', 'last_updated', 'log_message' ], $rows_to_migrate, $site_id );
if ( false !== $migrated ) {
// Do DELETEs in one query.
$deleted = 0;
$delete_query = $this->prepare_delete_query( \array_keys( $rows_to_delete ), $global_table_name );
if ( false !== $delete_query ) {
$deleted = $wpdb->query( $delete_query ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery
}
if ( false !== $deleted ) {
// Count the deleted rows if they were not included in the migrated
// rows because they were too old.
return \max( $migrated, $deleted );
}
}
return false;
}
/**
* Prepares the query for selecting existing rows by email.
*
* @global \wpdb $wpdb The WordPress Database Access Abstraction.
*
* @param string[] $emails An array of email adresses.
* @param string $table The table name.
*
* @return string|false The prepared query, or false.
*/
protected function prepare_email_query( array $emails, $table ) {
global $wpdb;
if ( empty( $emails ) || empty( $table ) ) {
return false;
}
$placeholders = \join( ',', \array_fill( 0, \count( $emails ), '%s' ) );
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
return $wpdb->prepare( "SELECT * FROM `{$table}` WHERE email IN ({$placeholders})", $emails );
}
/**
* Prepares the query for deleting obsolete rows from the database.
*
* @global \wpdb $wpdb The WordPress Database Access Abstraction.
*
* @param int[] $ids_to_delete The IDs to delete.
* @param string $table The table name.
*
* @return string|false The prepared query, or false.
*/
protected function prepare_delete_query( array $ids_to_delete, $table ) {
global $wpdb;
if ( empty( $ids_to_delete ) || empty( $table ) ) {
return false;
}
$placeholders = \join( ',', \array_fill( 0, \count( $ids_to_delete ), '%d' ) );
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
return $wpdb->prepare( "DELETE FROM `{$table}` WHERE id IN ({$placeholders})", $ids_to_delete );
}
/**
* Fixes the table schema when dbDelta cannot cope with the changes.
*
* The table itself is already guaranteed to exist.
*
* @since 2.4.0
*
* @param string $previous_version The previously installed plugin version.
*
* @return bool True if the schema was modified, false otherwise.
*/
public function maybe_upgrade_schema( $previous_version ) {
$result = false;
if ( \version_compare( $previous_version, '2.4.0', '<' ) ) {
$result = $this->maybe_drop_hash_column() || $result; // @phpstan-ignore-line -- to make copy & paste less error prone.
$result = $this->maybe_fix_last_updated_column_default() || $result;
}
return $result;
}
/**
* Drops the obsolete 'hash' column from the table (if it exists).
*
* @since 2.4.0
*
* @return bool
*/
protected function maybe_drop_hash_column() {
global $wpdb;
$table_name = $this->get_table_name();
// phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQLPlaceholders
if ( 'hash' === $wpdb->get_var( $wpdb->prepare( 'SHOW COLUMNS FROM `%1$s` LIKE \'hash\'', $table_name ) ) ) {
return (bool) $wpdb->query( $wpdb->prepare( 'ALTER TABLE `%1$s` DROP COLUMN hash', $table_name ) );
}
// phpcs:enable WordPress.DB
return false;
}
/**
* Drops the obsolete 'hash' column from the table.
*
* @since 2.4.0
*
* @return bool
*/
protected function maybe_fix_last_updated_column_default() {
global $wpdb;
$table_name = $this->get_table_name();
// phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQLPlaceholders
$column_definition = $wpdb->get_row( $wpdb->prepare( 'SHOW COLUMNS FROM `%1$s` LIKE \'last_updated\'', $table_name ), \ARRAY_A );
if ( 'CURRENT_TIMESTAMP' !== $column_definition['Default'] ) {
return (bool) $wpdb->query( $wpdb->prepare( 'ALTER TABLE `%1$s` MODIFY COLUMN `last_updated` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP NOT NULL', $table_name ) );
}
// phpcs:enable WordPress.DB
return false;
}
/**
* Upgrades the table data if necessary.
*
* @since 2.3.0
* @since 2.4.0 Renamed to maybe_upgrade_data. Parameter $previous_version added.
*
* @global \wpdb $wpdb The WordPress Database Access Abstraction.
*
* @param string $previous_version The previously installed plugin version.
*
* @return int The number of upgraded rows.
*/
public function maybe_upgrade_data( $previous_version ) {
return 0;
}
}