0 ) { return min( GATEWELL_DEFAULT_MAX_FILESIZE, $server_max ); } return GATEWELL_DEFAULT_MAX_FILESIZE; } /** * One-time migration from old cium_ prefix to gatewell_ prefix. * * Copies all old cium_* options to their new gatewell_* keys, * deletes the old options, and cleans up old transients. * * @since 1.2.0 */ function gatewell_maybe_migrate_options() { // Check if old option exists (indicates pre-rename install). if ( false === get_option( 'cium_db_version' ) ) { return; } $option_map = array( 'cium_form_fields' => 'gatewell_form_fields', 'cium_field_minimums' => 'gatewell_field_minimums', 'cium_size_limits' => 'gatewell_size_limits', 'cium_compression_quality' => 'gatewell_compression_quality', 'cium_format_conversion' => 'gatewell_format_conversion', 'cium_original_storage' => 'gatewell_original_storage', 'cium_allowed_formats' => 'gatewell_allowed_formats', 'cium_ui_options' => 'gatewell_ui_options', 'cium_access_roles' => 'gatewell_access_roles', 'cium_post_types' => 'gatewell_post_types', 'cium_db_version' => 'gatewell_db_version', 'cium_upload_rules' => 'gatewell_upload_rules', 'cium_rules_admin_only' => 'gatewell_rules_admin_only', ); foreach ( $option_map as $old_key => $new_key ) { $value = get_option( $old_key ); if ( false !== $value ) { update_option( $new_key, $value ); delete_option( $old_key ); } } delete_transient( 'cium_heic_support_check' ); } gatewell_maybe_migrate_options(); /** * One-time migration from the old imagelock_ prefix to gatewell_. * * The plugin was renamed from "ImageLock" to "Gatewell" in 1.6.0. This copies * every imagelock_* option across to its gatewell_* name and removes the old * one, so existing installs keep their settings. * * Unlike the cium_ map above this discovers the option names from the database * rather than listing them, because several are generated at runtime * (imagelock_ai_usage_YYYY-MM) and so cannot be enumerated ahead of time. * Values are moved with the options API rather than a bare SQL UPDATE so the * options cache stays coherent. * * The matching post meta keys and analytics tables are migrated separately, in * Migration (db version 4.0.0), because they need $wpdb and can wait for * admin_init. * * @since 1.6.0 */ function gatewell_maybe_migrate_imagelock_options() { // Cheap guard: all three are autoloaded, so on a fresh install this costs // no extra query and bails immediately. if ( false === get_option( 'imagelock_db_version' ) && false === get_option( 'imagelock_form_fields' ) && false === get_option( 'imagelock_size_limits' ) ) { return; } global $wpdb; // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery // phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching $old_names = $wpdb->get_col( $wpdb->prepare( "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s", $wpdb->esc_like( 'imagelock_' ) . '%' ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.NoCaching // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery foreach ( $old_names as $old_key ) { $new_key = 'gatewell_' . substr( $old_key, strlen( 'imagelock_' ) ); $value = get_option( $old_key ); // Never overwrite a gatewell_* value that already exists - that would // mean the migration already ran and the user has since changed the // setting. Deleting the stale old key is still safe. if ( false !== $value && false === get_option( $new_key ) ) { update_option( $new_key, $value ); } delete_option( $old_key ); } delete_transient( 'imagelock_heic_support_check' ); delete_transient( 'imagelock_dropbox_notice' ); } gatewell_maybe_migrate_imagelock_options(); /** * Lowercase a string, multibyte-aware when the mbstring extension is available. * * mbstring is optional in PHP and is genuinely absent on some hosts, so calling * mb_strtolower() directly would fatal there. Falls back to strtolower(), which * leaves non-ASCII bytes untouched rather than corrupting them. * * @param string $string String to lowercase. * @return string Lowercased string. * @since 1.6.1 */ function gatewell_strtolower( $string ) { $string = (string) $string; if ( function_exists( 'mb_strtolower' ) ) { return mb_strtolower( $string, 'UTF-8' ); } return strtolower( $string ); } /** * Count characters in a string, multibyte-aware when mbstring is available. * * Falls back to strlen() (byte count) when mbstring is not installed. * * @param string $string String to measure. * @return int Character count. * @since 1.6.1 */ function gatewell_strlen( $string ) { $string = (string) $string; if ( function_exists( 'mb_strlen' ) ) { return (int) mb_strlen( $string, 'UTF-8' ); } return strlen( $string ); } /** * Autoload plugin classes. * * @since 1.0.0 */ function gatewell_autoload() { $class_dir = GATEWELL_INCLUDES_DIR; spl_autoload_register( function ( $class ) use ( $class_dir ) { // Only load classes from this plugin. if ( strpos( $class, 'Gatewell\\' ) !== 0 ) { return; } // Remove namespace prefix. $class_name = str_replace( 'Gatewell\\', '', $class ); // Handle Admin namespace (e.g., Admin\Admin_Page). if ( strpos( $class_name, 'Admin\\' ) === 0 ) { $class_name = str_replace( 'Admin\\', '', $class_name ); $file = $class_dir . 'admin/class-' . strtolower( str_replace( '_', '-', $class_name ) ) . '.php'; } elseif ( strpos( $class_name, 'Analytics\\' ) === 0 ) { $class_name = str_replace( 'Analytics\\', '', $class_name ); $file = $class_dir . 'analytics/class-' . strtolower( str_replace( '_', '-', $class_name ) ) . '.php'; } else { // Convert to filename for root namespace classes. $file = $class_dir . 'class-' . strtolower( str_replace( '_', '-', $class_name ) ) . '.php'; } // Load file if it exists. if ( file_exists( $file ) ) { require_once $file; } } ); } gatewell_autoload(); /** * Load core plugin classes early. * * @since 1.0.0 */ require_once GATEWELL_INCLUDES_DIR . 'class-migration.php'; require_once GATEWELL_INCLUDES_DIR . 'class-feature-flags.php'; require_once GATEWELL_INCLUDES_DIR . 'class-format-capabilities.php'; require_once GATEWELL_INCLUDES_DIR . 'class-plugin-compatibility.php'; require_once GATEWELL_INCLUDES_DIR . 'class-rule-storage.php'; require_once GATEWELL_INCLUDES_DIR . 'class-rule-matcher.php'; require_once GATEWELL_INCLUDES_DIR . 'class-context-detector.php'; require_once GATEWELL_INCLUDES_DIR . 'class-file-type-controls-validator.php'; require_once GATEWELL_INCLUDES_DIR . 'analytics/class-analytics-cache.php'; require_once GATEWELL_INCLUDES_DIR . 'analytics/class-analytics-logger.php'; require_once GATEWELL_INCLUDES_DIR . 'analytics/class-dashboard-widget.php'; require_once GATEWELL_INCLUDES_DIR . 'analytics/class-analytics-page.php'; require_once GATEWELL_INCLUDES_DIR . 'analytics/class-analytics-ajax.php'; /** * Note: Translations are automatically loaded by WordPress.org since WP 4.6+ * No need to manually call load_plugin_textdomain() for plugins hosted on WordPress.org * * @since 1.0.0 */ /** * Initialize plugin with bridge table support * * @since 1.0.0 */ function gatewell_init_with_migration() { // Ensure migrations run on every init if ( is_admin() ) { Gatewell\Migration::maybe_run_migrations(); } // Instantiate handlers. new Gatewell\Upload_Handler(); // Initialize admin page (only in admin context). if ( is_admin() ) { new Gatewell\Admin\Admin_Page(); // Initialize rules page (only if feature flag is enabled). if ( GATEWELL_Feature_Flags::is_rules_enabled() ) { new Gatewell\Admin\Rules_Page(); } } // Enqueue scripts and styles. add_action( 'admin_enqueue_scripts', 'gatewell_enqueue_admin_scripts' ); // Redirect media-new.php to upload.php with modal. add_action( 'load-media-new.php', 'gatewell_redirect_media_new_page' ); // Declare Gatewell is active (for ImageAudit integration). // // `image_lock_is_active` is the legacy hook name and is kept because the // ImageAudit plugin already listens on it; dropping it would silently break // that integration on every install that has both plugins. `gatewell_is_active` // is the name to use going forward. Both fire; neither is deprecated yet. apply_filters( 'gatewell_is_active', true ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Published integration contract. The ImageAudit plugin already listens on this legacy name; dropping it would silently break that integration on every install running both plugins. The prefixed `gatewell_is_active` fires immediately above with an identical payload. apply_filters( 'image_lock_is_active', true ); // Initialize analytics — dashboard widget, Analytics page and its AJAX endpoints. Gatewell\Analytics\Analytics_Logger::get_instance(); // The custom recurrence must exist before anything is scheduled against it. add_filter( 'cron_schedules', array( 'Gatewell\Analytics\Analytics_Logger', 'register_cron_schedules' ) ); // phpcs:ignore WordPress.WP.CronInterval.ChangeDetected -- 30-day interval, far above the 15-minute threshold this sniff guards. Gatewell\Analytics\Analytics_Logger::schedule_cron(); Gatewell\Analytics\Dashboard_Widget::init(); Gatewell\Analytics\Analytics_Page::init(); Gatewell\Analytics\Analytics_Ajax::init(); } // Use the new init function with migration support add_action( 'plugins_loaded', 'gatewell_init_with_migration' ); /** * Redirect media-new.php to upload.php. * * Redirects the legacy full-page uploader to the media library * where our modal interceptor works. * * @since 1.0.0 */ function gatewell_redirect_media_new_page() { // Redirect to media library with a flag to auto-open modal. wp_safe_redirect( admin_url( 'upload.php?gatewell_open_modal=1' ) ); exit; } /** * Enqueue admin scripts and styles. * * @param string $hook The current admin page hook. * @since 1.0.0 */ function gatewell_enqueue_admin_scripts( $hook ) { // Check user capability. if ( ! current_user_can( 'upload_files' ) ) { return; } // Get current post ID if available (0 if not on a post edit screen). $post_id = 0; global $post; if ( $post && isset( $post->ID ) ) { $post_id = $post->ID; } // Get WordPress's max upload file size. $max_upload_size = wp_max_upload_size(); $max_upload_size_mb = round( $max_upload_size / 1024 / 1024 ); // Enqueue modal styles. wp_enqueue_style( 'gatewell-modal-styles', GATEWELL_PLUGIN_URL . 'assets/css/media-upload-modal.css', array(), GATEWELL_VERSION ); // Enqueue Cropper.js (bundled locally for WordPress.org compliance). // Cropper.js v2 is built on custom elements and carries its own styles in // shadow DOM, so there is no companion stylesheet to enqueue. wp_enqueue_script( 'cropperjs', GATEWELL_PLUGIN_URL . 'assets/vendor/cropperjs/cropper.min.js', array(), '2.2.0', true ); // Enqueue modal JavaScript. wp_enqueue_script( 'gatewell-modal-script', GATEWELL_PLUGIN_URL . 'assets/js/media-upload-modal.js', array( 'cropperjs' ), GATEWELL_VERSION, true ); // Enqueue media modal interceptor JavaScript (must load after modal script and WordPress media APIs). wp_enqueue_script( 'gatewell-interceptor-script', GATEWELL_PLUGIN_URL . 'assets/js/media-modal-interceptor.js', array( 'gatewell-modal-script', 'wp-util', 'media-editor', 'wp-api-fetch', 'wp-blocks', 'wp-data' ), GATEWELL_VERSION, true ); // Enqueue Gutenberg block editor interceptor (for Image blocks, Gallery blocks, etc.). wp_enqueue_script( 'gatewell-gutenberg-interceptor', GATEWELL_PLUGIN_URL . 'assets/js/gutenberg-interceptor.js', array( 'gatewell-interceptor-script' ), GATEWELL_VERSION, true ); // Get admin settings or use defaults with type checking. $default_size_limits = array( 'max_width' => GATEWELL_DEFAULT_MAX_WIDTH, 'max_height' => GATEWELL_DEFAULT_MAX_HEIGHT, 'max_filesize' => gatewell_default_max_filesize(), 'min_width' => GATEWELL_DEFAULT_MIN_WIDTH, 'min_height' => GATEWELL_DEFAULT_MIN_HEIGHT, ); $default_form_fields = array( 'alt_text' => array( 'show' => true, 'required' => true ), 'title' => array( 'show' => true, 'required' => true ), 'caption' => array( 'show' => false, 'required' => false ), ); $size_limits = get_option( 'gatewell_size_limits', $default_size_limits ); if ( ! is_array( $size_limits ) ) { $size_limits = $default_size_limits; } $size_limits = wp_parse_args( $size_limits, $default_size_limits ); $form_fields = get_option( 'gatewell_form_fields', $default_form_fields ); if ( ! is_array( $form_fields ) ) { $form_fields = $default_form_fields; } // Get field minimum character requirements. $default_field_minimums = array( 'alt_text' => 5, 'title' => 5, 'caption' => 0, ); $field_minimums = get_option( 'gatewell_field_minimums', $default_field_minimums ); if ( ! is_array( $field_minimums ) ) { $field_minimums = $default_field_minimums; } $field_minimums = wp_parse_args( $field_minimums, $default_field_minimums ); // Get allowed MIME types using the Format_Validator utility. $allowed_mime_types = Gatewell\Format_Validator::get_allowed_mime_types(); // Ensure max file size doesn't exceed server limit. $max_file_size = min( $size_limits['max_filesize'], $max_upload_size ); // Get UI options. $ui_options = get_option( 'gatewell_ui_options', array( 'show_wp_uploader_button' => true ) ); // Default to true if not set, otherwise use the saved value (which is a boolean). $show_wp_uploader_button = isset( $ui_options['show_wp_uploader_button'] ) ? (bool) $ui_options['show_wp_uploader_button'] : true; // Rules system context (Phase 3). $rules_enabled = GATEWELL_Feature_Flags::is_rules_enabled() && GATEWELL_Feature_Flags::can_user_access_rules(); $context_data = GATEWELL_Context_Detector::get_js_context( $post_id ); // Per-category upload size limits passed to the uploader. // Only categories the build can actually accept are listed: Format_Validator // rejects everything but images before the uploader is reached. /* * The image limit is the effective one, not a separate hard-coded number. * The modal reads fileTypeSizeLimits.image in preference to maxFileSize * (see getFileSizeLimit() in media-upload-modal.js), so a standalone default * here silently overrides both the admin's configured maximum and the * server's. That is how the uploader came to advertise "max 10 MB" on a site * whose setting said 6 MB and whose PHP upload_max_filesize was 2 MB. */ $effective_max_mb = max( 1, (int) round( $max_file_size / 1024 / 1024 ) ); $default_file_type_limits = array( 'image' => $effective_max_mb, ); $file_type_size_limits = get_option( 'gatewell_file_type_size_limits', $default_file_type_limits ); if ( ! is_array( $file_type_size_limits ) ) { $file_type_size_limits = $default_file_type_limits; } $file_type_size_limits = wp_parse_args( $file_type_size_limits, $default_file_type_limits ); // Never ship a limit for a category this build cannot accept, even if a stored // option (e.g. from an earlier install) still carries one. $file_type_size_limits = array_intersect_key( $file_type_size_limits, $default_file_type_limits ); // A stored per-category limit must still never promise more than the server // will accept, or the uploader validates a file the request cannot carry. $server_max_mb = max( 1, (int) round( $max_upload_size / 1024 / 1024 ) ); foreach ( $file_type_size_limits as $category => $limit ) { $file_type_size_limits[ $category ] = min( max( 1, (int) $limit ), $server_max_mb ); } // Pass settings to JavaScript via wp_localize_script. // Note: Explicitly convert boolean to ensure it's passed correctly to JavaScript. $settings = array( 'nonce' => wp_create_nonce( 'gatewell_nonce' ), 'ajaxUrl' => admin_url( 'admin-ajax.php' ), 'postId' => $post_id, 'maxWidth' => $size_limits['max_width'], 'maxHeight' => $size_limits['max_height'], 'minWidth' => $size_limits['min_width'], 'minHeight' => $size_limits['min_height'], 'maxFileSize' => $max_file_size, 'maxFileSizeMB' => round( $max_file_size / 1024 / 1024 ), 'allowedMimeTypes' => $allowed_mime_types, 'formFields' => $form_fields, 'fieldMinimums' => $field_minimums, 'showWpUploaderButton' => $show_wp_uploader_button, 'rulesEnabled' => $rules_enabled, 'uploadContext' => $context_data, 'fileTypeSizeLimits' => $file_type_size_limits, 'requireUniqueFields' => (bool) get_option( 'gatewell_require_unique_fields', true ), 'uniquenessThreshold' => (int) get_option( 'gatewell_uniqueness_threshold', 75 ), ); wp_localize_script( 'gatewell-interceptor-script', 'gatewellSettings', $settings ); // Inject modal template into footer. add_action( 'admin_footer', 'gatewell_inject_modal_template' ); } /** * Inject modal HTML template into admin footer. * * @since 1.0.0 */ function gatewell_inject_modal_template() { // Check user capability. if ( ! current_user_can( 'upload_files' ) ) { return; } // Include modal template. $template_path = GATEWELL_PLUGIN_DIR . 'templates/media-upload-modal.html'; if ( file_exists( $template_path ) ) { include $template_path; } } /** * Activation hook. * * @since 1.0.0 */ function gatewell_activate() { // The GD extension is used for server-side image work (resize, compress, // format conversion), but it is not a requirement for the plugin to run. // The upload modal, required alt text and titles, filename rules, format // gating and dimension limits are all independent of it, the crop is // performed in the browser, and Upload_Handler stores the uploaded bytes // unprocessed when Image_Processor cannot be constructed. // // Activation therefore never aborts. gatewell_gd_missing_notice() re-checks // the extension on every admin screen load, so the site owner is told what // is unavailable instead of being handed a wp_die() they cannot act on, and // the warning disappears by itself once the host enables GD. // Load and run migrations require_once GATEWELL_INCLUDES_DIR . 'class-migration.php'; Gatewell\Migration::init(); Gatewell\Migration::maybe_run_migrations(); // Store HEIC support status in transient for admin notice. $imagick_available = extension_loaded( 'imagick' ); set_transient( 'gatewell_heic_support_check', $imagick_available ? 'available' : 'unavailable', 24 * HOUR_IN_SECONDS ); // Start the clock for the one-time review prompt (no-op if already set). add_option( 'gatewell_installed_at', time(), '', false ); // Flag a one-time redirect to the Gatewell dashboard (handled on admin_init). set_transient( 'gatewell_activation_redirect', 1, 60 ); // gatewell_db_version holds the SCHEMA version and is owned by Migration, which // has just written it. Seeding it with the plugin version here only worked // because add_option() no-ops on an existing key; reorder the two calls and the // mismatch would make maybe_run_migrations() re-run forever. } register_activation_hook( __FILE__, 'gatewell_activate' ); /** * Send the user to the Gatewell dashboard once, right after activation. * * Skipped for bulk and network activation, AJAX requests and users who cannot * open the dashboard, so it never hijacks an install-several-plugins flow. * * @since 1.7.0 */ function gatewell_maybe_activation_redirect() { if ( ! get_transient( 'gatewell_activation_redirect' ) ) { return; } delete_transient( 'gatewell_activation_redirect' ); if ( wp_doing_ajax() || is_network_admin() || isset( $_GET['activate-multi'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- core sets this flag, nothing is read from it. return; } if ( ! current_user_can( 'gatewell_manage' ) ) { return; } wp_safe_redirect( admin_url( 'admin.php?page=gatewell-dashboard' ) ); exit; } add_action( 'admin_init', 'gatewell_maybe_activation_redirect' ); /** * Whether this is a paid build. * * GATEWELL_PRO is defined only inside a region the free build physically strips, * so it does not exist at all in the free edition. Never reference it bare from * code that ships in both editions. * * @since 1.7.0 * * @return bool */ function gatewell_is_paid_build() { return defined( 'GATEWELL_PRO' ) && GATEWELL_PRO; } /** * Support and review URLs on WordPress.org. * * @since 1.7.0 * * @param string $type 'support' or 'review'. * @return string */ function gatewell_feedback_url( $type ) { return 'review' === $type ? 'https://wordpress.org/support/plugin/gatewell/reviews/#new-post' : 'https://wordpress.org/support/plugin/gatewell/'; } /** * Handle the "don't ask again" link on the review prompt. * * Stored per user, so one admin dismissing it does not hide it for another. * * @since 1.7.0 */ function gatewell_handle_feedback_dismiss() { if ( ! isset( $_GET['gatewell_dismiss_feedback'] ) ) { return; } if ( ! current_user_can( 'manage_options' ) ) { return; } check_admin_referer( 'gatewell_dismiss_feedback' ); update_user_meta( get_current_user_id(), 'gatewell_feedback_dismissed', 1 ); wp_safe_redirect( remove_query_arg( array( 'gatewell_dismiss_feedback', '_wpnonce' ) ) ); exit; } add_action( 'admin_init', 'gatewell_handle_feedback_dismiss' ); /** * One-time, dismissible review prompt. * * Shown only on Gatewell's own screens, only to administrators, only in the free * edition, and only after real use: at least 10 accepted uploads and 7 days since * install. Nothing is offered in exchange for a review. * * @since 1.7.0 */ function gatewell_feedback_notice() { if ( gatewell_is_paid_build() || ! current_user_can( 'manage_options' ) ) { return; } $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only screen check. if ( 0 !== strpos( $page, 'gatewell-' ) ) { return; } if ( get_user_meta( get_current_user_id(), 'gatewell_feedback_dismissed', true ) ) { return; } // Installs that pre-date this option start their clock the first time we see them. $installed_at = (int) get_option( 'gatewell_installed_at', 0 ); if ( ! $installed_at ) { add_option( 'gatewell_installed_at', time(), '', false ); return; } $uploads = (int) get_option( 'gatewell_upload_count', 0 ); if ( $uploads < 10 || ( time() - $installed_at ) < 7 * DAY_IN_SECONDS ) { return; } $dismiss_url = wp_nonce_url( add_query_arg( 'gatewell_dismiss_feedback', '1' ), 'gatewell_dismiss_feedback' ); ?>
' . esc_html__( 'Support', 'gatewell' ) . ''; } return $links; } add_filter( 'plugin_row_meta', 'gatewell_plugin_row_meta', 10, 2 ); /** * Admin notice when the GD extension is unavailable. * * GD is not required for the plugin to run — alt text, filename, format and * dimension enforcement are all independent of it — but every server-side * image operation (resize, crop, compress, format conversion) needs it. Rather * than blocking activation, explain what is missing and what it costs. * * The check is made at render time, not at activation, so the notice clears * itself the moment the host enables the extension. * * @since 1.6.3 */ function gatewell_gd_missing_notice() { if ( ! current_user_can( 'activate_plugins' ) ) { return; } if ( extension_loaded( 'gd' ) ) { return; } ?>