| 1 |
<?php |
| 2 |
|
| 3 |
namespace Pushly\Admin; |
| 4 |
|
| 5 |
if ( ! defined( 'ABSPATH' ) ) { |
| 6 |
exit; |
| 7 |
} |
| 8 |
|
| 9 |
class Settings { |
| 10 |
private const MENU_ICON = 'data:image/svg+xml;base64,ICAgIDxzdmcKICAgICAgICB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciCiAgICAgICAgdmlld0JveD0iMCAwIDEwMjQgMTAyNCIKICAgICAgICBkYXRhLWljb249InB1c2hseSIKICAgICAgICBoZWlnaHQ9IjEwMjQiCiAgICAgICAgd2lkdGg9IjEwMjQiCiAgICAgICAgZmlsbD0iIzRkYmNhMiIKICAgICAgICBhcmlhLWhpZGRlbj0idHJ1ZSIKICAgID4KICAgICAgICA8cGF0aAogICAgICAgICAgICBkPSJNOTc0LjYwMiwxNTUuNjJjLTE5LjQ1Ni0yOC40NjctNDQuNzI2LTUwLjgyMy03NS4xMzEtNjYuNTA0Qzg2OC4zNyw3My4xMDEsODMzLjg5LDY1LDc5Ni45NCw2NUg2MjcuNTcKCQljLTEzMy40MzgsMC0xOTEuMzEyLDE1MC41MDMtMTk1Ljc1MywxNzIuMTAxbC0xMzcuMzQsNjMzLjU1N2MtNC43NDUsMjMuMTA1LTAuNDA4LDQ0LjQ3LDEyLjU4Miw2MS43NjJsMC41ODMsMC43NTQKCQljMTMuODUxLDE3LjAxNiwzMy42ODEsMjUuOTk5LDU3LjM1MiwyNS45OTljMjEuNTE2LDAsNDEuODg0LTcuNDk5LDYwLjUxNS0yMi4yOThsMC42MjYtMC40OTYKCQljMTguMDMxLTE1LjMwMywyOS40NjctMzMuOTc1LDMzLjg5OC01NS42MTFMNTA5LjE5OSw2NjFoMC4wOTZsMC4xNTYtMUg2ODMuMzVjMTUuNTgsMCwzMS4zMTctMS41ODUsNDcuMTE1LTQuNDcKCQljMS45NTUtMC4zMTIsMy44OTMtMC43MjYsNS44MzEtMS4wODNjMC4yOTUtMC4wNjEsMC41ODItMC4xMDMsMC44NzctMC4xNzJjMTYuOTQ1LTMuMjQxLDMzLjYxMi04LjU3MSw1My41MjEtMTYuOTMxCgkJYzM1LjE0OS0xNC44NzYsNjcuOTk2LTM2LjA4MSw5Ny40ODktNjIuODljNjMuMjg3LTU2LjU5NiwxMDQuMjA2LTEyNi4wNjIsMTIxLjY0Ni0yMDYuNTYzCgkJQzEwMjcuMTU2LDI4Ni4yNTcsMTAxNS4zMDQsMjE0LjgxOCw5NzQuNjAyLDE1NS42MnogTTg0NC4zMzQsMzU3LjU5Yy04LjI2NCwzNy41NTctMjYuMTksNjkuMzkxLTU0LjgzMSw5Ny4zMTEKCQljLTQuODE0LDQuNTQ5LTkuNjAzLDguNTYzLTE0LjM5OSwxMi4zMDhDNzUzLjA0MSw0ODQuMDUsNzMxLjA1Niw0OTIsNzA4LjM3Niw0OTJoLTE2My4yMWwyNi4zMDQtMTIzLjcxOWwxNS44MTUtNzQuMzU5CgkJYzYuMTA4LTE2LjE0MSwxNi41NTQtMzAuMzQ5LDMxLjE5NS00Mi4yNTRsMC42LTAuMjY5QzYzMi42NzEsMjQxLjA0MSw2NDcuMjE4LDIzNCw2NjIuNDQyLDIzMmgxMDkuMjkKCQljMjYuNDQyLDAsNDUuNjgyLDEwLjczLDYwLjU1OCwzNC4yNEM4NDcuOTIzLDI5Mi4wMzYsODUxLjgzNCwzMjEuODU4LDg0NC4zMzQsMzU3LjU5eiIKICAgICAgICAvPgogICAgICAgIDxwYXRoCiAgICAgICAgICAgIGQ9Ik0zNTUuMzEzLDE4Mi42MzZsLTAuMzgyLTAuNDg2Yy04LjgzOC0xMC44NDUtMjEuNDczLTE2LjU4LTM2LjU1OC0xNi41OGMtMTMuNzM4LDAtMjYuNzIxLDQuNzg0LTM4LjYxNywxNC4yMjEKCQlsLTAuMzgyLDAuMzE3Yy0xMS41MTUsOS43NjMtMTguODA1LDIxLjc3NC0yMS42MiwzNS41ODJMMjI2Ljc4NSwzNjJoLTAuMjA5bC04Mi4yMywzODYuODY3CgkJYy0zLjAzMiwxNC43MzYtMC4yNjEsMjguMzUzLDguMDIxLDM5LjM3MWwwLjM4MywwLjQ5MWM4LjgyOCwxMC44NTMsMjEuNDcyLDE2LjU2NywzNi41NTcsMTYuNTY3YzEzLjczOSwwLDI2LjcyOS00Ljc4LDM4LjYtMTQuMjE4CgkJbDAuMzkyLTAuMzJjMTEuNTE0LTkuNzYsMTguNzg3LTIxLjcwNiwyMS42MzctMzUuNTE0TDI4MC45MDQsNjA5aDAuMTk5bDgyLjIzOS0zODYuOTI4CgkJQzM2Ni4zNjcsMjA3LjMzLDM2My41ODYsMTkzLjY2NywzNTUuMzEzLDE4Mi42MzZ6IgogICAgICAgIC8+CiAgICAgICAgPHBhdGgKICAgICAgICAgICAgZD0iTTE2OC40OTQsMjQwLjVsLTAuMzM5LTAuNDNjLTcuNzI1LTkuNTA2LTE4LjgyMS0xNC41MzMtMzIuMDM5LTE0LjUzM2MtMTIuMDM1LDAtMjMuNDE4LDQuMTkyLTMzLjgzNywxMi40NjFsLTAuMzQ4LDAuMjgyCgkJYy0xMC4wOCw4LjU2LTE2LjQ3NiwxOC44NDItMTguOTUyLDMwLjk0Mkw3OC4zMjEsMjg3aC0wLjE3NEw2LjA3NSw2MjYuMzI5Yy0yLjY1OSwxMi45MzEtMC4yMjYsMjQuOTc2LDcuMDIxLDM0LjY0N2wwLjMzOSwwLjQ4MgoJCWM3LjczNCw5LjUwNiwxOC44MzEsMTQuNTU4LDMyLjA0OCwxNC41NThjMTIuMDQ0LDAsMjMuNDI3LTQuMTc1LDMzLjgzOC0xMi40NTZsMC4zMzktMC4yNjJjMTAuMDk3LTguNTYsMTYuNDc2LTE5LjAyLDE4Ljk2LTMxLjEzMwoJCUwxMjUuNzY4LDUwNGgwLjE4Mmw0OS41ODQtMjI4LjkzN0MxNzguMTg0LDI2Mi4xNDEsMTc1Ljc1OSwyNTAuMTY0LDE2OC40OTQsMjQwLjV6IgogICAgICAgIC8+CiAgICA8L3N2Zz4='; |
| 11 |
|
| 12 |
public function register_hooks(): void { |
| 13 |
add_action( 'admin_menu', [ $this, 'add_menu_page' ] ); |
| 14 |
add_action( 'rest_api_init', [ $this, 'register_settings' ] ); |
| 15 |
add_action( 'admin_init', [ $this, 'maybe_migrate_options' ] ); |
| 16 |
add_action( 'admin_init', [ $this, 'register_settings' ] ); |
| 17 |
add_action( 'admin_notices', [ $this, 'register_settings_errors' ] ); |
| 18 |
add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_scripts' ] ); |
| 19 |
add_action( |
| 20 |
'plugin_action_links_' . plugin_basename( PUSHLY__DIR . '/pushly.php' ), |
| 21 |
[ $this, 'add_links_to_plugin' ] |
| 22 |
); |
| 23 |
} |
| 24 |
|
| 25 |
public function add_menu_page(): void { |
| 26 |
add_menu_page( |
| 27 |
'Pushly', |
| 28 |
'Pushly', |
| 29 |
'manage_options', |
| 30 |
'pushly', |
| 31 |
[ $this, 'menu_page_html' ], |
| 32 |
self::MENU_ICON |
| 33 |
); |
| 34 |
} |
| 35 |
|
| 36 |
public function menu_page_html(): void { |
| 37 |
if ( ! current_user_can( 'manage_options' ) ) { |
| 38 |
return; |
| 39 |
} |
| 40 |
|
| 41 |
printf( |
| 42 |
'<div class="wrap" id="pushly-settings">%s</div>', |
| 43 |
esc_html__( 'Loading…', 'pushly' ) |
| 44 |
); |
| 45 |
} |
| 46 |
|
| 47 |
public function enqueue_scripts( string $admin_page ): void { |
| 48 |
if ( 'toplevel_page_pushly' !== $admin_page ) { |
| 49 |
return; |
| 50 |
} |
| 51 |
|
| 52 |
$asset_file = PUSHLY__DIR_BUILD . '/settings.asset.php'; |
| 53 |
if ( ! file_exists( $asset_file ) ) { |
| 54 |
return; |
| 55 |
} |
| 56 |
|
| 57 |
$asset = include $asset_file; |
| 58 |
|
| 59 |
wp_enqueue_script( |
| 60 |
'pushly-script', |
| 61 |
plugins_url( 'build/settings.js', PUSHLY__DIR_BUILD ), |
| 62 |
$asset['dependencies'], |
| 63 |
$asset['version'], |
| 64 |
[ 'in_footer' => true ] |
| 65 |
); |
| 66 |
|
| 67 |
$options = get_option( 'pushly', [] ); |
| 68 |
|
| 69 |
wp_add_inline_script( |
| 70 |
'pushly-script', |
| 71 |
'const pushly_env = ' . wp_json_encode( [ |
| 72 |
'CDN_DOMAIN' => PUSHLY__CDN_DOMAIN, |
| 73 |
'API_NONCE' => wp_create_nonce( 'wp_rest' ), |
| 74 |
'debug_logging_enabled' => ! empty( $options['debug_logging_enabled'] ), |
| 75 |
] ), |
| 76 |
'before' |
| 77 |
); |
| 78 |
|
| 79 |
wp_enqueue_style( |
| 80 |
'pushly-style', |
| 81 |
plugins_url( 'build/settings.css', PUSHLY__DIR_BUILD ), |
| 82 |
array_filter( $asset['dependencies'], fn( $style ) => wp_style_is( $style, 'registered' ) ), |
| 83 |
$asset['version'] |
| 84 |
); |
| 85 |
} |
| 86 |
|
| 87 |
/** |
| 88 |
* @param string[] $actions |
| 89 |
* @return string[] |
| 90 |
*/ |
| 91 |
public function add_links_to_plugin( array $actions ): array { |
| 92 |
return array_merge( $actions, [ |
| 93 |
'settings' => '<a href="' . admin_url( 'admin.php?page=pushly' ) . '">' . __( 'Settings', 'pushly' ) . '</a>', |
| 94 |
] ); |
| 95 |
} |
| 96 |
|
| 97 |
/** |
| 98 |
* One-time option migrations. Hooked to admin_init only so DB writes |
| 99 |
* never happen on REST requests. |
| 100 |
*/ |
| 101 |
public function maybe_migrate_options(): void { |
| 102 |
$new_options = []; |
| 103 |
|
| 104 |
$legacy_options = get_option( 'pushly_options' ); |
| 105 |
if ( ! empty( $legacy_options ) ) { |
| 106 |
delete_option( 'pushly_options' ); |
| 107 |
if ( ! empty( $legacy_options['pushly_domain_key'] ) ) { |
| 108 |
$new_options['sdk_key'] = $legacy_options['pushly_domain_key']; |
| 109 |
} |
| 110 |
} |
| 111 |
|
| 112 |
$current_options = get_option( 'pushly', [] ); |
| 113 |
if ( empty( $current_options['enabled_post_types'] ) ) { |
| 114 |
$new_options['enabled_post_types'] = [ 'post' ]; |
| 115 |
} |
| 116 |
|
| 117 |
if ( ! empty( $new_options ) ) { |
| 118 |
update_option( 'pushly', array_merge( $new_options, $current_options ) ); |
| 119 |
} |
| 120 |
} |
| 121 |
|
| 122 |
public function register_settings(): void { |
| 123 |
$schema = [ |
| 124 |
'type' => 'object', |
| 125 |
'properties' => [ |
| 126 |
'domain_id' => [ 'type' => 'integer' ], |
| 127 |
'sdk_key' => [ 'type' => 'string' ], |
| 128 |
'api_key' => [ 'type' => [ 'string', 'null' ] ], |
| 129 |
'sending_enabled' => [ 'type' => 'boolean' ], |
| 130 |
'auto_send_enabled' => [ 'type' => 'boolean' ], |
| 131 |
'enabled_post_types' => [ 'type' => 'array' ], |
| 132 |
'initialization_disabled' => [ 'type' => 'boolean' ], |
| 133 |
'debug_logging_enabled' => [ 'type' => 'boolean' ], |
| 134 |
], |
| 135 |
]; |
| 136 |
|
| 137 |
register_setting( |
| 138 |
'options', |
| 139 |
'pushly', |
| 140 |
[ |
| 141 |
'type' => 'object', |
| 142 |
'show_in_rest' => [ 'schema' => $schema ], |
| 143 |
'sanitize_callback' => [ $this, 'sanitize_settings' ], |
| 144 |
] |
| 145 |
); |
| 146 |
} |
| 147 |
|
| 148 |
/** |
| 149 |
* @param array $input |
| 150 |
* @return array|\WP_Error |
| 151 |
*/ |
| 152 |
public function sanitize_settings( array $input ) { |
| 153 |
$current_options = get_option( 'pushly', [] ); |
| 154 |
$input['domain_id'] = (int) filter_var( $input['domain_id'], FILTER_SANITIZE_NUMBER_INT ); |
| 155 |
$input['sdk_key'] = sanitize_text_field( $input['sdk_key'] ); |
| 156 |
|
| 157 |
// Detect debug_logging_enabled transition from false → true and write environment snapshot. |
| 158 |
$was_enabled = ! empty( $current_options['debug_logging_enabled'] ); |
| 159 |
$now_enabled = ! empty( $input['debug_logging_enabled'] ); |
| 160 |
|
| 161 |
if ( $now_enabled && ! $was_enabled ) { |
| 162 |
try { |
| 163 |
$sdk_key = $input['sdk_key'] ?? ''; |
| 164 |
if ( strlen( $sdk_key ) >= 4 ) { |
| 165 |
$masked = str_repeat( '*', strlen( $sdk_key ) - 4 ) . substr( $sdk_key, -4 ); |
| 166 |
} else { |
| 167 |
$masked = str_repeat( '*', strlen( $sdk_key ) ); |
| 168 |
} |
| 169 |
|
| 170 |
$snapshot_context = [ |
| 171 |
'plugin_version' => PUSHLY__PLUGIN_VERSION, |
| 172 |
'wordpress_version' => get_bloginfo( 'version' ), |
| 173 |
'php_version' => phpversion(), |
| 174 |
'sdk_key' => $masked, |
| 175 |
'api_key_configured' => ! empty( $input['api_key'] ), |
| 176 |
'sending_enabled' => ! empty( $input['sending_enabled'] ), |
| 177 |
'auto_send_enabled' => ! empty( $input['auto_send_enabled'] ), |
| 178 |
'enabled_post_types' => $input['enabled_post_types'] ?? [], |
| 179 |
'debug_logging_enabled_at' => gmdate( 'c' ), |
| 180 |
'active_theme' => wp_get_theme()->get( 'Name' ), |
| 181 |
'multisite' => is_multisite(), |
| 182 |
]; |
| 183 |
|
| 184 |
$log_store = new LogStore(); |
| 185 |
$log_writer = new LogWriter( $log_store, true ); |
| 186 |
$log_writer->log_immediate( |
| 187 |
'info', |
| 188 |
'environment_snapshot', |
| 189 |
'Environment snapshot: Captured — Debug logging enabled at ' . gmdate( 'c' ), |
| 190 |
null, |
| 191 |
$snapshot_context |
| 192 |
); |
| 193 |
} catch ( \Exception $e ) { |
| 194 |
// Silently suppress — settings save must proceed even if snapshot fails. |
| 195 |
} |
| 196 |
} |
| 197 |
|
| 198 |
if ( ! empty( $input['sending_enabled'] ) ) { |
| 199 |
if ( empty( $input['api_key'] ) ) { |
| 200 |
return new \WP_Error( |
| 201 |
'missing_required_property', |
| 202 |
'API key must be provided when sending is enabled.', |
| 203 |
[ 'status' => 400 ] |
| 204 |
); |
| 205 |
} |
| 206 |
|
| 207 |
// Encrypt the API key if it's new or has changed from the stored value. |
| 208 |
// On first save, current_options['api_key'] is empty so we always encrypt. |
| 209 |
// On subsequent saves, we only re-encrypt if the user entered a different key. |
| 210 |
$stored_key = $current_options['api_key'] ?? ''; |
| 211 |
if ( empty( $stored_key ) || $stored_key !== $input['api_key'] ) { |
| 212 |
$input['api_key'] = Util::encrypt_api_key( |
| 213 |
$input['sdk_key'], |
| 214 |
$input['sdk_key'], |
| 215 |
$input['api_key'] |
| 216 |
); |
| 217 |
} |
| 218 |
} |
| 219 |
|
| 220 |
return $input; |
| 221 |
} |
| 222 |
|
| 223 |
public function register_settings_errors(): void { |
| 224 |
settings_errors( 'pushly' ); |
| 225 |
} |
| 226 |
} |
| 227 |
|