prefix * followed by a literal — no caller can influence it. Table names cannot be passed as prepare() * placeholders, so there is no version of these queries that satisfies the sniff. Every value * that does come from outside is placeholdered. * * phpcs:disable WordPress.DB.PreparedSQL.NotPrepared * phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery * phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching * * The schema-change warning is disabled for the same reason: creating and dropping this table is * this class's job, and uninstall depends on drop() actually dropping it. * * phpcs:disable WordPress.DB.DirectDatabaseQuery.SchemaChange */ /** * One table mapping a path a page used to answer on to the post that owns it now. * * WHY A TABLE AND NOT POST META. * * The lookup happens on a 404 — for a path, with no post in hand. Post meta is indexed on * meta_key only, so finding "which post used to live at about/team/jo" would mean scanning every * row carrying the key. A 404 handler runs on every miss, including bot traffic, so that scan is * the hot path on exactly the requests you least want to spend money on. * * The path is stored twice: hashed for the unique index, and readable for the settings screen. * The hash exists because a utf8mb4 VARCHAR(255) index overruns MySQL's key length on older * servers, and because equality is the only comparison ever needed. */ class PathRedirectStore { /** * Schema version, bumped when the table changes. */ const SCHEMA_VERSION = 1; /** * Option holding the installed schema version. */ const VERSION_OPTION = 'adminkeep_redirect_schema'; /** * Fully-qualified table name. * * @return string */ public static function table() { global $wpdb; return $wpdb->prefix . 'adminkeep_path_redirects'; } /** * Create the table if it is missing or out of date. * * Called lazily, the first time a redirect actually needs recording — a site that never * enables this feature never gets the table. * * @return void */ public static function maybe_install() { if ( (int) get_option( self::VERSION_OPTION ) === self::SCHEMA_VERSION ) { return; } global $wpdb; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; $table = self::table(); $collate = $wpdb->get_charset_collate(); dbDelta( "CREATE TABLE {$table} ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, path_hash char(40) NOT NULL, old_path varchar(255) NOT NULL, post_id bigint(20) unsigned NOT NULL, created_at datetime NOT NULL, PRIMARY KEY (id), UNIQUE KEY path_hash (path_hash), KEY post_id (post_id) ) {$collate};" ); update_option( self::VERSION_OPTION, self::SCHEMA_VERSION, false ); } /** * Normalise a URL path into the form stored. * * Query strings, fragments, leading and trailing slashes and percent-encoding all vary * between the request and what the site generates. Everything is reduced to "about/team". * * @param string $path Raw path. * @return string */ public static function normalise( $path ) { $path = (string) $path; $path = explode( '#', $path )[0]; $path = explode( '?', $path )[0]; $path = rawurldecode( $path ); return trim( $path, '/' ); } /** * Record that a post used to answer on a path. * * @param string $old_path Path the post used to live at. * @param int $post_id Post that owns it. * @return void */ public static function record( $old_path, $post_id ) { $old_path = self::normalise( $old_path ); if ( '' === $old_path ) { return; } self::maybe_install(); global $wpdb; $table = self::table(); // A path can only ever belong to one post, and the newest claim wins: if two pages have // occupied this address over time, the one that just moved away from it is the one whose // inbound links are about to break. // This plugin's own table, so there is no core API for it; the name is $wpdb->prefix plus a // literal and every value is bound. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $wpdb->query( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter "INSERT INTO {$table} (path_hash, old_path, post_id, created_at) VALUES (%s, %s, %d, %s) ON DUPLICATE KEY UPDATE post_id = VALUES(post_id), created_at = VALUES(created_at)", sha1( $old_path ), $old_path, (int) $post_id, current_time( 'mysql', true ) ) ); } /** * The post that used to answer on a path. * * @param string $path Requested path. * @return int|null */ public static function lookup( $path ) { $path = self::normalise( $path ); if ( '' === $path || ! self::installed() ) { return null; } global $wpdb; $table = self::table(); // Plugin-owned table; name is $wpdb->prefix plus a literal, the hash is bound. Runs only on // a 404, so caching a miss would keep serving the 404 after the redirect is recorded. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $post_id = $wpdb->get_var( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter "SELECT post_id FROM {$table} WHERE path_hash = %s LIMIT 1", sha1( $path ) ) ); return null === $post_id ? null : (int) $post_id; } /** * Forget a path. * * @param string $path Path to forget. * @return void */ public static function forget( $path ) { $path = self::normalise( $path ); if ( '' === $path || ! self::installed() ) { return; } global $wpdb; $wpdb->delete( self::table(), array( 'path_hash' => sha1( $path ) ), array( '%s' ) ); } /** * Forget every path belonging to a post. * * @param int $post_id Post id. * @return void */ public static function forget_post( $post_id ) { if ( ! self::installed() ) { return; } global $wpdb; $wpdb->delete( self::table(), array( 'post_id' => (int) $post_id ), array( '%d' ) ); } /** * Delete one row by id. * * @param int $id Row id. * @return void */ public static function delete( $id ) { if ( ! self::installed() ) { return; } global $wpdb; $wpdb->delete( self::table(), array( 'id' => (int) $id ), array( '%d' ) ); } /** * Recorded paths, newest first. * * @param int $limit Maximum rows. * @return array[] */ public static function all( $limit = 100 ) { if ( ! self::installed() ) { return array(); } global $wpdb; $table = self::table(); // Plugin-owned table; name is $wpdb->prefix plus a literal, the limit is bound. Feeds the // settings screen, which must show what is stored right now. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter return $wpdb->get_results( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter "SELECT id, old_path, post_id, created_at FROM {$table} ORDER BY id DESC LIMIT %d", (int) $limit ), ARRAY_A ); } /** * How many paths are recorded. * * @return int */ public static function count() { if ( ! self::installed() ) { return 0; } global $wpdb; $table = self::table(); // Plugin-owned table, no parameters at all; the name is $wpdb->prefix plus a literal. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter return (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table}" ); } /** * Whether the table exists. * * @return bool */ public static function installed() { return (int) get_option( self::VERSION_OPTION ) === self::SCHEMA_VERSION; } /** * Drop the table entirely. Used by uninstall. * * @return void */ public static function drop() { global $wpdb; $table = self::table(); // Plugin-owned table being dropped on uninstall; DDL has no prepared form. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $wpdb->query( "DROP TABLE IF EXISTS {$table}" ); delete_option( self::VERSION_OPTION ); } }