| @@ -4,8 +4,15 @@ | ||
| 4 | 4 | { |
| 5 | 5 | private $core; |
| 6 | 6 | private $namespace = 'meow-gallery/v1'; |
| 7 | 7 | |
| 8 | + // Gallery attributes that decide *which* media a gallery shows. They must never be taken from | |
| 9 | + // an untrusted request: see rest_load_gallery_collection(). | |
| 10 | + private static $source_atts = [ | |
| 11 | + 'collection', 'id', 'ids', 'include', 'tags', 'posts', 'latest_posts', 'attachments', | |
| 12 | + 'rml', 'wplr-collection', 'meow', | |
| 13 | + ]; | |
| 14 | + | |
| 8 | 15 | public function __construct( $core ) { |
| 9 | 16 | $this->core = $core; |
| 10 | 17 | |
| 11 | 18 | // FOR DEBUG |
| @@ -66,8 +73,13 @@ | ||
| 66 | 73 | 'methods' => 'POST', |
| 67 | 74 | 'permission_callback' => array( $this->core, 'can_access_settings' ), |
| 68 | 75 | 'callback' => array( $this, 'rest_update_gallery_rank' ), |
| 69 | 76 | ) ); |
| 77 | + register_rest_route( $this->namespace, '/rml_folders', array( | |
| 78 | + 'methods' => 'GET', | |
| 79 | + 'permission_callback' => array( $this->core, 'can_access_settings' ), | |
| 80 | + 'callback' => array( $this, 'rest_rml_folders' ), | |
| 81 | + ) ); | |
| 70 | 82 | |
| 71 | 83 | |
| 72 | 84 | register_rest_route( $this->namespace, '/fetch_shortcodes', array( |
| 73 | 85 | 'methods' => 'POST', |
| @@ -115,9 +127,9 @@ | ||
| 115 | 127 | |
| 116 | 128 | // Gallery |
| 117 | 129 | register_rest_route( $this->namespace, '/images/', array( |
| 118 | 130 | 'methods' => 'POST', |
| 119 | - 'permission_callback' => '__return_true', | |
| 131 | + 'permission_callback' => array( $this, 'can_load_images' ), | |
| 120 | 132 | 'callback' => array( $this, 'rest_images' ) |
| 121 | 133 | ) ); |
| 122 | 134 | |
| 123 | 135 | register_rest_route( $this->namespace, '/fetch_posts', array( |
| @@ -131,8 +143,18 @@ | ||
| 131 | 143 | ) |
| 132 | 144 | ) ); |
| 133 | 145 | } |
| 134 | 146 | |
| 147 | + // The /images/ route feeds the infinite scroll and nothing else: when it is off (the default, | |
| 148 | + // and always in the free version) the gallery is rendered whole and the front-end never calls | |
| 149 | + // this. It has to stay open to visitors when infinite scroll IS on, but leaving it open | |
| 150 | + // everywhere exposed the title, caption and URL of any attachment ID, including attachments of | |
| 151 | + // posts that are not published. | |
| 152 | + public function can_load_images() { | |
| 153 | + $infinite = class_exists( 'MeowPro_MGL_Core' ) && Meow_MGL_Core::get_plugin_option( 'infinite', false ); | |
| 154 | + return apply_filters( 'mgl_allow_load_images', (bool) $infinite ); | |
| 155 | + } | |
| 156 | + | |
| 135 | 157 | function preview( WP_REST_Request $request ) { |
| 136 | 158 | $params = $request->get_body( ); |
| 137 | 159 | $params = json_decode( $params ); |
| 138 | 160 | $params->ids = implode( ',', $params->ids ); |
| @@ -137,31 +159,70 @@ | ||
| 137 | 159 | $params = json_decode( $params ); |
| 138 | 160 | $params->ids = implode( ',', $params->ids ); |
| 139 | 161 | $atts = ( array ) $params; |
| 140 | 162 | |
| 163 | + $full = !empty( $atts['full'] ); | |
| 164 | + unset( $atts['full'] ); | |
| 165 | + | |
| 141 | 166 | $is_collection = isset( $atts['collection'] ) && !empty( $atts['collection'] ); |
| 142 | 167 | if ( $is_collection ) { |
| 143 | - $html = do_shortcode( '[meow-collection id="' . $atts['collection'] . '"]' ); | |
| 168 | + $html = $this->core->render_collection( $atts['collection'] ); | |
| 169 | + $counts = [ 'total' => 0, 'shown' => 0 ]; | |
| 144 | 170 | } else { |
| 171 | + $this->core->last_preview_counts = [ 'total' => 0, 'shown' => 0 ]; | |
| 172 | + if ( $full ) { | |
| 173 | + $this->core->preview_cutoff = PHP_INT_MAX; | |
| 174 | + } | |
| 145 | 175 | $html = $this->core->gallery( $atts, [ 'isPreview' => true ] ); |
| 176 | + $counts = $this->core->last_preview_counts; | |
| 146 | 177 | } |
| 147 | 178 | |
| 148 | 179 | |
| 149 | - return new WP_REST_Response( [ 'success' => true, 'data' => $html ], 200 ); | |
| 180 | + return new WP_REST_Response( [ | |
| 181 | + 'success' => true, | |
| 182 | + 'data' => $html, | |
| 183 | + 'total' => intval( $counts['total'] ), | |
| 184 | + 'shown' => intval( $counts['shown'] ), | |
| 185 | + ], 200 ); | |
| 150 | 186 | } |
| 151 | 187 | |
| 152 | 188 | function rest_load_gallery_collection( $request ) { |
| 153 | 189 | try { |
| 154 | 190 | $params = $request->get_json_params( ); |
| 155 | - $gallery_id = $params['id']; | |
| 156 | - $search_slug = $params['search_slug']; | |
| 191 | + $gallery_id = $params['id'] ?? ''; | |
| 192 | + $search_slug = $params['search_slug'] ?? ''; | |
| 193 | + $gallery_atts = $params['gallery_atts'] ?? array(); | |
| 194 | + $gallery_atts = is_array( $gallery_atts ) ? $gallery_atts : array(); | |
| 157 | 195 | |
| 158 | 196 | $key = [ |
| 159 | 197 | 'gallery_id' => 'id', |
| 160 | 198 | 'wplr_collection_id' => 'wplr-collection', |
| 199 | + 'rml' => 'rml', | |
| 161 | 200 | ]; |
| 162 | 201 | |
| 163 | - $html = $this->core->gallery( [ $key[$search_slug] => $gallery_id ], [ 'isPreview' => false, 'isRest' => true ] ); | |
| 202 | + // This route is public (visitors open galleries from a collection), so everything it | |
| 203 | + // receives is untrusted. The gallery to render is decided by 'search_slug' + 'id' | |
| 204 | + // only: the caller-supplied attributes are stripped of anything that could point the | |
| 205 | + // gallery at other content. Without this, 'collection' could be used to inject | |
| 206 | + // arbitrary shortcodes (reported by JunHee CHO, 2026-09). | |
| 207 | + if ( !isset( $key[ $search_slug ] ) ) { | |
| 208 | + return new WP_REST_Response( [ 'success' => false, 'message' => __( 'Unknown gallery source.', MGL_DOMAIN ) ], 400 ); | |
| 209 | + } | |
| 210 | + $gallery_atts = array_diff_key( $gallery_atts, array_flip( self::$source_atts ) ); | |
| 211 | + | |
| 212 | + // The RML source is a folder path, the others are identifiers. | |
| 213 | + if ( $search_slug !== 'rml' ) { | |
| 214 | + $gallery_id = Meow_MGL_Core::sanitize_id( $gallery_id ); | |
| 215 | + if ( $gallery_id === '' ) { | |
| 216 | + return new WP_REST_Response( [ 'success' => false, 'message' => __( 'Invalid gallery ID.', MGL_DOMAIN ) ], 400 ); | |
| 217 | + } | |
| 218 | + } | |
| 219 | + | |
| 220 | + $shortcode_atts = array(); | |
| 221 | + $shortcode_atts[ $key[$search_slug] ] = $gallery_id; | |
| 222 | + $shortcode_atts = [...$shortcode_atts, ...$gallery_atts]; | |
| 223 | + | |
| 224 | + $html = $this->core->gallery( $shortcode_atts, [ 'isPreview' => false, 'isRest' => true ] ); | |
| 164 | 225 | $mwlData = json_encode( $this->core->get_rewritten_mwl_data( ) ); |
| 165 | 226 | return new WP_REST_Response( [ 'success' => true, 'data' => $html, 'mwl_data' => $mwlData ], 200 ); |
| 166 | 227 | } |
| 167 | 228 | catch ( Exception $e ) { |
| @@ -172,8 +233,15 @@ | ||
| 172 | 233 | function rest_all_settings( ) { |
| 173 | 234 | return new WP_REST_Response( [ 'success' => true, 'data' => $this->core->get_all_options( ) ], 200 ); |
| 174 | 235 | } |
| 175 | 236 | |
| 237 | + function rest_rml_folders( ) { | |
| 238 | + if ( ! Meow_MGL_RML::is_available() ) { | |
| 239 | + return new WP_REST_Response( [ 'success' => true, 'available' => false, 'data' => [] ], 200 ); | |
| 240 | + } | |
| 241 | + return new WP_REST_Response( [ 'success' => true, 'available' => true, 'data' => Meow_MGL_RML::get_all_folders() ], 200 ); | |
| 242 | + } | |
| 243 | + | |
| 176 | 244 | function rest_reset_options( ) { |
| 177 | 245 | $this->core->reset_options( ); |
| 178 | 246 | return new WP_REST_Response( [ 'success' => true, 'options' => $this->core->get_all_options( ) ], 200 ); |
| 179 | 247 | } |
| @@ -183,9 +251,9 @@ | ||
| 183 | 251 | global $wpdb; |
| 184 | 252 | $params = $request->get_json_params( ); |
| 185 | 253 | |
| 186 | 254 | $id = $params['id']; |
| 187 | - $medias = $params['medias']; | |
| 255 | + $medias = Meow_MGL_Core::normalize_medias( $params['medias'] ?? null ); | |
| 188 | 256 | $name = $params['name']; |
| 189 | 257 | $layout = $params['layout']; |
| 190 | 258 | $description = $params['description']; |
| 191 | 259 | $posts = $params['posts']; |
| @@ -195,14 +263,15 @@ | ||
| 195 | 263 | $lead_image_id = $params['lead_image_id']; |
| 196 | 264 | $order_by = $params['order_by']; |
| 197 | 265 | $is_post_mode = $params['is_post_mode']; |
| 198 | 266 | $is_hero_mode = $params['is_hero_mode']; |
| 267 | + $rml = $params['rml'] ?? null; | |
| 199 | 268 | |
| 200 | 269 | if ( !$name ) { |
| 201 | 270 | throw new Exception( __( 'Please enter a name for your shortcode.', MGL_DOMAIN )); |
| 202 | 271 | } |
| 203 | 272 | |
| 204 | - if ( !$is_post_mode && ( !$medias || !count( $medias['thumbnail_ids'] )) ) { | |
| 273 | + if ( !$is_post_mode && empty( $medias['thumbnail_ids'] ) ) { | |
| 205 | 274 | throw new Exception( __( 'Please select at least one image.', MGL_DOMAIN )); |
| 206 | 275 | } |
| 207 | 276 | |
| 208 | 277 | if ( $is_post_mode && $dynamic_source === 'posts' && ( !$posts && !$latest_posts )) { |
| @@ -212,8 +281,12 @@ | ||
| 212 | 281 | if ( $is_post_mode && $dynamic_source === 'tags' && !$tags ) { |
| 213 | 282 | throw new Exception( __( 'Please enter at least one tag.', MGL_DOMAIN )); |
| 214 | 283 | } |
| 215 | 284 | |
| 285 | + if ( $is_post_mode && $dynamic_source === 'rml' && empty( $rml ) ) { | |
| 286 | + throw new Exception( __( 'Please select a Real Media Library folder.', MGL_DOMAIN )); | |
| 287 | + } | |
| 288 | + | |
| 216 | 289 | if ( $is_hero_mode && !$is_post_mode ) { |
| 217 | 290 | throw new Exception( __( 'Hero mode is only available for post mode.', MGL_DOMAIN )); |
| 218 | 291 | } |
| 219 | 292 | |
| @@ -238,9 +311,10 @@ | ||
| 238 | 311 | 'is_hero_mode' => $is_hero_mode ? 1 : 0, |
| 239 | 312 | 'posts' => $posts ? serialize( $posts ) : null, |
| 240 | 313 | 'latest_posts' => $latest_posts, |
| 241 | 314 | 'tags' => serialize( $tags ), |
| 242 | - 'dynamic_source' => $dynamic_source | |
| 315 | + 'dynamic_source' => $dynamic_source, | |
| 316 | + 'rml' => $rml | |
| 243 | 317 | ]; |
| 244 | 318 | |
| 245 | 319 | if ( $exists ) { |
| 246 | 320 | // Update existing record |
| @@ -379,9 +453,9 @@ | ||
| 379 | 453 | $galleries[$gallery['id']] = [ |
| 380 | 454 | 'name' => $gallery['name'], |
| 381 | 455 | 'description' => $gallery['description'], |
| 382 | 456 | 'layout' => $gallery['layout'], |
| 383 | - 'medias' => unserialize( $gallery['medias'] ), | |
| 457 | + 'medias' => Meow_MGL_Core::hydrate_medias( maybe_unserialize( $gallery['medias'] ) ), | |
| 384 | 458 | 'is_post_mode' => ( bool )$gallery['is_post_mode'], |
| 385 | 459 | 'hero' => ( bool )$gallery['is_hero_mode'], |
| 386 | 460 | 'posts' => $gallery['posts'] ? unserialize( $gallery['posts'] ) : null, |
| 387 | 461 | 'latest_posts' => $gallery['latest_posts'], |
| @@ -491,24 +565,21 @@ | ||
| 491 | 565 | "AND p.ID NOT IN ( " . implode( ', ', array_fill( 0, count( $except ), '%s' )) . " )", $except |
| 492 | 566 | ) : ''; |
| 493 | 567 | $join_clause = ''; |
| 494 | 568 | if ( $unusedImages ) { |
| 495 | - // Retrieve the serialized option from the database | |
| 496 | - $meow_gallery_shortcodes = get_option( 'mgl_shortcodes' ); | |
| 569 | + // Every image used by a gallery, read from the galleries table (this used to read the | |
| 570 | + // old 'mgl_shortcodes' option, which isn't written anymore since the migration). | |
| 571 | + $shortcodes_table = $wpdb->prefix . 'mgl_gallery_shortcodes'; | |
| 572 | + Meow_MGL_Migrations::check_db(); | |
| 497 | 573 | |
| 498 | - // Deserialize the option to get the array | |
| 499 | - $shortcodes_array = maybe_unserialize( $meow_gallery_shortcodes ); | |
| 500 | - | |
| 501 | - // Extract all thumbnail IDs from the array | |
| 502 | 574 | $used_thumbnail_ids = []; |
| 503 | - foreach ( $shortcodes_array as $shortcode ) { | |
| 504 | - if ( isset( $shortcode['medias']['thumbnail_ids'] ) && is_array( $shortcode['medias']['thumbnail_ids'] ) ) { | |
| 505 | - $used_thumbnail_ids = array_merge( $used_thumbnail_ids, $shortcode['medias']['thumbnail_ids'] ); | |
| 506 | - } | |
| 575 | + foreach ( $wpdb->get_col( "SELECT medias FROM $shortcodes_table" ) as $medias ) { | |
| 576 | + $medias = Meow_MGL_Core::normalize_medias( maybe_unserialize( $medias ) ); | |
| 577 | + $used_thumbnail_ids = array_merge( $used_thumbnail_ids, $medias['thumbnail_ids'] ); | |
| 507 | 578 | } |
| 508 | 579 | |
| 509 | 580 | // Make sure the IDs are integers |
| 510 | - $used_thumbnail_ids = array_map( 'intval', $used_thumbnail_ids ); | |
| 581 | + $used_thumbnail_ids = array_unique( array_map( 'intval', $used_thumbnail_ids ) ); | |
| 511 | 582 | |
| 512 | 583 | // Include the NOT IN clause to exclude used thumbnail IDs |
| 513 | 584 | if ( !empty( $used_thumbnail_ids ) ) { |
| 514 | 585 | $placeholders = implode( ',', array_fill( 0, count( $used_thumbnail_ids ), '%d' ) ); |
| @@ -591,8 +662,33 @@ | ||
| 591 | 662 | 'data' => $this->core->get_gallery_images( $image_ids, $atts, $layout, $size ) |
| 592 | 663 | ], 200 ); |
| 593 | 664 | } |
| 594 | 665 | |
| 666 | + // Applies WordPress's own visibility rules to a raw posts query: published posts for everyone, | |
| 667 | + // other people's drafts only with edit_others_posts, other people's private posts only with | |
| 668 | + // read_private_posts, and your own in both cases. 'upload_files' (the capability gating this | |
| 669 | + // REST controller) is held by Authors, who must not see the whole site's unpublished content. | |
| 670 | + private function get_post_status_clause( $alias = 'p', $post_type = 'post' ) { | |
| 671 | + global $wpdb; | |
| 672 | + | |
| 673 | + $post_type_object = get_post_type_object( $post_type ); | |
| 674 | + $read_private_cap = $post_type_object ? $post_type_object->cap->read_private_posts : 'read_private_posts'; | |
| 675 | + $edit_others_cap = $post_type_object ? $post_type_object->cap->edit_others_posts : 'edit_others_posts'; | |
| 676 | + | |
| 677 | + $user_id = get_current_user_id(); | |
| 678 | + $clause = "AND ( $alias.post_status = 'publish'"; | |
| 679 | + | |
| 680 | + $clause .= current_user_can( $read_private_cap ) | |
| 681 | + ? " OR $alias.post_status = 'private'" | |
| 682 | + : $wpdb->prepare( " OR ( $alias.post_status = 'private' AND $alias.post_author = %d )", $user_id ); | |
| 683 | + | |
| 684 | + $clause .= current_user_can( $edit_others_cap ) | |
| 685 | + ? " OR $alias.post_status = 'draft'" | |
| 686 | + : $wpdb->prepare( " OR ( $alias.post_status = 'draft' AND $alias.post_author = %d )", $user_id ); | |
| 687 | + | |
| 688 | + return $clause . " ) "; | |
| 689 | + } | |
| 690 | + | |
| 595 | 691 | function rest_fetch_posts( $request ) { |
| 596 | 692 | try { |
| 597 | 693 | $params = $request->get_json_params(); |
| 598 | 694 | $search = isset($params['search']) ? $params['search'] : ''; |
| @@ -607,8 +703,13 @@ | ||
| 607 | 703 | $searchPlaceholder, |
| 608 | 704 | $searchPlaceholder |
| 609 | 705 | ) : ''; |
| 610 | 706 | |
| 707 | + // The same clause is used by both queries on purpose: the search also matches | |
| 708 | + // post_content, so a count taken over a wider set than the rows would let a user probe | |
| 709 | + // the body of posts they cannot read (reported by Kaan Ă–zbek, 2026-09). | |
| 710 | + $where_status_clause = $this->get_post_status_clause( 'p' ); | |
| 711 | + | |
| 611 | 712 | $posts = $wpdb->get_results( |
| 612 | 713 | $wpdb->prepare( |
| 613 | 714 | "SELECT p.ID, p.post_title, p.post_date, p.post_status, u.display_name as author |
| 614 | 715 | FROM $wpdb->posts p |
| @@ -613,9 +714,9 @@ | ||
| 613 | 714 | "SELECT p.ID, p.post_title, p.post_date, p.post_status, u.display_name as author |
| 614 | 715 | FROM $wpdb->posts p |
| 615 | 716 | LEFT JOIN $wpdb->users u ON p.post_author = u.ID |
| 616 | 717 | WHERE p.post_type = 'post' |
| 617 | - AND p.post_status IN ('publish', 'draft', 'private') | |
| 718 | + $where_status_clause | |
| 618 | 719 | $where_search_clause |
| 619 | 720 | ORDER BY p.post_date DESC |
| 620 | 721 | LIMIT %d, %d", |
| 621 | 722 | $offset, |
| @@ -627,9 +728,9 @@ | ||
| 627 | 728 | $posts_count = (int)$wpdb->get_var( |
| 628 | 729 | "SELECT COUNT(*) |
| 629 | 730 | FROM $wpdb->posts p |
| 630 | 731 | WHERE p.post_type = 'post' |
| 631 | - AND p.post_status IN ('publish', 'draft', 'private') | |
| 732 | + $where_status_clause | |
| 632 | 733 | $where_search_clause" |
| 633 | 734 | ); |
| 634 | 735 | |
| 635 | 736 | $data = array_map(function($post) { |