array( 'Site Under Maintenance', 'Coming soon', $name . ' will be back shortly', $name . ' is coming soon', ), 'message' => array( 'We are currently performing maintenance. We will be back soon.', 'We are updating our website. We will be back very soon.', ), ); } /** * Retourne tous les paramètres par défaut * @return array */ public static function get_defaults() { return [ 'maintenance_enabled' => false, 'page_mode' => 'maintenance', // 'maintenance' | 'coming_soon' // v2.9.174 — titre au nom du site et message utile par défaut // (nouvelles installations ; un texte enregistré n'est jamais touché). 'title' => self::default_title(), 'subtitle' => '', 'message' => __('We are updating our website. We will be back very soon.', '1001web-maintenance-launch-pages'), 'logo_url' => '', 'visitor_access_enabled' => false, 'visitor_password' => '', 'visitor_access_duration' => 86400, // secondes (24 h) // v2.9.174 : masqué par défaut (nouvelles installations) — un bouton // « Admin Login » visible de tous inquiétait les testeurs. 'show_admin_button' => false, // Nouvelles installations seulement (sites existants conservent leurs choix). 'bg_color_1' => '#1F2937', 'bg_color_1_opacity' => 100, 'bg_color_2' => '#0F172A', 'bg_color_2_opacity' => 100, 'title_color' => '#F97316', 'title_opacity' => 100, 'message_color' => '#FFEDD5', 'message_opacity' => 100, 'button_color' => '#F97316', 'button_opacity' => 100, 'bg_image_enabled' => false, 'bg_image_url' => '', 'bg_overlay_opacity' => 50, 'container_bg_color' => '#0F172A', 'container_bg_opacity' => 92, 'container_shadow_enabled'=> true, 'container_shadow_intensity' => 30, 'container_max_width' => 600, 'container_padding' => 60, 'container_border_radius' => 20, 'title_font' => 'Poppins', 'message_font' => 'Open Sans', 'title_size' => 42, 'message_size' => 18, 'animation_enabled' => true, 'logo_animation' => 'pulse', 'text_animation' => 'fadeIn', 'button_animation' => 'slideUp', 'animation_duration' => 1000, // ON par défaut : aucune ressource externe n'est chargée tant que // l'utilisateur ne l'a pas explicitement demandé (les IP visiteurs // ne partent pas chez Google). Polices système en repli. 'rgpd_mode' => true, 'seo_503_enabled' => true, 'retry_after_hours' => 24, 'noindex_enabled' => true, // v2.9.174 — pendant la maintenance, l'API REST de contenu // (/wp-json/wp/v2/) ne répond plus aux visiteurs non autorisés. 'rest_content_block' => true, 'show_powered_by' => false, // Crédit frontend opt-in (WP.org guideline 10). Remplace hide_powered_by. 'sidebar_enabled' => false, 'sidebar_position' => 'right', 'sidebar_tab_color' => '#667eea', 'social_facebook' => '', 'social_twitter' => '', 'social_instagram' => '', 'social_linkedin' => '', 'social_youtube' => '', 'social_tiktok' => '', 'contact_email' => '', 'contact_phone' => '', 'contact_whatsapp' => '', 'logo_size' => 150, 'custom_bg_enabled' => false, 'custom_bg_url' => '', 'purge_data_on_uninstall' => false, 'notification_enabled' => false, 'notification_button_text'=> __('Notify me', '1001web-maintenance-launch-pages'), 'notification_success_msg'=> __('Thank you! You will be notified as soon as the site is back online.', '1001web-maintenance-launch-pages'), 'notification_emails' => [], 'turnstile_enabled' => false, 'turnstile_site_key' => '', 'turnstile_secret_key' => '', 'turnstile_on_notification' => true, 'turnstile_on_visitor' => true, 'countdown_enabled' => false, 'countdown_date' => '', 'countdown_time' => '00:00', 'countdown_title' => __('Launching in', '1001web-maintenance-launch-pages'), 'countdown_end_text' => __('The site is now online!', '1001web-maintenance-launch-pages'), 'countdown_style' => 'boxes', 'countdown_show_days' => true, 'countdown_show_hours' => true, 'countdown_show_minutes' => true, 'countdown_show_seconds' => true, 'countdown_auto_disable' => true, 'custom_presets' => [], 'lottie_enabled' => false, 'lottie_url' => '', 'lottie_position' => 'above-title', 'lottie_size' => 200, 'lottie_offset_x' => 0, // décalage horizontal en px (-300..300) 'lottie_offset_y' => 0, // décalage vertical en px (-300..300) 'lottie_loop' => true, 'lottie_autoplay' => true, 'lottie_speed' => 1.0, // Decimal (0.25 a 3) : l'import le gardait entier 'video_bg_enabled' => false, 'video_bg_url' => '', 'video_bg_type' => 'youtube', 'video_bg_opacity' => 30, 'video_bg_muted' => true, 'parallax_enabled' => false, 'parallax_intensity' => 50, 'stats_enabled' => false, // Compteurs dans leurs propres options (mgenius_stats_visits + mgenius_stats). Voir MGENIUS_Settings::get_stats() / record_visit(). 'stats_start_date' => '', 'schedule_enabled' => false, 'schedule_started' => '', // Debut de la derniere plage demarree 'schedule_auto_on' => false, // Maintenance actuelle allumee par la planification 'schedule_start_date' => '', 'schedule_start_time' => '', 'schedule_end_date' => '', 'schedule_end_time' => '', 'schedule_timezone' => '', // Vide = fuseau du site (Reglages > General) 'whitelist_enabled' => false, 'whitelist_urls' => [], 'whitelist_ips' => [], 'whitelist_roles' => ['administrator'], // Empty for new installations (a saved list is kept): a user agent // is easy to fake, and serving the site to robots only can be // treated as cloaking by search engines. 'whitelist_user_agents' => [], 'blacklist_enabled' => false, 'blacklist_ips' => [], 'blacklist_user_agents' => [], 'blacklist_message' => __('Access denied.', '1001web-maintenance-launch-pages'), 'blacklist_logging' => false, 'custom_access_limit' => 25, ]; } /** * Charge les paramètres depuis la BDD avec merge des defaults * @return array */ public static function load() { $saved = get_option('mgenius_settings', []); return wp_parse_args($saved, self::get_defaults()); } /** * Lecture GARANTIE fraîche depuis la base de données. * * OBLIGATOIRE dans tout handler load-modify-save (AJAX admin) : sur les * environnements avec object cache persistant (Redis/Memcached), get_option() * peut retourner une copie périmée du tableau — le save() qui suit écrase * alors la DB avec cet état périmé. Bug concret : appliquer un preset * désactivait le mode maintenance (maintenance_enabled=false hérité du cache). * D'où l'invalidation du cache de l'option AVANT la lecture. * * @return array */ /** * Date « Y-m-d H:i:s » enregistree dans le fuseau du site * (current_time('mysql'), wp_date()) convertie en horodatage Unix reel. * strtotime() la lisait comme de l'UTC : decalage egal au fuseau horaire. * * @param string $datetime Date locale du site. * @return int 0 si la date est vide ou invalide. */ public static function local_to_timestamp($datetime) { if (empty($datetime) || !is_string($datetime)) { return 0; } try { $dt = new DateTime($datetime, wp_timezone()); } catch (Exception $e) { return 0; } return (int) $dt->getTimestamp(); } /** * Fuseau de la planification. Vide (defaut) ou invalide : le * fuseau du site. Auparavant, America/Montreal etait impose par defaut a * tous les sites, ou qu'ils soient. * * @param array $settings Reglages du plugin. * @return DateTimeZone */ public static function schedule_timezone($settings) { if (!empty($settings['schedule_timezone']) && is_string($settings['schedule_timezone'])) { try { return new DateTimeZone($settings['schedule_timezone']); } catch (Exception $e) { // Valeur invalide : on retombe sur le fuseau du site. } } return wp_timezone(); } /** * Outils de contraste (WCAG 2.x). Ils servent au rendu de la * page de maintenance : le texte des boutons prend automatiquement la * couleur lisible, quel que soit le style ou la couleur choisie. * * @param string $hex Couleur #rgb ou #rrggbb. * @return float Luminance relative (0 a 1). */ public static function color_luminance($hex) { $hex = preg_replace('/[^0-9A-Fa-f]/', '', (string) $hex); if (strlen($hex) === 3) { $hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2]; } if (strlen($hex) !== 6) { return 0.0; } $lum = 0.0; $weights = array(0.2126, 0.7152, 0.0722); for ($i = 0; $i < 3; $i++) { $c = hexdec(substr($hex, $i * 2, 2)) / 255; $c = ($c <= 0.03928) ? $c / 12.92 : pow(($c + 0.055) / 1.055, 2.4); $lum += $weights[$i] * $c; } return $lum; } /** * @return float Rapport de contraste entre deux couleurs (1 a 21). */ public static function color_contrast($a, $b) { $la = self::color_luminance($a); $lb = self::color_luminance($b); if ($la < $lb) { $t = $la; $la = $lb; $lb = $t; } return ($la + 0.05) / ($lb + 0.05); } /** * Couleur $front posee sur $back avec une opacite $alpha (0 a 1). * * @return string #rrggbb */ public static function color_mix($front, $back, $alpha) { $f = preg_replace('/[^0-9A-Fa-f]/', '', (string) $front); $b = preg_replace('/[^0-9A-Fa-f]/', '', (string) $back); if (strlen($f) === 3) { $f = $f[0] . $f[0] . $f[1] . $f[1] . $f[2] . $f[2]; } if (strlen($b) === 3) { $b = $b[0] . $b[0] . $b[1] . $b[1] . $b[2] . $b[2]; } if (strlen($f) !== 6) { $f = 'ffffff'; } if (strlen($b) !== 6) { $b = '000000'; } $alpha = max(0.0, min(1.0, (float) $alpha)); $out = '#'; for ($i = 0; $i < 3; $i++) { $v = hexdec(substr($f, $i * 2, 2)) * $alpha + hexdec(substr($b, $i * 2, 2)) * (1 - $alpha); $out .= str_pad(dechex((int) round($v)), 2, '0', STR_PAD_LEFT); } return $out; } /** * Blanc ou presque noir, selon ce qui se lit le mieux sur $bg. * * @return string */ public static function readable_text_on($bg) { return (self::color_contrast('#ffffff', $bg) >= self::color_contrast('#111827', $bg)) ? '#ffffff' : '#111827'; } public static function load_fresh() { wp_cache_delete('mgenius_settings', 'options'); wp_cache_delete('alloptions', 'options'); return self::load(); } /** * Reglages que seules une entree ou une sortie de maintenance * changent (interrupteur, assistant, planification, fin du compte a * rebours, activation et desactivation de l'extension). */ const TRANSITION_KEYS = ['maintenance_enabled', 'schedule_auto_on', 'schedule_started']; /** Raison du dernier echec de save() : 'busy' ou 'db'. */ private static $save_error = ''; /** * Sauvegarde les paramètres * * Toute ecriture passe par le verrou de transition. Hors * transition (formulaire, sauvegarde automatique, presets, import, Lottie, * statistiques, themes, migrations), l'etat de la maintenance * (TRANSITION_KEYS) est celui de la base, relu sous le verrou : une * sauvegarde partie avant une bascule ne la defait plus, dans un sens * comme dans l'autre. Une bascule en cours est attendue (20 s au plus). * * @param array $settings * @return bool True si la base contient ces reglages (ecrits, ou deja * identiques) ; false sinon (voir last_save_error()). */ public static function save($settings) { self::$save_error = ''; $main = class_exists('MGENIUS_Main'); $transition = $main && MGENIUS_Main::in_transition(); if ($main && !$transition && !MGENIUS_Main::begin_transition()) { self::$save_error = 'busy'; return false; } wp_cache_delete('mgenius_settings', 'options'); wp_cache_delete('alloptions', 'options'); $current = get_option('mgenius_settings', null); if (!$transition && is_array($settings) && is_array($current)) { foreach (self::TRANSITION_KEYS as $key) { if (array_key_exists($key, $current)) { $settings[$key] = $current[$key]; } else { unset($settings[$key]); } } } // MGENIUS_Main lit déjà cette option en SQL direct, l'autoload n'apportait aucun bénéfice. $saved = (null !== $current && maybe_serialize($current) === maybe_serialize($settings)) || update_option('mgenius_settings', $settings, false); if (!$saved) { wp_cache_delete('mgenius_settings', 'options'); $saved = maybe_serialize(get_option('mgenius_settings', null)) === maybe_serialize($settings); if (!$saved) { self::$save_error = 'db'; } } if ($main && !$transition) { MGENIUS_Main::end_transition(); } return $saved; } /** * Pourquoi le dernier save() a echoue : 'busy' (une bascule de * la maintenance n'a pas rendu la main a temps), 'db', ou '' (reussi). * * @return string */ public static function last_save_error() { return self::$save_error; } // --------------------------------------------------------------------- // visitor_token isolé de mgenius_settings // // Le token est la CLÉ DE SIGNATURE du cookie d'accès visiteur : // hash('sha256', $expiry . $token . SECURE_AUTH_KEY) // Il doit donc rester lisible en clair — le hacher (comme visitor_password) // rendrait toute vérification impossible. Le vrai problème n'est pas le // stockage en clair, c'est la SURFACE : mg_settings est exporté par // export_all_settings() et lu partout. // // Correctif : option dédiée, non autoloadée, jamais exportée, jamais migrée. // --------------------------------------------------------------------- /** * Nettoie un tableau de réglages importé depuis un fichier externe. * * Le fichier d'import est fourni par l'utilisateur : sa structure est * vérifiée, mais son contenu ne l'était pas. On ne conserve donc que les * clés connues, et chaque valeur est ramenée au type de son défaut. * Toute clé inconnue est écartée — un export trafiqué ne peut pas injecter * de réglage arbitraire. * * @param array $raw Réglages bruts issus du JSON. * @return array Réglages nettoyés. */ /** * Nettoie un preset importe depuis un fichier externe. * * Meme principe que sanitize_import() : un preset est un jeu de reglages de * design, ses valeurs finissent dans le CSS de la page publique. Le contenu * du fichier ne doit donc jamais etre stocke tel quel. * * @param mixed $raw Preset brut issu du JSON. * @return array Preset nettoye, ou [] s'il est inexploitable. */ public static function sanitize_preset($raw) { if (!is_array($raw)) { return []; } $clean = []; // Metadonnees propres au preset. foreach (['name', 'created'] as $meta) { if (isset($raw[$meta])) { $clean[$meta] = sanitize_text_field((string) $raw[$meta]); } } // Les valeurs de design vivent sous la cle 'data' (voir save_custom_preset). // Elles passent par le meme filtre que l'import global : seules les cles // connues survivent, chacune ramenee au type de son defaut. $data = $raw['data'] ?? ( $raw['settings'] ?? null ); // A preset only carries design: a file that also contains // maintenance_enabled, the whitelist or visitor access cannot change // them (they used to be applied with the preset). $design = array_flip( self::get_preset_fields() ); if ( is_array( $data ) ) { $clean['data'] = array_intersect_key( self::sanitize_import( $data ), $design ); } else { // Ancien format : les valeurs etaient a la racine du preset. $clean = array_merge( $clean, array_intersect_key( self::sanitize_import( $raw ), $design ) ); } if ( empty( $clean['name'] ) ) { $clean['name'] = __( 'Imported preset', '1001web-maintenance-launch-pages' ); } return $clean; } /** * Whitelisted addresses typed one per line. Each line is decoded first * (an address copied from the browser bar, « /%D0%BA%D0%B0... », keeps its * non-ASCII slug), then sanitized on its own: an invalid line is dropped * without emptying the others. * * @param string $raw Text of the field. * @return string[] */ public static function sanitize_path_list($raw) { $list = []; foreach (preg_split('/\r\n|\r|\n/', (string) $raw) as $line) { $line = sanitize_text_field(rawurldecode($line)); if ('' !== $line && !in_array($line, $list, true)) { $list[] = $line; } } return $list; } /** * Date of a form or of an import: « YYYY-MM-DD » that exists in the * calendar, otherwise an empty string. * * @param mixed $value Value to check. * @return string */ public static function clean_date($value) { $value = is_string($value) ? trim($value, " \t\n\r\0\x0B") : ''; if (preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $value, $m) && checkdate((int) $m[2], (int) $m[3], (int) $m[1])) { return $value; } return ''; } /** * Time of a form or of an import: « HH:MM » (24 h; seconds are dropped), * otherwise $fallback. * * @param mixed $value Value to check. * @param string $fallback Value returned when $value is not a valid time. * @return string */ public static function clean_time($value, $fallback = '') { $value = is_string($value) ? trim($value, " \t\n\r\0\x0B") : ''; if (preg_match('/^([01]\d|2[0-3]):[0-5]\d(:[0-5]\d)?$/', $value)) { return substr($value, 0, 5); } return $fallback; } public static function sanitize_import($raw) { if (!is_array($raw)) { return []; } $defaults = self::get_defaults(); $clean = []; // Cles reelles des reglages (les anciennes listes visaient // des cles inexistantes : le message perdait ses sauts de ligne). // Champs acceptant du HTML restreint (messages affichés aux visiteurs). $rich_text = ['message']; // Champs URL. $urls = ['logo_url', 'bg_image_url', 'video_bg_url', 'custom_bg_url', 'lottie_url']; // Secrets et donnees d'acces jamais importes : un fichier // forge ne doit pas pouvoir poser un mot de passe visiteur connu de // son auteur, ni une cle secrete. $never_imported = ['visitor_password', 'turnstile_secret_key', 'schedule_auto_on', 'schedule_started']; foreach ($defaults as $key => $default) { if (!array_key_exists($key, $raw) || in_array($key, $never_imported, true)) { continue; } $value = $raw[$key]; if ('custom_presets' === $key) { // Chaque preset repasse par son propre filtre. $clean[$key] = []; if (is_array($value)) { foreach ($value as $preset_id => $preset) { $preset_id = sanitize_key((string) $preset_id); $preset = self::sanitize_preset($preset); if ('' !== $preset_id && !empty($preset['data'])) { $clean[$key][$preset_id] = $preset; } } } } elseif (is_bool($default)) { $clean[$key] = (bool) $value; } elseif (is_int($default)) { $clean[$key] = is_scalar($value) ? (int) $value : $default; } elseif (is_float($default)) { $clean[$key] = is_scalar($value) ? (float) $value : $default; } elseif (is_array($default)) { // Lists of text entries only: a nested array (« [["1.2.3.4"]] ») // made every public page end with a fatal error. $list = []; foreach ((is_array($value) ? $value : []) as $item) { if (is_scalar($item)) { $item = sanitize_text_field((string) $item); if ('' !== $item) { $list[] = $item; } } } $clean[$key] = $list; } elseif (!is_scalar($value)) { $clean[$key] = $default; } elseif (in_array($key, ['countdown_date', 'schedule_start_date', 'schedule_end_date'], true)) { // A malformed date (« 31/12/2030 ») ended the maintenance page // with a fatal error: only « YYYY-MM-DD » is kept. $clean[$key] = self::clean_date((string) $value); } elseif (in_array($key, ['countdown_time', 'schedule_start_time', 'schedule_end_time'], true)) { $clean[$key] = self::clean_time((string) $value, $default); } elseif (in_array($key, $urls, true)) { $clean[$key] = esc_url_raw((string) $value); } elseif (in_array($key, $rich_text, true)) { $clean[$key] = wp_kses_post((string) $value); } elseif (is_string($default) && 1 === preg_match('/^#(?:[0-9a-fA-F]{3}){1,2}$/', $default)) { // Color setting: only a valid hex color is kept (same rule as // the settings screen), anything else falls back to the default. $color = sanitize_hex_color((string) $value); $clean[$key] = $color ? $color : $default; } elseif (is_string($value) && preg_match('/^#[0-9a-fA-F]{3,8}$/', $value)) { $clean[$key] = sanitize_hex_color($value) ?: $default; } else { $clean[$key] = sanitize_text_field((string) $value); } } return $clean; } const VISITOR_TOKEN_OPTION = 'mgenius_visitor_token'; /** * Retourne le token d'accès visiteur, en le générant au besoin. * Migre automatiquement l'ancienne valeur logée dans mg_settings. * * @return string */ public static function get_visitor_token() { wp_cache_delete(self::VISITOR_TOKEN_OPTION, 'options'); $token = get_option(self::VISITOR_TOKEN_OPTION, ''); if (!empty($token) && is_string($token)) { return $token; } // Migration : récupérer l'ancien emplacement pour ne pas invalider les // cookies en cours, puis purger la clé de mg_settings. $settings = get_option('mgenius_settings', []); if (is_array($settings) && !empty($settings['visitor_token'])) { $token = $settings['visitor_token']; unset($settings['visitor_token']); self::save($settings); } else { $token = wp_generate_password(32, false); } self::set_visitor_token($token); return $token; } /** * Écrit le token (Redis-safe : $wpdb direct + purge de cache). * Révoque de fait tous les accès visiteurs en cours. * * @param string $token * @return string Le token écrit. */ public static function set_visitor_token($token) { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Redis-safe write: update_option() can be served from a persistent object cache and never reach the database. Cache is invalidated explicitly right after. $exists = $wpdb->get_var($wpdb->prepare( "SELECT option_id FROM {$wpdb->options} WHERE option_name = %s LIMIT 1", self::VISITOR_TOKEN_OPTION )); if ($exists) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Redis-safe write: update_option() can be served from a persistent object cache and never reach the database. Cache is invalidated explicitly right after. $wpdb->update( $wpdb->options, ['option_value' => $token], ['option_name' => self::VISITOR_TOKEN_OPTION], ['%s'], ['%s'] ); } else { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Redis-safe write: update_option() can be served from a persistent object cache and never reach the database. Cache is invalidated explicitly right after. $wpdb->insert( $wpdb->options, [ 'option_name' => self::VISITOR_TOKEN_OPTION, 'option_value' => $token, 'autoload' => 'no', ], ['%s', '%s', '%s'] ); } wp_cache_delete(self::VISITOR_TOKEN_OPTION, 'options'); wp_cache_delete('alloptions', 'options'); return $token; } /** * Régénère le token — invalide tous les cookies d'accès existants. * * @return string */ public static function rotate_visitor_token() { return self::set_visitor_token(wp_generate_password(32, false)); } /** * Migration du doublon custom_bg vers bg_image (option dépréciée). * Idempotente : reproduit l'ancien comportement (custom_bg écrasait bg_image), * puis désactive définitivement custom_bg. */ public static function migrate_custom_bg_to_bg_image() { if (get_option('mgenius_custom_bg_migrated')) { return; } $s = get_option('mgenius_settings', null); if (is_array($s)) { $changed = false; // Récupère l'image custom_bg UNIQUEMENT si aucune image standard n'est définie. if (!empty($s['custom_bg_enabled']) && !empty($s['custom_bg_url']) && empty($s['bg_image_url'])) { $s['bg_image_url'] = $s['custom_bg_url']; $s['bg_image_enabled'] = true; $changed = true; } if (!empty($s['custom_bg_enabled'])) { $s['custom_bg_enabled'] = false; $changed = true; } if (isset($s['custom_bg_url']) && $s['custom_bg_url'] !== '') { $s['custom_bg_url'] = ''; $changed = true; } if ($changed) { self::save($s); } } update_option('mgenius_custom_bg_migrated', 1, false); } /** * Force autoload=no sur l'option mg_settings (migration installs existantes). */ public static function ensure_settings_autoload_off() { if (function_exists('wp_set_option_autoload')) { // WP 6.4+ wp_set_option_autoload('mgenius_settings', false); return; } // Fallback WP < 6.4 : re-save avec le paramètre autoload. $v = get_option('mgenius_settings', null); if ($v !== null) { self::save($v); } } /** * Lecture des stats (stockage isolé, non-autoloadé). * @return array{visits:int,unique:int} */ public static function get_stats() { $visits = (int) get_option('mgenius_stats_visits', 0); $data = get_option('mgenius_stats', []); $unique = ( is_array($data) && isset($data['unique']) ) ? (int) $data['unique'] : 0; return ['visits' => $visits, 'unique' => $unique]; } /** * Enregistre une visite. Compteur incrémenté de façon atomique (SQL direct), * jamais en réécrivant le blob settings. Unique-visiteurs via set borné (10 000). * @param string $ip_hash Hash de l'IP visiteur (md5(ip + wp_salt())) * @return array{visits:int,unique:int,is_new:bool} */ public static function record_visit($ip_hash) { global $wpdb; // Incrément atomique : évite les races read-modify-write sous charge. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Atomic counter increment $rows = $wpdb->query($wpdb->prepare( "UPDATE {$wpdb->options} SET option_value = option_value + 1 WHERE option_name = %s", 'mgenius_stats_visits' )); if (!$rows) { // Première visite : l'option n'existe pas encore. add_option('mgenius_stats_visits', 1, '', 'no'); } wp_cache_delete('mgenius_stats_visits', 'options'); // Unique-visiteurs (set borné). $data = get_option('mgenius_stats', []); if (!is_array($data)) { $data = []; } $ips = ( isset($data['visitor_ips']) && is_array($data['visitor_ips']) ) ? $data['visitor_ips'] : []; $is_new = !in_array($ip_hash, $ips, true); if ($is_new) { $ips[] = $ip_hash; if (count($ips) > 10000) { $ips = array_slice($ips, -10000); } $data['visitor_ips'] = $ips; $data['unique'] = (int) ($data['unique'] ?? 0) + 1; update_option('mgenius_stats', $data, false); } $stats = self::get_stats(); $stats['is_new'] = $is_new; return $stats; } /** * Remet les stats à zéro. */ public static function reset_stats() { update_option('mgenius_stats_visits', 0, false); update_option('mgenius_stats', ['unique' => 0, 'visitor_ips' => []], false); } /** * Migration one-shot : convertit l'ancienne durée d'accès (heures) en secondes. * Les anciennes versions traitaient la valeur en heures (× 3600). * Toute valeur < 3600 est donc une valeur legacy en heures → on multiplie par 3600. */ public static function migrate_visitor_duration_to_seconds() { $settings = get_option('mgenius_settings', []); if (!is_array($settings) || !isset($settings['visitor_access_duration'])) { return; } $v = (int) $settings['visitor_access_duration']; if ($v > 0 && $v < 3600) { $settings['visitor_access_duration'] = min(31536000, $v * 3600); self::save($settings); } } /** * Migration one-shot : déplace les anciens compteurs hors du blob settings. */ public static function migrate_stats_from_settings() { $settings = get_option('mgenius_settings', []); if (!is_array($settings)) { return; } $changed = false; if (array_key_exists('stats_visits', $settings)) { if (get_option('mgenius_stats_visits', null) === null) { add_option('mgenius_stats_visits', (int) $settings['stats_visits'], '', 'no'); } unset($settings['stats_visits']); $changed = true; } if (array_key_exists('stats_unique_visitors', $settings) || array_key_exists('stats_visitors_ips', $settings)) { $data = get_option('mgenius_stats', []); if (!is_array($data)) { $data = []; } if (!isset($data['unique'])) { $data['unique'] = (int) ($settings['stats_unique_visitors'] ?? 0); } if (!isset($data['visitor_ips'])) { $data['visitor_ips'] = is_array($settings['stats_visitors_ips'] ?? null) ? $settings['stats_visitors_ips'] : []; } update_option('mgenius_stats', $data, false); unset($settings['stats_unique_visitors'], $settings['stats_visitors_ips']); $changed = true; } if ($changed) { self::save($settings); } } /** * Emails abonnés : option dédiée mg_notification_emails, * autoload=false, écriture $wpdb direct + wp_cache_delete() * (update_option() peut être bloqué par un object cache persistant). * @return array Liste des abonnés [{email, date, ip}] */ public static function get_notification_emails() { // Lecture directe SQL : contourne un éventuel cache objet stale. global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Read-through bypassing potentially stale object cache $row = $wpdb->get_var( $wpdb->prepare( "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1", 'mgenius_notification_emails' ) ); if ( null === $row ) { return []; } $emails = maybe_unserialize( $row ); return is_array( $emails ) ? $emails : []; } /** * Écrit la liste des abonnés en SQL direct. * @param array $emails * @return bool Succès de l'écriture (vérifiable, contrairement à update_option). */ public static function set_notification_emails( $emails ) { global $wpdb; if ( ! is_array( $emails ) ) { return false; } $value = maybe_serialize( $emails ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Direct write, object cache purged below $exists = $wpdb->get_var( $wpdb->prepare( "SELECT option_id FROM {$wpdb->options} WHERE option_name = %s LIMIT 1", 'mgenius_notification_emails' ) ); if ( null === $exists ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Direct insert with autoload=no $result = $wpdb->insert( $wpdb->options, [ 'option_name' => 'mgenius_notification_emails', 'option_value' => $value, 'autoload' => 'no' ], [ '%s', '%s', '%s' ] ); } else { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Direct update $result = $wpdb->update( $wpdb->options, [ 'option_value' => $value ], [ 'option_name' => 'mgenius_notification_emails' ], [ '%s' ], [ '%s' ] ); } // Purge du cache objet (Redis/Memcached) pour que get_option() serve la valeur fraîche. wp_cache_delete( 'mgenius_notification_emails', 'options' ); wp_cache_delete( 'alloptions', 'options' ); wp_cache_delete( 'notoptions', 'options' ); return false !== $result; } /** * Ajoute un abonné (append + dédup). * L'IP est hashée (md5(ip + wp_salt)) — jamais stockée en clair (align GDPR, * même mécanisme que les stats). * @param string $email Email validé (is_email déjà passé par l'appelant) * @param string $visitor_ip IP brute (sera hashée ici) * @return string 'added' | 'duplicate' | 'error' */ public static function add_notification_email( $email, $visitor_ip = '' ) { $emails = self::get_notification_emails(); foreach ( $emails as $email_data ) { if ( isset( $email_data['email'] ) && $email_data['email'] === $email ) { return 'duplicate'; } } $emails[] = [ 'email' => $email, 'date' => current_time( 'mysql' ), 'ip_hash' => $visitor_ip ? md5( $visitor_ip . wp_salt() ) : '', ]; return self::set_notification_emails( $emails ) ? 'added' : 'error'; } /** * Migration douce : emails du blob mg_settings → * option dédiée. Idempotente. S3 appliqué au passage : IPs en clair → hashées. */ public static function migrate_notification_emails_from_settings() { $settings = get_option( 'mgenius_settings', [] ); if ( ! is_array( $settings ) || ! array_key_exists( 'notification_emails', $settings ) ) { return; } $legacy = is_array( $settings['notification_emails'] ) ? $settings['notification_emails'] : []; $current = self::get_notification_emails(); $known = []; foreach ( $current as $e ) { if ( isset( $e['email'] ) ) { $known[ $e['email'] ] = true; } } foreach ( $legacy as $email_data ) { $mail = $email_data['email'] ?? ''; if ( empty( $mail ) || isset( $known[ $mail ] ) ) { continue; } // S3 — IP en clair héritée → hashée ; jamais reportée brute. $raw_ip = $email_data['ip'] ?? ''; $current[] = [ 'email' => $mail, 'date' => $email_data['date'] ?? current_time( 'mysql' ), 'ip_hash' => ( ! empty( $email_data['ip_hash'] ) ) ? $email_data['ip_hash'] : ( $raw_ip ? md5( $raw_ip . wp_salt() ) : '' ), ]; $known[ $mail ] = true; } if ( self::set_notification_emails( $current ) ) { unset( $settings['notification_emails'] ); self::save( $settings ); } } /** * Design settings, the only ones a custom preset saves, imports and * applies. * * @return string[] */ public static function get_preset_fields() { // Vraies cles des reglages de design. L'ancienne liste // visait des cles inexistantes (container_bg, animation, bg_opacity_1) // et oubliait l'image et la video de fond : un design perso applique // gardait l'image du design precedent. return [ 'bg_color_1', 'bg_color_1_opacity', 'bg_color_2', 'bg_color_2_opacity', 'title_color', 'title_opacity', 'message_color', 'message_opacity', 'button_color', 'button_opacity', 'bg_image_enabled', 'bg_image_url', 'bg_overlay_opacity', 'custom_bg_enabled', 'custom_bg_url', 'video_bg_enabled', 'video_bg_url', 'video_bg_type', 'video_bg_opacity', 'video_bg_muted', 'container_bg_color', 'container_bg_opacity', 'container_shadow_enabled', 'container_shadow_intensity', 'container_max_width', 'container_padding', 'container_border_radius', 'title_font', 'message_font', 'title_size', 'message_size', 'logo_size', 'animation_enabled', 'logo_animation', 'text_animation', 'button_animation', 'animation_duration', 'parallax_enabled', 'parallax_intensity', 'lottie_enabled', 'lottie_url', 'lottie_position', 'lottie_size', 'lottie_offset_x', 'lottie_offset_y', 'lottie_loop', 'lottie_autoplay', 'lottie_speed', 'countdown_style', ]; } /** * Réinitialise uniquement les paramètres de design * @param array $settings Settings actuels * @return array Settings avec design réinitialisé */ public static function reset_design($settings) { $defaults = self::get_defaults(); $design_keys = [ 'bg_color_1', 'bg_color_1_opacity', 'bg_color_2', 'bg_color_2_opacity', 'title_color', 'title_opacity', 'message_color', 'message_opacity', 'button_color', 'button_opacity', 'bg_image_enabled', 'bg_image_url', 'bg_overlay_opacity', 'container_bg_color', 'container_bg_opacity', 'container_shadow_enabled', 'container_shadow_intensity', 'container_max_width', 'container_padding', 'container_border_radius', 'title_font', 'message_font', 'animation_enabled', 'logo_animation', 'text_animation', 'button_animation', 'animation_duration' ]; foreach ($design_keys as $key) { $settings[$key] = $defaults[$key]; } return $settings; } /** * Liste des polices disponibles * @return array */ public static function get_fonts() { return [ 'Poppins' => 'Poppins', 'Open Sans' => 'Open Sans', 'Roboto' => 'Roboto', 'Lato' => 'Lato', 'Montserrat' => 'Montserrat', 'Playfair Display' => 'Playfair Display', 'Raleway' => 'Raleway', 'Inter' => 'Inter', // Remplace Ubuntu (licence UFL, non OFL) 'Nunito' => 'Nunito', 'Merriweather' => 'Merriweather', ]; } /** * Liste des animations disponibles * @return array */ public static function get_animations() { return [ 'none' => __('None', '1001web-maintenance-launch-pages'), 'fadeIn' => __('Fade In', '1001web-maintenance-launch-pages'), 'slideUp' => __('Slide Up', '1001web-maintenance-launch-pages'), 'slideDown' => __('Slide Down', '1001web-maintenance-launch-pages'), 'pulse' => __('Pulse', '1001web-maintenance-launch-pages'), 'bounce' => __('Bounce', '1001web-maintenance-launch-pages'), 'zoom' => __('Zoom', '1001web-maintenance-launch-pages'), ]; } /** * Presets de design prédéfinis * Fonds .webp (assets/images/presets/) : photos CC0 de StockSnap, * crédits dans readme.txt (Third Party Libraries). * @return array */ public static function get_presets() { $base_url = MGENIUS_PLUGIN_URL . 'assets/images/presets/'; return [ // Premier dans la liste, défaut pour nouvelles installations. 'genius' => [ 'name' => '🔥 GENIUS Orange', 'bg_color_1' => '#1F2937', // Sombre 'bg_color_2' => '#0F172A', // Sombre profond (gradient) 'title_color' => '#F97316', // Orange de la marque 'message_color' => '#FFEDD5', // Orange ultra-clair 'button_color' => '#F97316', // Orange brand 'container_bg_color' => '#0B1020', 'container_bg_opacity' => 74, 'bg_image_url' => $base_url . 'genius.webp', // v2.9.175 — photo CC0 (readme) 'bg_image_enabled' => true, 'bg_overlay_opacity' => 12, ], 'default' => [ 'name' => '🌸 ' . __('Soft & Pastel', '1001web-maintenance-launch-pages'), 'bg_color_1' => '#D4BADD', 'bg_color_2' => '#E4EFC5', 'title_color' => '#EA580C', 'message_color' => '#555555', 'button_color' => '#4F46E5', // Texte blanc lisible 'container_bg_color' => '#ffffff', 'container_bg_opacity' => 95, 'bg_image_url' => '', 'bg_image_enabled' => false, ], 'dark' => [ 'name' => '🌙 ' . __('Dark & Elegant', '1001web-maintenance-launch-pages'), 'bg_color_1' => '#0B1226', 'bg_color_2' => '#111A33', 'title_color' => '#F1D59A', // v2.9.175 — or pâle (13 : 1 sur la carte) 'message_color' => '#CBD5E1', 'button_color' => '#A16207', // or foncé, texte blanc 4,9 : 1 'container_bg_color' => '#0B1226', 'container_bg_opacity' => 85, 'bg_image_url' => $base_url . 'dark.webp', // Photo CC0 (readme) 'bg_overlay_opacity' => 35, 'bg_image_enabled' => true, ], 'light' => [ 'name' => '☀️ ' . __('Light & Clean', '1001web-maintenance-launch-pages'), 'bg_color_1' => '#f8f9fa', 'bg_color_2' => '#e9ecef', 'title_color' => '#212529', 'message_color' => '#5C636A', // v2.9.175 — 6,1 : 1 (était #6c757d) 'button_color' => '#495057', 'container_bg_color' => '#ffffff', 'container_bg_opacity' => 92, 'bg_image_url' => $base_url . 'light.webp', // Photo CC0 (readme) 'bg_overlay_opacity' => 0, 'bg_image_enabled' => true, ], 'corporate' => [ 'name' => '💼 ' . __('Professional', '1001web-maintenance-launch-pages'), 'bg_color_1' => '#2c3e50', 'bg_color_2' => '#34495e', 'title_color' => '#2c3e50', 'message_color' => '#555555', 'button_color' => '#1D6FB8', // Texte blanc lisible 'container_bg_color' => '#ffffff', 'container_bg_opacity' => 94, 'bg_image_url' => $base_url . 'corporate.webp', // Photo CC0 (readme) 'bg_overlay_opacity' => 20, 'bg_image_enabled' => true, ], // v2.9.175 — Zen remplace Startup. 'zen' => [ 'name' => '🧘 ' . __('Zen', '1001web-maintenance-launch-pages'), 'bg_color_1' => '#1E3A8A', 'bg_color_2' => '#C9A27E', 'title_color' => '#1E3A8A', 'message_color' => '#475569', 'button_color' => '#1D4ED8', 'container_bg_color' => '#ffffff', 'container_bg_opacity' => 88, 'bg_image_url' => $base_url . 'zen.webp', // Photo CC0 (readme) 'bg_overlay_opacity' => 10, 'bg_image_enabled' => true, ], 'nature' => [ 'name' => '🌿 ' . __('Nature', '1001web-maintenance-launch-pages'), 'bg_color_1' => '#1a3a2a', 'bg_color_2' => '#2d4a3a', 'title_color' => '#ffffff', 'message_color' => '#d4edda', 'button_color' => '#15803D', // Texte blanc lisible 'container_bg_color' => '#0f2418', // Carte foncee : texte clair lisible 'container_bg_opacity' => 82, 'bg_image_url' => $base_url . 'nature.webp', // Photo CC0 (readme) 'bg_overlay_opacity' => 35, 'bg_image_enabled' => true, ], 'tech' => [ 'name' => '⚡ ' . __('Futuristic', '1001web-maintenance-launch-pages'), 'bg_color_1' => '#1E0B3A', 'bg_color_2' => '#0B0420', // v2.9.175 — palette violette accordée à la photo (titre 11,2 : 1, bouton 6,3 : 1). 'title_color' => '#F0ABFC', 'message_color' => '#E9D5FF', 'button_color' => '#A21CAF', 'container_bg_color' => '#12051F', 'container_bg_opacity' => 82, 'bg_image_url' => $base_url . 'tech.webp', // Photo CC0 (readme) 'bg_overlay_opacity' => 35, 'bg_image_enabled' => true, ], // v2.9.175 — Noël remplace Playful. 'christmas' => [ 'name' => '🎄 ' . __('Christmas', '1001web-maintenance-launch-pages'), 'bg_color_1' => '#1C1410', 'bg_color_2' => '#3B2A1E', 'title_color' => '#F6D7A7', 'message_color' => '#F3E8DA', 'button_color' => '#B91C1C', 'container_bg_color' => '#1C1410', 'container_bg_opacity' => 80, 'bg_image_url' => $base_url . 'christmas.webp', // Photo CC0 (readme) 'bg_overlay_opacity' => 20, 'bg_image_enabled' => true, ], ]; } /** * Liste des rôles WordPress * @return array */ public static function get_roles() { $roles = []; $wp_roles = wp_roles(); if ($wp_roles && isset($wp_roles->roles)) { foreach ($wp_roles->roles as $key => $role) { $roles[$key] = $role['name']; } } return $roles; } /** * Récupérer tous les tokens d'accès * @return array */ public static function get_access_tokens() { $tokens = get_option('mgenius_access_tokens', []); if (!is_array($tokens)) { return []; } // Real tokens only (each one is a set of fields): a corrupted value // never opens the site nor breaks the Access tab. return array_filter($tokens, 'is_array'); } /** * Sauvegarder les tokens * @param array $tokens * @return bool */ public static function save_access_tokens($tokens) { return update_option('mgenius_access_tokens', $tokens); } /** * Générer un nouveau token d'accès * @param array $data * @return string|false Token généré ou false si limite atteinte */ public static function create_access_token($data) { $settings = self::load(); $tokens = self::get_access_tokens(); $active_count = self::count_active_tokens(); $limit = intval($settings['custom_access_limit'] ?? 25); if ($limit > 0 && $active_count >= $limit) { return false; } $token = wp_generate_password(16, false, false); while (isset($tokens[$token])) { $token = wp_generate_password(16, false, false); } $tokens[$token] = [ 'name' => sanitize_text_field($data['name'] ?? ''), 'created' => current_time('mysql'), 'expires' => $data['expires'] ?? null, 'visits' => 0, 'last_visit' => null, ]; // Écriture vérifiée : en cas d'échec, false, jamais un // lien fantôme remis à l'utilisateur. if ( ! self::save_access_tokens($tokens) ) { return false; } wp_cache_delete('mgenius_access_tokens', 'options'); return $token; } /** * Supprimer un token * @param string $token * @return bool */ public static function delete_access_token($token) { $tokens = self::get_access_tokens(); if (isset($tokens[$token])) { unset($tokens[$token]); self::save_access_tokens($tokens); return true; } return false; } /** * Valider un token d'accès * @param string $token * @param string $ip IP du visiteur * @return bool|array False si invalide, données du token si valide */ public static function validate_access_token($token, $ip = '') { $tokens = self::get_access_tokens(); if (!isset($tokens[$token])) { return false; } $data = $tokens[$token]; if (!empty($data['expires'])) { $expires_time = self::local_to_timestamp($data['expires']); if ($expires_time && $expires_time < time()) { return false; } } // Jetons d'anciennes versions : la restriction IP reste respectee. if (!empty($data['ip_restrict']) && $data['ip_restrict'] !== $ip) { return false; } return $data; } /** * Enregistrer une visite via token * @param string $token * @param string $ip * @param string $user_agent */ public static function log_token_visit($token, $ip, $user_agent) { $tokens = self::get_access_tokens(); if (!isset($tokens[$token])) { return; } $tokens[$token]['visits'] = ($tokens[$token]['visits'] ?? 0) + 1; $tokens[$token]['last_visit'] = current_time('mysql'); // Plus de journal d'acces (IP, navigateur) : aucun ecran ne // l'affichait, ces donnees personnelles n'etaient donc pas utiles. self::save_access_tokens($tokens); } /** * Ligne descriptive d'un jeton, identique a l'ouverture de la * page et apres un rechargement de la liste : expiration (avec l'heure), * visites (singulier/pluriel) et derniere visite. * * @param array $data Donnees du jeton. * @return string */ public static function token_meta_text($data) { $format = get_option('date_format') . ' ' . get_option('time_format'); $parts = []; // « Unlimited » est enregistré au 2099-12-31 (admin.js) : affiché sans expiration. if (!empty($data['expires']) && '2099-12-31' !== substr((string) $data['expires'], 0, 10)) { $ts = self::local_to_timestamp($data['expires']); $parts[] = sprintf( /* translators: %s: date and time */ ($ts && $ts <= time()) ? __('Expired on %s', '1001web-maintenance-launch-pages') : __('Expires on %s', '1001web-maintenance-launch-pages'), wp_date($format, $ts) ); } else { $parts[] = __('Unlimited', '1001web-maintenance-launch-pages'); } $visits = (int) ($data['visits'] ?? 0); /* translators: %d: number of visits */ $parts[] = sprintf(_n('%d visit', '%d visits', $visits, '1001web-maintenance-launch-pages'), $visits); if (!empty($data['last_visit'])) { /* translators: %s: date and time of the last visit */ $parts[] = sprintf(__('last visit %s', '1001web-maintenance-launch-pages'), wp_date($format, self::local_to_timestamp($data['last_visit']))); } return implode(' | ', $parts); } /** * Compter les tokens actifs (non expirés) * @return int */ public static function count_active_tokens() { $tokens = self::get_access_tokens(); $count = 0; $now = time(); foreach ($tokens as $data) { if (empty($data['expires'])) { $count++; } else { $expires_time = self::local_to_timestamp($data['expires']); if ($expires_time && $expires_time > $now) { $count++; } } } return $count; } /** * Compter les tokens expirés * @return int */ public static function count_expired_tokens() { $tokens = self::get_access_tokens(); $count = 0; $now = time(); foreach ($tokens as $data) { if (!empty($data['expires'])) { $expires_time = self::local_to_timestamp($data['expires']); if ($expires_time && $expires_time <= $now) { $count++; } } } return $count; } /** * Obtenir les statistiques des accès * @return array */ public static function get_access_stats() { $tokens = self::get_access_tokens(); $settings = self::load(); $total_visits = 0; foreach ($tokens as $data) { $total_visits += ($data['visits'] ?? 0); } return [ 'active' => self::count_active_tokens(), 'expired' => self::count_expired_tokens(), 'total_visits' => $total_visits, 'limit' => intval($settings['custom_access_limit'] ?? 25), ]; } /** * Thèmes complets (5 thèmes : Sobre · Corporate · Festif · Lancement · Horizon). * Un thème applique une configuration complète : visuel + typo + layout + animations. * Les presets visuels existants restent intacts — les Thèmes sont une couche au-dessus. * @return array */ /** * v2.9.174 — True if a media URL is one bundled with the plugin (preset * backgrounds), false for the user's own image, video or animation. * * @param string $url * @return bool */ public static function is_bundled_media( $url ) { if ( ! is_string( $url ) || '' === $url ) { return false; } return 0 === strpos( $url, MGENIUS_PLUGIN_URL ) || false !== strpos( $url, '/assets/images/presets/' ); } /** * v2.9.174 — Settings that hold the user's own background media. Applying * a theme or a preset never removes them (the tester's own photo was * wiped by every color theme). * * @param array $settings * @return string[] */ public static function own_media_keys( $settings ) { $keys = array(); if ( ! empty( $settings['bg_image_enabled'] ) && ! empty( $settings['bg_image_url'] ) && ! self::is_bundled_media( $settings['bg_image_url'] ) ) { $keys = array_merge( $keys, array( 'bg_image_enabled', 'bg_image_url', 'bg_overlay_opacity' ) ); } if ( ! empty( $settings['video_bg_enabled'] ) && ! empty( $settings['video_bg_url'] ) && ! self::is_bundled_media( $settings['video_bg_url'] ) ) { $keys = array_merge( $keys, array( 'video_bg_enabled', 'video_bg_url', 'video_bg_type', 'video_bg_opacity', 'video_bg_muted' ) ); } if ( ! empty( $settings['lottie_enabled'] ) && ! empty( $settings['lottie_url'] ) && ! self::is_bundled_media( $settings['lottie_url'] ) ) { $keys = array_merge( $keys, array( 'lottie_enabled', 'lottie_url' ) ); } return $keys; } public static function get_themes() { return [ 'sobre' => [ 'id' => 'sobre', 'label' => __('Minimal', '1001web-maintenance-launch-pages'), 'description' => __('Refined minimalism, zero distraction.', '1001web-maintenance-launch-pages'), 'css_preview' => ['from' => '#FAFAF9', 'to' => '#E7E5E4', 'accent' => '#1C1917'], 'font_preview'=> 'Poppins', 'settings' => [ 'bg_color_1' => '#FAFAF9', 'bg_color_1_opacity' => 100, 'bg_color_2' => '#E7E5E4', 'bg_color_2_opacity' => 100, 'title_color' => '#1C1917', 'title_opacity' => 100, 'message_color' => '#57534E', 'message_opacity' => 100, 'button_color' => '#1C1917', 'button_opacity' => 100, 'bg_image_enabled' => false, 'container_bg_color' => '#FFFFFF', 'container_bg_opacity' => 100, 'container_shadow_enabled' => true, 'container_shadow_intensity' => 12, 'container_max_width' => 560, 'container_padding' => 56, 'container_border_radius' => 12, 'title_font' => 'Poppins', 'message_font' => 'Open Sans', 'title_size' => 36, 'message_size' => 17, 'animation_enabled' => true, 'logo_animation' => 'none', 'text_animation' => 'fadeIn', 'button_animation' => 'fadeIn', 'animation_duration' => 600, 'parallax_enabled' => false, 'countdown_style' => 'minimal', ], ], 'corporate' => [ 'id' => 'corporate', 'label' => __('Corporate', '1001web-maintenance-launch-pages'), 'description' => __('Institutional trust — deep blue, B2B and professionals.', '1001web-maintenance-launch-pages'), 'css_preview' => ['from' => '#0F2A4A', 'to' => '#1E3A5F', 'accent' => '#2563EB'], 'font_preview'=> 'Poppins', 'settings' => [ 'bg_color_1' => '#0F2A4A', 'bg_color_1_opacity' => 100, 'bg_color_2' => '#1E3A5F', 'bg_color_2_opacity' => 100, 'title_color' => '#FFFFFF', 'title_opacity' => 100, 'message_color' => '#CBD5E1', 'message_opacity' => 100, 'button_color' => '#2563EB', 'button_opacity' => 100, 'bg_image_enabled' => false, 'container_bg_color' => '#0B1F38', 'container_bg_opacity' => 88, 'container_shadow_enabled' => true, 'container_shadow_intensity' => 35, 'container_max_width' => 640, 'container_padding' => 60, 'container_border_radius' => 8, 'title_font' => 'Poppins', 'message_font' => 'Open Sans', 'title_size' => 40, 'message_size' => 18, 'animation_enabled' => true, 'logo_animation' => 'none', 'text_animation' => 'fadeIn', 'button_animation' => 'slideUp', 'animation_duration' => 800, 'parallax_enabled' => false, 'countdown_style' => 'boxes', ], ], 'festif' => [ 'id' => 'festif', 'label' => __('Festive', '1001web-maintenance-launch-pages'), 'description' => __('Warm energy — events, retail, campaigns.', '1001web-maintenance-launch-pages'), 'css_preview' => ['from' => '#7C3AED', 'to' => '#EC4899', 'accent' => '#F59E0B'], 'font_preview'=> 'Poppins', 'settings' => [ 'bg_color_1' => '#7C3AED', 'bg_color_1_opacity' => 100, 'bg_color_2' => '#EC4899', 'bg_color_2_opacity' => 100, 'title_color' => '#FFFFFF', 'title_opacity' => 100, 'message_color' => '#FDF2F8', 'message_opacity' => 95, 'button_color' => '#F59E0B', 'button_opacity' => 100, 'bg_image_enabled' => false, 'container_bg_color' => '#4C1D95', 'container_bg_opacity' => 72, 'container_shadow_enabled' => true, 'container_shadow_intensity' => 45, 'container_max_width' => 620, 'container_padding' => 64, 'container_border_radius' => 28, 'title_font' => 'Poppins', 'message_font' => 'Open Sans', 'title_size' => 44, 'message_size' => 18, 'animation_enabled' => true, 'logo_animation' => 'pulse', 'text_animation' => 'fadeIn', 'button_animation' => 'slideUp', 'animation_duration' => 1000, 'parallax_enabled' => true, 'parallax_intensity' => 40, 'countdown_style' => 'boxes', ], ], 'lancement' => [ 'id' => 'lancement', 'label' => __('Launch', '1001web-maintenance-launch-pages'), 'description' => __('Tech night + electric accent — startups and new sites.', '1001web-maintenance-launch-pages'), 'css_preview' => ['from' => '#020617', 'to' => '#0F172A', 'accent' => '#22D3EE'], 'font_preview'=> 'Poppins', 'settings' => [ 'bg_color_1' => '#020617', 'bg_color_1_opacity' => 100, 'bg_color_2' => '#0F172A', 'bg_color_2_opacity' => 100, 'title_color' => '#22D3EE', 'title_opacity' => 100, 'message_color' => '#94A3B8', 'message_opacity' => 100, 'button_color' => '#06B6D4', 'button_opacity' => 100, 'bg_image_enabled' => false, 'container_bg_color' => '#0F172A', 'container_bg_opacity' => 65, 'container_shadow_enabled' => true, 'container_shadow_intensity' => 50, 'container_max_width' => 680, 'container_padding' => 64, 'container_border_radius' => 16, 'title_font' => 'Poppins', 'message_font' => 'Open Sans', 'title_size' => 46, 'message_size' => 18, 'animation_enabled' => true, 'logo_animation' => 'none', 'text_animation' => 'fadeIn', 'button_animation' => 'slideUp', 'animation_duration' => 900, 'parallax_enabled' => false, 'countdown_style' => 'boxes', 'countdown_enabled' => true, ], ], 'horizon' => [ 'id' => 'horizon', 'label' => __('Horizon', '1001web-maintenance-launch-pages'), 'description' => __('Coming Soon — warm gradient, optimistic and welcoming.', '1001web-maintenance-launch-pages'), 'css_preview' => ['from' => '#F59E0B', 'to' => '#EF4444', 'accent' => '#FFFFFF'], 'font_preview'=> 'Poppins', 'for_mode' => 'coming_soon', 'settings' => [ 'bg_color_1' => '#F59E0B', 'bg_color_1_opacity' => 100, 'bg_color_2' => '#EF4444', 'bg_color_2_opacity' => 100, 'title_color' => '#FFFFFF', 'title_opacity' => 100, 'message_color' => '#FEF3C7', 'message_opacity' => 100, 'button_color' => '#FFFFFF', 'button_opacity' => 100, 'bg_image_enabled' => false, 'container_bg_color' => '#7C2D12', 'container_bg_opacity' => 72, // Message lisible 'container_shadow_enabled' => true, 'container_shadow_intensity' => 40, 'container_max_width' => 620, 'container_padding' => 60, 'container_border_radius' => 20, 'title_font' => 'Poppins', 'message_font' => 'Open Sans', 'title_size' => 44, 'message_size' => 18, 'animation_enabled' => true, 'logo_animation' => 'pulse', 'text_animation' => 'fadeIn', 'button_animation' => 'slideUp', 'animation_duration' => 800, 'parallax_enabled' => false, 'countdown_style' => 'minimal', 'countdown_enabled' => true, ], ], ]; } /** * Applique un thème complet. * 1. Snapshot $wpdb direct (option non-autoloadée). * 2. Merge sélectif (uniquement les clés du thème). * 3. Save vérifié — false si l'écriture échoue (Redis-safe). * Ne modifie JAMAIS : title, subtitle, message, logo_url, * ni les réglages fonctionnels (visitor_*, notification_*, jetons, Turnstile, SEO). * * @param string $theme_id ID du thème (sobre|corporate|festif|lancement) * @return bool Succès */ public static function apply_theme( $theme_id ) { global $wpdb; $themes = self::get_themes(); if ( ! isset( $themes[ $theme_id ] ) ) { return false; } $settings = self::load_fresh(); // — Merge sélectif : uniquement les clés définies dans le thème — $theme_map = $themes[ $theme_id ]['settings']; if ( 'lancement' === $theme_id && isset( $theme_map['countdown_enabled'] ) ) { if ( ! empty( $settings['countdown_date'] ) ) { unset( $theme_map['countdown_enabled'] ); } } // v2.9.174 — the user's own background image, video or animation stays. foreach ( self::own_media_keys( $settings ) as $own_key ) { unset( $theme_map[ $own_key ] ); } // — Snapshot (option non-autoloadée, $wpdb direct) — // Only design values: « Revert Theme » restores them and nothing else // (maintenance state, texts and access rules stay). v2.9.174 : the // snapshot keeps the design from BEFORE THE FIRST theme; applying a // second theme only adds the keys the first one did not touch (Revert // used to go back one theme only). // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Critical snapshot read, bypasses Redis object cache $snap_row = $wpdb->get_var( $wpdb->prepare( "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1", 'mgenius_theme_snapshot' ) ); $snap_exists = ( null !== $snap_row ); $previous = $snap_exists ? maybe_unserialize( $snap_row ) : array(); // A snapshot written before 2.9.172 holds every setting (it has // « maintenance_enabled »): it is not carried over. if ( ! is_array( $previous ) || array_key_exists( 'maintenance_enabled', $previous ) ) { $previous = array(); } $snapshot_value = maybe_serialize( $previous + array_intersect_key( $settings, $theme_map ) ); if ( ! $snap_exists ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Direct insert, autoload=no $wpdb->insert( $wpdb->options, [ 'option_name' => 'mgenius_theme_snapshot', 'option_value' => $snapshot_value, 'autoload' => 'no' ], [ '%s', '%s', '%s' ] ); } else { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Direct update $wpdb->update( $wpdb->options, [ 'option_value' => $snapshot_value ], [ 'option_name' => 'mgenius_theme_snapshot' ], [ '%s' ], [ '%s' ] ); } wp_cache_delete( 'mgenius_theme_snapshot', 'options' ); wp_cache_delete( 'alloptions', 'options' ); wp_cache_delete( 'notoptions', 'options' ); foreach ( $theme_map as $key => $value ) { $settings[ $key ] = $value; } // A theme that shows the countdown never brings back one already over // (during maintenance, it would end the maintenance at the next visit). if ( MGENIUS_Main::countdown_end_is_past( $settings ) ) { $settings['countdown_enabled'] = false; } $saved = self::save( $settings ); if ( ! $saved ) { return false; } // Stocker l'ID du thème actif (non-critique, best-effort). update_option( 'mgenius_active_theme', sanitize_key( $theme_id ), false ); return true; } /** * Annule le dernier thème appliqué (restaure le snapshot). * @return bool Succès */ public static function revert_theme() { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Read snapshot bypassing stale cache $row = $wpdb->get_var( $wpdb->prepare( "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1", 'mgenius_theme_snapshot' ) ); if ( null === $row ) { return false; } $snapshot = maybe_unserialize( $row ); if ( ! is_array( $snapshot ) ) { return false; } // Design values only, merged into the current settings: maintenance, // texts, access rules and schedule stay as they are now. A snapshot // written by an older version holds every setting; only the keys the // applied theme manages are read from it. $current = self::load_fresh(); $before = $current; // v2.9.174 — every key any theme manages (the snapshot now spans // several themes), never the user's own media added since. $protected = self::own_media_keys( $current ); // Old full snapshot (it holds « maintenance_enabled »): only the keys // of the theme applied last, as before. $keys = array_key_exists( 'maintenance_enabled', $snapshot ) ? self::theme_setting_keys( self::get_active_theme() ) : self::theme_setting_keys( null ); foreach ( $keys as $key ) { if ( array_key_exists( $key, $snapshot ) && ! in_array( $key, $protected, true ) ) { $current[ $key ] = $snapshot[ $key ]; } } if ( MGENIUS_Main::countdown_end_is_past( $current ) ) { $current['countdown_enabled'] = false; } if ( $current !== $before && ! self::save( $current ) ) { return false; } // Supprimer snapshot + thème actif. delete_option( 'mgenius_theme_snapshot' ); delete_option( 'mgenius_active_theme' ); wp_cache_delete( 'mgenius_theme_snapshot', 'options' ); wp_cache_delete( 'mgenius_active_theme', 'options' ); wp_cache_delete( 'alloptions', 'options' ); return true; } /** * Settings a theme changes: those of $theme_id, or of every theme when it * is unknown. * * @param string|false $theme_id Theme ID. * @return string[] */ private static function theme_setting_keys( $theme_id ) { $themes = self::get_themes(); if ( is_string( $theme_id ) && isset( $themes[ $theme_id ]['settings'] ) ) { return array_keys( $themes[ $theme_id ]['settings'] ); } $keys = array(); foreach ( $themes as $theme ) { $keys = array_merge( $keys, array_keys( $theme['settings'] ) ); } return array_values( array_unique( $keys ) ); } /** * Retourne l'ID du thème actif ou false. * @return string|false */ public static function get_active_theme() { return get_option( 'mgenius_active_theme', false ); } /** * Indique si un snapshot (annulation disponible) existe en base. * @return bool */ public static function has_theme_snapshot() { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Presence check, bypasses stale object cache $exists = $wpdb->get_var( $wpdb->prepare( "SELECT option_id FROM {$wpdb->options} WHERE option_name = %s LIMIT 1", 'mgenius_theme_snapshot' ) ); return null !== $exists; } }