*/ private static array $rendered = array(); public static function init(): void { add_action( 'init', array( __CLASS__, 'maybe_install' ), 1 ); add_action( 'rest_api_init', array( __CLASS__, 'register_routes' ) ); add_filter( 'the_content', array( __CLASS__, 'append_to_content' ), 24 ); add_action( 'before_delete_post', array( __CLASS__, 'delete_post_data' ) ); } public static function table_name(): string { global $wpdb; return $wpdb->prefix . 'sg7g_likes'; } public static function install(): void { global $wpdb; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; $charset_collate = $wpdb->get_charset_collate(); $table = self::table_name(); dbDelta( "CREATE TABLE {$table} ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, post_id bigint(20) unsigned NOT NULL, voter_hash char(64) NOT NULL, created_at datetime NOT NULL, PRIMARY KEY (id), UNIQUE KEY post_voter (post_id, voter_hash), KEY post_id (post_id), KEY created_at (created_at) ) {$charset_collate};" ); update_option( self::SCHEMA_OPTION, self::SCHEMA, false ); } public static function maybe_install(): void { if ( self::SCHEMA !== (string) get_option( self::SCHEMA_OPTION, '' ) ) { self::install(); } } public static function register_routes(): void { $common = array( 'permission_callback' => '__return_true', 'args' => array( 'id' => array( 'required' => true, 'sanitize_callback' => 'absint', 'validate_callback' => static fn( $value ): bool => absint( $value ) > 0, ), 'token' => array( 'required' => true, 'sanitize_callback' => 'sanitize_text_field', 'validate_callback' => static fn( $value ): bool => is_string( $value ) && 64 === strlen( $value ), ), ), ); register_rest_route( '7sgaleri/v1', '/like/(?P\d+)', array( array_merge( $common, array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( __CLASS__, 'status' ), ) ), array_merge( $common, array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( __CLASS__, 'set' ), 'args' => array_merge( $common['args'], array( 'liked' => array( 'required' => true, 'sanitize_callback' => 'rest_sanitize_boolean', 'validate_callback' => static fn( $value ): bool => is_bool( $value ) || in_array( $value, array( 0, 1, '0', '1', 'false', 'true' ), true ), ), ) ), ) ), ) ); register_rest_route( '7sgaleri/v1', '/metrics', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( __CLASS__, 'metrics' ), 'permission_callback' => '__return_true', 'args' => array( 'ids' => array( 'required' => true, 'sanitize_callback' => static function ( mixed $value ): string { $ids = array_slice( array_values( array_unique( array_filter( array_map( 'absint', explode( ',', (string) $value ) ) ) ) ), 0, 100 ); return implode( ',', $ids ); }, 'validate_callback' => static function ( mixed $value ): bool { return is_string( $value ) && 1 === preg_match( '/^\d+(?:,\d+){0,99}$/', $value ); }, ), ), ) ); } /** * Return current card metrics directly from the interaction tables. * * This endpoint intentionally accepts POST so a page/cache layer cannot * replay a previously generated metrics response. The count itself is * always calculated with COUNT(*) below; no aggregate post meta is read. */ public static function metrics( WP_REST_Request $request ): WP_REST_Response { $requested_ids = array_filter( array_map( 'absint', explode( ',', (string) $request->get_param( 'ids' ) ) ) ); $requested_ids = array_slice( array_values( array_unique( $requested_ids ) ), 0, 100 ); $post_ids = self::published_ids( $requested_ids ); $items = array(); foreach ( $post_ids as $post_id ) { $post_id = absint( $post_id ); $views = SG7G_Views::get( $post_id ); $likes = SG7G_Settings::enabled( 'show_likes' ) ? self::get( $post_id ) : 0; $items[] = array( 'id' => $post_id, 'views' => $views, 'viewsFormatted' => SG7G_Views::format( $views ), 'likes' => $likes, 'likesFormatted' => self::format( $likes ), 'label' => SG7G_Settings::enabled( 'show_likes' ) ? sprintf( /* translators: 1: View count, 2: Like count. */ __( '%1$s views, %2$s likes', '7sgaleri' ), number_format_i18n( $views ), number_format_i18n( $likes ) ) : sprintf( /* translators: %s: View count. */ __( '%s views', '7sgaleri' ), number_format_i18n( $views ) ), ); } $response = rest_ensure_response( array( 'items' => $items ) ); $response->header( 'Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0' ); $response->header( 'CDN-Cache-Control', 'no-store' ); $response->header( 'X-SG7G-Live-Metrics', '1' ); $response->header( 'Pragma', 'no-cache' ); $response->header( 'X-Robots-Tag', 'noindex, nofollow' ); return $response; } public static function status( WP_REST_Request $request ): WP_REST_Response|WP_Error { $post_id = absint( $request['id'] ); $valid = self::validate_request( $post_id, (string) $request->get_param( 'token' ) ); if ( is_wp_error( $valid ) ) { return $valid; } return self::response( $post_id, self::has_liked( $post_id ) ); } public static function set( WP_REST_Request $request ): WP_REST_Response|WP_Error { $post_id = absint( $request['id'] ); $valid = self::validate_request( $post_id, (string) $request->get_param( 'token' ) ); if ( is_wp_error( $valid ) ) { return $valid; } $liked = rest_sanitize_boolean( $request->get_param( 'liked' ) ); $voter_hash = self::voter_hash( $liked ); if ( '' === $voter_hash ) { if ( ! $liked ) { return self::response( $post_id, false ); } return new WP_Error( 'sg7g_like_identity_failed', __( 'Your like could not be saved.', '7sgaleri' ), array( 'status' => 500 ) ); } global $wpdb; $table = self::table_name(); if ( $liked ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->query( $wpdb->prepare( 'INSERT IGNORE INTO %i (post_id, voter_hash, created_at) VALUES (%d, %s, %s)', $table, $post_id, $voter_hash, current_time( 'mysql', true ) ) ); } else { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->delete( $table, array( 'post_id' => $post_id, 'voter_hash' => $voter_hash, ), array( '%d', '%s' ) ); } return self::response( $post_id, self::has_liked( $post_id ) ); } public static function append_to_content( string $content ): string { if ( is_admin() || ! is_singular( 'sg7g_media' ) || ! in_the_loop() || ! is_main_query() ) { return $content; } $post_id = get_the_ID(); if ( $post_id <= 0 || isset( self::$rendered[ $post_id ] ) || ! SG7G_Settings::enabled( 'show_likes' ) || post_password_required( $post_id ) ) { return $content; } self::$rendered[ $post_id ] = true; ob_start(); ?>
get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE post_id = %d', self::table_name(), $post_id ) ); return max( 0, (int) $count ); } public static function has_liked( int $post_id ): bool { $voter_hash = self::voter_hash( false ); if ( '' === $voter_hash ) { return false; } global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching return (bool) $wpdb->get_var( $wpdb->prepare( 'SELECT 1 FROM %i WHERE post_id = %d AND voter_hash = %s LIMIT 1', self::table_name(), $post_id, $voter_hash ) ); } public static function token( int $post_id ): string { return hash_hmac( 'sha256', 'sg7g-like|' . $post_id, wp_salt( 'auth' ) ); } public static function format( int $count ): string { return SG7G_Views::format( $count ); } public static function icon(): string { return ''; } /** Render the compact thumbnail-led popularity index on the first gallery archive page. */ public static function render_popular(): void { if ( ! SG7G_Settings::enabled( 'show_likes' ) || ! SG7G_Settings::enabled( 'show_popular' ) || ! is_post_type_archive( 'sg7g_media' ) || is_tax() || '' !== SG7G_Search::request_term() || max( 1, (int) get_query_var( 'paged' ) ) > 1 ) { return; } $period = (string) SG7G_Settings::get( 'popular_period', 'month' ); $period = in_array( $period, array( 'week', 'month', 'all' ), true ) ? $period : 'month'; $limit = (int) SG7G_Settings::get( 'popular_count', 8 ); $limit = in_array( $limit, array( 4, 8, 12 ), true ) ? $limit : 8; $items = self::popular_items( $limit, $period ); /* The Number of items setting is a visible layout control, not merely a * query ceiling. If the selected period contains fewer liked items, * complete the requested count with real aggregate-like entries and * finally published catalogue entries. No synthetic likes are created. */ if ( count( $items ) < $limit && 'all' !== $period ) { $items = self::merge_popular_items( $items, self::popular_items( $limit, 'all' ), $limit ); } if ( count( $items ) < $limit ) { $items = self::merge_popular_items( $items, self::popular_catalogue_items( $limit ), $limit ); } if ( empty( $items ) ) { return; } $title = trim( (string) SG7G_Settings::get( 'popular_title', '' ) ); $title = '' !== $title ? $title : __( 'Most Liked', '7sgaleri' ); $rows = max( 1, (int) ceil( count( $items ) / 4 ) ); $section_label = sprintf( /* translators: 1: Popular section title, 2: Time period. */ __( '%1$s, %2$s', '7sgaleri' ), $title, self::period_label( $period ) ); ?> $primary Primary ranked items. * @param array $fallback Fallback candidates. * @return array */ private static function merge_popular_items( array $primary, array $fallback, int $limit ): array { $limit = min( 12, max( 4, $limit ) ); $seen = array(); $items = array(); foreach ( array_merge( $primary, $fallback ) as $item ) { $post_id = absint( $item['post_id'] ?? 0 ); if ( $post_id <= 0 || isset( $seen[ $post_id ] ) ) { continue; } $seen[ $post_id ] = true; $items[] = array( 'post_id' => $post_id, 'like_count' => max( 0, (int) ( $item['like_count'] ?? self::get( $post_id ) ) ), ); if ( count( $items ) >= $limit ) { break; } } return $items; } /** * @return array */ public static function popular_items( int $limit, string $period ): array { global $wpdb; $limit = min( 12, max( 4, $limit ) ); $table = self::table_name(); $sql = 'SELECT l.post_id, COUNT(*) AS like_count FROM %i l INNER JOIN %i p ON p.ID = l.post_id WHERE p.post_type = %s AND p.post_status = %s AND p.post_password = %s'; $args = array( $table, $wpdb->posts, 'sg7g_media', 'publish', '' ); if ( 'week' === $period || 'month' === $period ) { $days = 'week' === $period ? 7 : 30; $from = gmdate( 'Y-m-d H:i:s', time() - ( $days * DAY_IN_SECONDS ) ); $sql .= ' AND l.created_at >= %s'; $args[] = $from; } $sql .= ' GROUP BY l.post_id ORDER BY like_count DESC, MAX(l.created_at) DESC LIMIT %d'; $args[] = $limit; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $rows = $wpdb->get_results( $wpdb->prepare( $sql, ...$args ), ARRAY_A ); if ( ! is_array( $rows ) ) { return array(); } return array_values( array_map( static fn( array $row ): array => array( 'post_id' => absint( $row['post_id'] ?? 0 ), 'like_count' => max( 0, (int) ( $row['like_count'] ?? 0 ) ), ), $rows ) ); } /** * Return a deterministic catalogue fallback when every item currently has * zero likes. This keeps the home section structurally present without * inventing interaction data: all returned like_count values remain zero. * * @return array */ private static function popular_catalogue_items( int $limit ): array { $limit = min( 12, max( 4, $limit ) ); $query = new WP_Query( array( 'post_type' => 'sg7g_media', 'post_status' => 'publish', 'has_password' => false, 'posts_per_page' => $limit, 'orderby' => array( 'date' => 'DESC', 'ID' => 'DESC' ), 'order' => 'DESC', 'fields' => 'ids', 'ignore_sticky_posts' => true, 'no_found_rows' => true, 'update_post_meta_cache' => false, 'update_post_term_cache' => false, ) ); if ( empty( $query->posts ) ) { return array(); } return array_values( array_map( static fn( $post_id ): array => array( 'post_id' => absint( $post_id ), 'like_count' => self::get( absint( $post_id ) ), ), $query->posts ) ); } public static function delete_post_data( int $post_id ): void { if ( 'sg7g_media' !== get_post_type( $post_id ) ) { return; } global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->delete( self::table_name(), array( 'post_id' => $post_id ), array( '%d' ) ); } private static function period_label( string $period ): string { if ( 'week' === $period ) { return __( 'Last 7 days', '7sgaleri' ); } if ( 'all' === $period ) { return __( 'All time', '7sgaleri' ); } return __( 'Last 30 days', '7sgaleri' ); } private static function validate_request( int $post_id, string $token ): true|WP_Error { $post = get_post( $post_id ); if ( ! $post || 'sg7g_media' !== $post->post_type || 'publish' !== $post->post_status ) { return new WP_Error( 'sg7g_invalid_media', __( 'Invalid gallery content.', '7sgaleri' ), array( 'status' => 404 ) ); } if ( '' !== (string) $post->post_password && post_password_required( $post ) ) { return new WP_Error( 'sg7g_protected_media', __( 'This gallery content is password protected.', '7sgaleri' ), array( 'status' => 403 ) ); } if ( ! SG7G_Settings::enabled( 'show_likes' ) ) { return new WP_Error( 'sg7g_likes_disabled', __( 'Likes are disabled.', '7sgaleri' ), array( 'status' => 403 ) ); } if ( '' === $token || ! hash_equals( self::token( $post_id ), $token ) ) { return new WP_Error( 'sg7g_invalid_like_token', __( 'Invalid like request.', '7sgaleri' ), array( 'status' => 403 ) ); } return true; } private static function voter_hash( bool $create_guest ): string { if ( is_user_logged_in() ) { return hash_hmac( 'sha256', 'user|' . get_current_user_id(), wp_salt( 'secure_auth' ) ); } $guest_token = isset( $_COOKIE[ self::COOKIE_NAME ] ) ? sanitize_text_field( wp_unslash( $_COOKIE[ self::COOKIE_NAME ] ) ) : ''; if ( ! preg_match( '/^[A-Za-z0-9]{32}$/', $guest_token ) ) { $guest_token = ''; } if ( '' === $guest_token && $create_guest ) { $guest_token = wp_generate_password( 32, false, false ); self::set_guest_cookie( $guest_token ); $_COOKIE[ self::COOKIE_NAME ] = $guest_token; } return '' === $guest_token ? '' : hash_hmac( 'sha256', 'guest|' . $guest_token, wp_salt( 'secure_auth' ) ); } private static function set_guest_cookie( string $token ): void { if ( headers_sent() ) { return; } $cookie_path = defined( 'COOKIEPATH' ) && COOKIEPATH ? COOKIEPATH : '/'; $cookie_domain = defined( 'COOKIE_DOMAIN' ) && COOKIE_DOMAIN ? COOKIE_DOMAIN : ''; setcookie( self::COOKIE_NAME, $token, array( 'expires' => time() + YEAR_IN_SECONDS, 'path' => $cookie_path, 'domain' => $cookie_domain, 'secure' => is_ssl(), 'httponly' => true, 'samesite' => 'Lax', ) ); } private static function response( int $post_id, bool $liked ): WP_REST_Response { $count = self::get( $post_id ); $response = rest_ensure_response( array( 'count' => $count, 'formatted' => self::format( $count ), 'liked' => $liked, 'buttonLabel' => $liked ? __( 'Liked', '7sgaleri' ) : __( 'Like', '7sgaleri' ), 'ariaLabel' => self::button_aria_label( $liked, $count ), 'label' => sprintf( /* translators: %s: Formatted like count. */ __( '%s likes', '7sgaleri' ), number_format_i18n( $count ) ), ) ); $response->header( 'Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0' ); $response->header( 'CDN-Cache-Control', 'no-store' ); $response->header( 'X-SG7G-Live-Like', '1' ); $response->header( 'Pragma', 'no-cache' ); $response->header( 'X-Robots-Tag', 'noindex, nofollow' ); return $response; } /** * Return published media IDs without priming WordPress's post/meta cache. * * @param array $requested_ids * @return array */ private static function published_ids( array $requested_ids ): array { global $wpdb; if ( empty( $requested_ids ) ) { return array(); } $placeholders = implode( ',', array_fill( 0, count( $requested_ids ), '%d' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $rows = $wpdb->get_col( $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE ID IN ({$placeholders}) AND post_type = %s AND post_status = %s", ...array_merge( $requested_ids, array( 'sg7g_media', 'publish' ) ) ) ); $published = array_map( 'absint', is_array( $rows ) ? $rows : array() ); return array_values( array_intersect( $requested_ids, $published ) ); } private static function button_aria_label( bool $liked, int $count ): string { if ( $liked ) { /* translators: %s: Like count. */ return sprintf( __( 'Remove your like. %s likes', '7sgaleri' ), number_format_i18n( $count ) ); } /* translators: %s: Like count. */ return sprintf( __( 'Like this content. %s likes', '7sgaleri' ), number_format_i18n( $count ) ); } }