*/ private const SECRETS = array( 'slack' => array( 'webhook_url' ), 'discord' => array( 'webhook_url' ), 'telegram' => array( 'bot_token' ), 'teams' => array( 'webhook_url' ), 'webhook' => array( 'url', 'secret' ), ); /** * Default settings. Every channel is off until the store owner sets it up. * * @return array */ public function defaults(): array { return array( 'channels' => array( 'email' => array( 'enabled' => false, 'recipients' => '', ), 'slack' => array( 'enabled' => false, 'webhook_url' => '', ), 'discord' => array( 'enabled' => false, 'webhook_url' => '', ), 'telegram' => array( 'enabled' => false, 'bot_token' => '', 'chat_id' => '', ), 'teams' => array( 'enabled' => false, 'webhook_url' => '', ), 'webhook' => array( 'enabled' => false, 'url' => '', 'secret' => '', ), ), 'events' => array( 'critical' => true, 'warning' => false, 'out_of_stock' => true, 'low_stock' => false, 'po_overdue' => true, 'po_received' => false, ), 'digest' => array( 'enabled' => false, 'hour' => 9, ), 'cooldown_hours' => 24, ); } /** * Full settings, secrets included. Server-side use only. * * @return array */ public function get(): array { $saved = get_option( self::OPTION, array() ); return $this->merge( $this->defaults(), is_array( $saved ) ? $saved : array() ); } /** * Settings safe to send to the browser: secrets masked. * * @return array */ public function for_api(): array { $settings = $this->get(); foreach ( self::SECRETS as $channel => $fields ) { foreach ( $fields as $field ) { $settings['channels'][ $channel ][ $field ] = $this->mask( (string) $settings['channels'][ $channel ][ $field ] ); } } return $settings; } /** * Validate and save settings from the API. * * @param array $input Raw input. * @return array|\WP_Error Masked saved settings, or an error naming the bad field. */ public function update( array $input ) { $current = $this->get(); $next = $current; $channels = isset( $input['channels'] ) && is_array( $input['channels'] ) ? $input['channels'] : array(); foreach ( self::CHANNELS as $id ) { $in = isset( $channels[ $id ] ) && is_array( $channels[ $id ] ) ? $channels[ $id ] : array(); foreach ( $current['channels'][ $id ] as $field => $old ) { if ( ! array_key_exists( $field, $in ) ) { continue; } if ( 'enabled' === $field ) { $next['channels'][ $id ]['enabled'] = (bool) $in['enabled']; continue; } $value = trim( (string) $in[ $field ] ); // The browser only ever sees a mask; sending it back means "keep". if ( $this->is_secret( $id, $field ) && str_starts_with( $value, self::MASK ) ) { continue; } $next['channels'][ $id ][ $field ] = $value; } $error = $this->validate_channel( $id, $next['channels'][ $id ] ); if ( is_wp_error( $error ) ) { return $error; } $next['channels'][ $id ] = $error; } $events = isset( $input['events'] ) && is_array( $input['events'] ) ? $input['events'] : array(); foreach ( self::EVENTS as $event ) { if ( array_key_exists( $event, $events ) ) { $next['events'][ $event ] = (bool) $events[ $event ]; } } $digest = isset( $input['digest'] ) && is_array( $input['digest'] ) ? $input['digest'] : array(); if ( array_key_exists( 'enabled', $digest ) ) { $next['digest']['enabled'] = (bool) $digest['enabled']; } if ( array_key_exists( 'hour', $digest ) ) { $next['digest']['hour'] = max( 0, min( 23, (int) $digest['hour'] ) ); } if ( array_key_exists( 'cooldown_hours', $input ) ) { $next['cooldown_hours'] = max( 1, min( 720, (int) $input['cooldown_hours'] ) ); } update_option( self::OPTION, $next, false ); return $this->for_api(); } /** * Channels that are switched on and fully configured. * * @return array> Channel ID => config. */ public function active_channels(): array { $out = array(); foreach ( $this->get()['channels'] as $id => $config ) { if ( ! empty( $config['enabled'] ) && $this->is_configured( $id, $config ) ) { $out[ $id ] = $config; } } return $out; } /** * Whether a channel has everything it needs to send. * * @param string $id Channel ID. * @param array $config Channel config. */ public function is_configured( string $id, array $config ): bool { switch ( $id ) { case 'email': return true; // Falls back to the site admin email. case 'telegram': return '' !== $config['bot_token'] && '' !== $config['chat_id']; case 'webhook': return '' !== $config['url']; default: return '' !== ( $config['webhook_url'] ?? '' ); } } /** * Whether an alert type is switched on. * * @param string $event Event ID. */ public function event_enabled( string $event ): bool { return ! empty( $this->get()['events'][ $event ] ); } /** * Validate one channel's fields. * * @param string $id Channel ID. * @param array $config Channel config. * @return array|\WP_Error Cleaned config. */ private function validate_channel( string $id, array $config ) { switch ( $id ) { case 'email': $emails = array_filter( array_map( 'trim', explode( ',', (string) $config['recipients'] ) ) ); foreach ( $emails as $email ) { if ( ! is_email( $email ) ) { /* translators: %s: the invalid email address. */ return $this->invalid( sprintf( __( '"%s" is not a valid email address.', 'profit-engine' ), $email ) ); } } $config['recipients'] = implode( ', ', array_map( 'sanitize_email', $emails ) ); break; case 'slack': if ( '' !== $config['webhook_url'] && ! $this->url_on_host( $config['webhook_url'], array( 'hooks.slack.com' ) ) ) { return $this->invalid( __( 'The Slack webhook URL should start with https://hooks.slack.com/', 'profit-engine' ) ); } break; case 'discord': if ( '' !== $config['webhook_url'] && ( ! $this->url_on_host( $config['webhook_url'], array( 'discord.com', 'discordapp.com', 'ptb.discord.com', 'canary.discord.com' ) ) || ! str_contains( $config['webhook_url'], '/api/webhooks/' ) ) ) { return $this->invalid( __( 'The Discord webhook URL should look like https://discord.com/api/webhooks/…', 'profit-engine' ) ); } break; case 'telegram': if ( '' !== $config['bot_token'] && ! preg_match( '/^\d{5,}:[A-Za-z0-9_-]{30,}$/', $config['bot_token'] ) ) { return $this->invalid( __( 'The Telegram bot token should look like 123456789:ABC… (from @BotFather).', 'profit-engine' ) ); } if ( '' !== $config['chat_id'] && ! preg_match( '/^(-?\d+|@[A-Za-z0-9_]{5,})$/', $config['chat_id'] ) ) { return $this->invalid( __( 'The Telegram chat ID should be a number (e.g. -1001234567890) or a @channelname.', 'profit-engine' ) ); } break; case 'teams': if ( '' !== $config['webhook_url'] ) { $error = $this->check_public_https( $config['webhook_url'], __( 'Microsoft Teams webhook URL', 'profit-engine' ) ); if ( $error ) { return $error; } } break; case 'webhook': if ( '' !== $config['url'] ) { $error = $this->check_public_https( $config['url'], __( 'webhook URL', 'profit-engine' ) ); if ( $error ) { return $error; } } $config['secret'] = sanitize_text_field( (string) $config['secret'] ); break; } return $config; } /** * Whether a field is a secret. * * @param string $channel Channel ID. * @param string $field Field name. */ private function is_secret( string $channel, string $field ): bool { return in_array( $field, self::SECRETS[ $channel ] ?? array(), true ); } /** * Mask a secret, keeping only its last 4 characters as a hint. * * @param string $value Secret. */ private function mask( string $value ): string { return '' === $value ? '' : self::MASK . substr( $value, -4 ); } /** * Whether a URL is https on one of the given hosts. * * @param string $url URL. * @param string[] $hosts Allowed hosts. */ private function url_on_host( string $url, array $hosts ): bool { return $this->is_https_url( $url ) && in_array( strtolower( (string) wp_parse_url( $url, PHP_URL_HOST ) ), $hosts, true ); } /** * Check that a URL is https and reachable on the public internet. * * @param string $url URL. * @param string $label Field name for the message. * @return \WP_Error|null Null when fine. */ private function check_public_https( string $url, string $label ): ?\WP_Error { if ( 'https' !== wp_parse_url( $url, PHP_URL_SCHEME ) ) { /* translators: %s: field name, e.g. "webhook URL". */ return $this->invalid( sprintf( __( 'The %s must start with https://', 'profit-engine' ), $label ) ); } // Resolves the host and refuses local or private network addresses. if ( false === wp_http_validate_url( $url ) ) { /* translators: %s: field name, e.g. "webhook URL". */ return $this->invalid( sprintf( __( 'The %s can\'t be reached from your site, or points to a local or private network address.', 'profit-engine' ), $label ) ); } return null; } /** * Whether a value is a valid https URL. * * @param string $url URL. */ private function is_https_url( string $url ): bool { return 'https' === wp_parse_url( $url, PHP_URL_SCHEME ) && false !== wp_http_validate_url( $url ); } /** * Build a validation error. * * @param string $message Message. */ private function invalid( string $message ): \WP_Error { return new \WP_Error( 'profit_engine_notification_invalid', $message, array( 'status' => 400 ) ); } /** * Recursively merge saved values over defaults (only known keys survive). * * @param array $defaults Defaults. * @param array $saved Saved values. * @return array */ private function merge( array $defaults, array $saved ): array { foreach ( $defaults as $key => $value ) { if ( ! array_key_exists( $key, $saved ) ) { continue; } $defaults[ $key ] = is_array( $value ) && is_array( $saved[ $key ] ) ? $this->merge( $value, $saved[ $key ] ) : $saved[ $key ]; } return $defaults; } }