|null */ private $cache = null; /** * Defaults. * * @return array */ public static function defaults(): array { return array( 'enabled' => true, 'oauth_enabled' => true, 'dcr_enabled' => true, 'app_password_auth' => true, 'allow_app_passwords' => false, // Re-allow Application Passwords for MCP requests when a security plugin turned them off. 'allowed_roles' => array( 'administrator' ), 'app_password_scopes' => array( 'read', 'content:write', 'commerce:write', 'forms:write' ), 'allowed_origins' => array(), 'allow_insecure_http' => false, 'rate_read' => 300, 'rate_write' => 60, 'rate_auth_fail' => 10, 'access_token_ttl' => 3600, 'refresh_token_ttl' => 30 * DAY_IN_SECONDS, 'log_retention_days' => 90, 'history_days' => 30, 'tool_mode' => 'compact', // compact | full. 'pexels_key' => '', 'unsplash_key' => '', 'groups' => array(), // group id => off | read | write. Missing = group default. 'tools_off' => array(), // Tool names switched off one by one. 'read_only' => false, // AI apps may only read; every change is refused. 'delete_data' => false, // Remove all plugin data when the plugin is deleted. Off: deleting keeps activity, history, connections and settings. ); } /** * All settings. * * @return array */ public function all(): array { if ( null === $this->cache ) { $stored = get_option( self::OPTION, array() ); $this->cache = array_merge( self::defaults(), is_array( $stored ) ? $stored : array() ); } return $this->cache; } /** * One setting. * * @param string $key Key. * @param mixed $default Fallback. * @return mixed */ public function get( string $key, $default = null ) { $all = $this->all(); return array_key_exists( $key, $all ) ? $all[ $key ] : $default; } /** * Access level for a tool group. * * @param string $group Group id. * @param string $default Group default. */ public function group_mode( string $group, string $default ): string { $groups = (array) $this->get( 'groups', array() ); $mode = $groups[ $group ] ?? $default; return in_array( $mode, array( 'off', 'read', 'write' ), true ) ? $mode : $default; } /** * Sanitize and save. * * @param array $input Raw input. * @return array */ public function save( array $input ): array { $out = $this->all(); unset( $out['file_edit_override'] ); // Not a setting of the free plugin. foreach ( array( 'enabled', 'oauth_enabled', 'dcr_enabled', 'app_password_auth', 'allow_app_passwords', 'allow_insecure_http', 'delete_data', 'read_only' ) as $key ) { if ( array_key_exists( $key, $input ) ) { $out[ $key ] = (bool) filter_var( $input[ $key ], FILTER_VALIDATE_BOOLEAN ); } } $ints = array( 'rate_read' => array( 10, 5000 ), 'rate_write' => array( 1, 1000 ), 'rate_auth_fail' => array( 3, 100 ), 'access_token_ttl' => array( 300, DAY_IN_SECONDS ), 'refresh_token_ttl' => array( HOUR_IN_SECONDS, 180 * DAY_IN_SECONDS ), 'log_retention_days' => array( 1, 3650 ), ); foreach ( $ints as $key => $range ) { if ( isset( $input[ $key ] ) && is_numeric( $input[ $key ] ) ) { $out[ $key ] = max( $range[0], min( $range[1], (int) $input[ $key ] ) ); } } if ( isset( $input['allowed_roles'] ) ) { $roles = array_keys( wp_roles()->roles ); $out['allowed_roles'] = array_values( array_intersect( array_map( 'sanitize_key', (array) $input['allowed_roles'] ), $roles ) ); if ( ! $out['allowed_roles'] ) { $out['allowed_roles'] = array( 'administrator' ); } } if ( isset( $input['app_password_scopes'] ) ) { $out['app_password_scopes'] = Auth\Scopes::sanitize( (array) $input['app_password_scopes'] ); } if ( isset( $input['allowed_origins'] ) ) { $raw = is_array( $input['allowed_origins'] ) ? $input['allowed_origins'] : preg_split( '/[\s,]+/', (string) $input['allowed_origins'] ); $list = array(); foreach ( (array) $raw as $origin ) { $origin = is_scalar( $origin ) ? self::normalize_origin( (string) $origin ) : ''; if ( '' !== $origin ) { $list[] = $origin; } } $out['allowed_origins'] = array_values( array_unique( $list ) ); } if ( isset( $input['tool_mode'] ) && in_array( $input['tool_mode'], array( 'compact', 'full' ), true ) ) { $out['tool_mode'] = $input['tool_mode']; } foreach ( array( 'pexels_key', 'unsplash_key' ) as $key ) { if ( ! empty( $input[ $key . '_clear' ] ) ) { $out[ $key ] = ''; } elseif ( isset( $input[ $key ] ) && '' !== trim( (string) $input[ $key ] ) ) { // The field is shown empty, so an empty field keeps the saved key. $out[ $key ] = preg_replace( '/[^A-Za-z0-9_\-]/', '', (string) $input[ $key ] ); } } if ( isset( $input['history_days'] ) && is_numeric( $input['history_days'] ) ) { $out['history_days'] = max( 1, min( 365, (int) $input['history_days'] ) ); } if ( isset( $input['groups'] ) && is_array( $input['groups'] ) ) { $groups = (array) $out['groups']; foreach ( $input['groups'] as $group => $mode ) { $group = sanitize_key( (string) $group ); if ( $group && in_array( $mode, array( 'off', 'read', 'write' ), true ) ) { $groups[ $group ] = $mode; } } $out['groups'] = $groups; } if ( isset( $input['tools_off'] ) && is_array( $input['tools_off'] ) ) { $out['tools_off'] = array_values( array_unique( array_filter( array_map( static function ( $t ) { return preg_replace( '/[^a-z0-9_]/', '', strtolower( (string) $t ) ); }, $input['tools_off'] ) ) ) ); } update_option( self::OPTION, $out, true ); $this->cache = $out; return $out; } /** * An origin as browsers send it: scheme://host[:port], lower case, * http or https only. A path is dropped; wildcards and "null" are refused. * * @param string $origin Raw origin. */ public static function normalize_origin( string $origin ): string { $origin = strtolower( trim( $origin ) ); if ( '' === $origin || false !== strpos( $origin, '*' ) || 'null' === $origin ) { return ''; } $parts = wp_parse_url( $origin ); if ( ! is_array( $parts ) || empty( $parts['scheme'] ) || empty( $parts['host'] ) || ! in_array( $parts['scheme'], array( 'http', 'https' ), true ) ) { return ''; } if ( isset( $parts['user'] ) || isset( $parts['pass'] ) ) { return ''; } if ( ! preg_match( '/^(\[[0-9a-f:.]+\]|[a-z0-9.-]+)$/', $parts['host'] ) ) { return ''; } $port = isset( $parts['port'] ) ? (int) $parts['port'] : 0; if ( ( 'https' === $parts['scheme'] && 443 === $port ) || ( 'http' === $parts['scheme'] && 80 === $port ) ) { $port = 0; } return $parts['scheme'] . '://' . $parts['host'] . ( $port ? ':' . $port : '' ); } /** * Drop the in-memory copy. */ public function flush(): void { $this->cache = null; } }