false, 'download_remote_image' => true, 'user_consent' => true, 'delete_on_uninstall' => false, 'disable_animations' => false, 'load_fonts_locally' => false, 'remove_dashboard_css' => false, ); /** * Private constructor. * * @since 1.0.0 * @access private */ private function __construct() { $this->load_settings(); $this->apply_hooks(); } /** * Get the single instance. * * @since 1.0.0 * @access public * * @return Settings_Manager */ public static function get_instance() { if ( is_null( self::$instance ) ) { self::$instance = new self(); } return self::$instance; } /** * Load settings from the database, merged with defaults. * * @since 1.0.0 * @access private * * @return void */ private function load_settings() { $saved = get_option( 'acumenpopup_settings', array() ); // One-time migration: new settings added in v2 must start at their defaults. // Prevents a DB value that was set before the settings UI existed from persisting. if ( ! get_option( 'acumenpopup_settings_v2' ) ) { $new_keys = array( 'delete_on_uninstall', 'disable_animations', 'load_fonts_locally', 'remove_dashboard_css' ); foreach ( $new_keys as $key ) { $saved[ $key ] = $this->defaults[ $key ]; } update_option( 'acumenpopup_settings', $saved ); update_option( 'acumenpopup_settings_v2', true ); } if ( is_array( $saved ) ) { foreach ( $saved as $k => $v ) { $saved[ $k ] = wp_validate_boolean( $v ); } } $this->settings = wp_parse_args( $saved, $this->defaults ); } /** * Get a single setting value as a boolean. * * @since 1.0.0 * @access public * * @param string $key Setting key. * @param bool $default Fallback value if key is missing. * * @return bool */ public function get( $key, $default = false ) { if ( ! array_key_exists( $key, $this->settings ) ) { return $default; } return (bool) $this->settings[ $key ]; } /** * Register all conditional WordPress hooks based on current settings. * * @since 1.0.0 * @access private * * @return void */ private function apply_hooks() { // ── Unfiltered Upload ──────────────────────────────────────────────── if ( $this->get( 'unfiltered_upload' ) ) { add_filter( 'upload_mimes', array( $this, 'allow_svg_json_upload' ) ); add_filter( 'wp_handle_upload_prefilter', array( $this, 'sanitize_svg_upload' ) ); add_filter( 'wp_check_filetype_and_ext', array( $this, 'fix_svg_filetype' ), 10, 5 ); } // ── User Consent ───────────────────────────────────────────────────── if ( $this->get( 'user_consent' ) ) { add_action( 'admin_notices', array( $this, 'maybe_show_consent_notice' ) ); add_action( 'wp_ajax_acumenpopup_dismiss_notice', array( $this, 'ajax_dismiss_notice' ) ); } // ── Disable Animations ─────────────────────────────────────────────── if ( $this->get( 'disable_animations' ) ) { add_action( 'wp_enqueue_scripts', array( $this, 'disable_popup_animations' ), 9999 ); } // ── Load Fonts Locally ─────────────────────────────────────────────── if ( $this->get( 'load_fonts_locally' ) ) { add_filter( 'acumenpopup_google_font_url', array( $this, 'use_privacy_font_url' ) ); } // ── Remove Dashboard CSS ───────────────────────────────────────────── if ( $this->get( 'remove_dashboard_css' ) ) { add_action( 'admin_enqueue_scripts', array( $this, 'dequeue_dashboard_css' ), 9999 ); } } // ========================================================================= // Unfiltered Upload // ========================================================================= /** * Add SVG and JSON to allowed upload MIME types. * * @since 1.0.0 * @access public * * @param array $mimes Allowed MIME types. * * @return array */ public function allow_svg_json_upload( $mimes ) { $mimes['svg'] = 'image/svg+xml'; $mimes['svgz'] = 'image/svg+xml'; $mimes['json'] = 'application/json'; return $mimes; } /** * Sanitize SVG files before they are saved to disk. * * @since 1.0.0 * @access public * * @param array $file Uploaded file data. * * @return array */ public function sanitize_svg_upload( $file ) { if ( ! isset( $file['tmp_name'] ) || ! isset( $file['type'] ) ) { return $file; } if ( 'image/svg+xml' !== $file['type'] ) { return $file; } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents $content = file_get_contents( $file['tmp_name'] ); if ( false === $content ) { $file['error'] = esc_html__( 'Cannot read uploaded SVG file.', 'acumen-popup' ); return $file; } $sanitized = $this->clean_svg( $content ); if ( false === $sanitized ) { $file['error'] = esc_html__( 'SVG file failed the security check and was rejected.', 'acumen-popup' ); return $file; } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents file_put_contents( $file['tmp_name'], $sanitized ); return $file; } /** * Fix WordPress MIME detection for SVG files (WP 5.1+ strips unrecognised types). * * @since 1.0.0 * @access public * * @param array $data MIME data. * @param string $file Full path to file. * @param string $filename File name. * @param array|null $mimes Allowed MIME types. * @param string|bool $real_mime Real MIME type detected. * * @return array */ public function fix_svg_filetype( $data, $file, $filename, $mimes, $real_mime = false ) { if ( ! in_array( strtolower( pathinfo( $filename, PATHINFO_EXTENSION ) ), array( 'svg', 'svgz' ), true ) ) { return $data; } $data['ext'] = 'svg'; $data['type'] = 'image/svg+xml'; return $data; } /** * Remove dangerous elements and attributes from SVG markup. * * @since 1.0.0 * @access private * * @param string $svg Raw SVG content. * * @return string|false Sanitized SVG or false if it looks malicious. */ private function clean_svg( $svg ) { // Strip XML declaration and DOCTYPE to prevent entity injection. $svg = preg_replace( '/<\?xml[^>]*\?>/i', '', $svg ); $svg = preg_replace( '/]*>/i', '', $svg ); // Reject if