|
| 1 | +<?php |
| 2 | + |
| 3 | +/** |
| 4 | + * Widget Controller - public REST API endpoints for the embeddable widget. |
| 5 | + * |
| 6 | + * All endpoints are publicly accessible (no authentication required) |
| 7 | + * but are guarded by a widget_enabled setting and rate limiting. |
| 8 | + */ |
| 9 | + |
| 10 | +namespace Escalated\Api; |
| 11 | + |
| 12 | +use Escalated\Models\Setting; |
| 13 | +use Escalated\Models\Ticket; |
| 14 | +use Escalated\Services\TicketService; |
| 15 | +use WP_REST_Request; |
| 16 | +use WP_REST_Server; |
| 17 | + |
| 18 | +class Widget_Controller extends Base_Controller |
| 19 | +{ |
| 20 | + /** |
| 21 | + * Route base. |
| 22 | + * |
| 23 | + * @var string |
| 24 | + */ |
| 25 | + protected $rest_base = 'widget'; |
| 26 | + |
| 27 | + /** |
| 28 | + * Register widget routes. |
| 29 | + */ |
| 30 | + public function register_routes(): void |
| 31 | + { |
| 32 | + // Widget configuration. |
| 33 | + register_rest_route( |
| 34 | + $this->namespace, |
| 35 | + '/'.$this->rest_base.'/config', |
| 36 | + [ |
| 37 | + [ |
| 38 | + 'methods' => WP_REST_Server::READABLE, |
| 39 | + 'callback' => [$this, 'get_config'], |
| 40 | + 'permission_callback' => [$this, 'widget_enabled_check'], |
| 41 | + ], |
| 42 | + ] |
| 43 | + ); |
| 44 | + |
| 45 | + // List KB articles for widget. |
| 46 | + register_rest_route( |
| 47 | + $this->namespace, |
| 48 | + '/'.$this->rest_base.'/articles', |
| 49 | + [ |
| 50 | + [ |
| 51 | + 'methods' => WP_REST_Server::READABLE, |
| 52 | + 'callback' => [$this, 'get_articles'], |
| 53 | + 'permission_callback' => [$this, 'widget_enabled_check'], |
| 54 | + 'args' => [ |
| 55 | + 'search' => [ |
| 56 | + 'type' => 'string', |
| 57 | + 'sanitize_callback' => 'sanitize_text_field', |
| 58 | + ], |
| 59 | + ], |
| 60 | + ], |
| 61 | + ] |
| 62 | + ); |
| 63 | + |
| 64 | + // Get a single KB article by slug. |
| 65 | + register_rest_route( |
| 66 | + $this->namespace, |
| 67 | + '/'.$this->rest_base.'/articles/(?P<slug>[a-z0-9-]+)', |
| 68 | + [ |
| 69 | + [ |
| 70 | + 'methods' => WP_REST_Server::READABLE, |
| 71 | + 'callback' => [$this, 'get_article'], |
| 72 | + 'permission_callback' => [$this, 'widget_enabled_check'], |
| 73 | + 'args' => [ |
| 74 | + 'slug' => [ |
| 75 | + 'required' => true, |
| 76 | + 'type' => 'string', |
| 77 | + 'sanitize_callback' => 'sanitize_title', |
| 78 | + ], |
| 79 | + ], |
| 80 | + ], |
| 81 | + ] |
| 82 | + ); |
| 83 | + |
| 84 | + // Create a ticket via widget. |
| 85 | + register_rest_route( |
| 86 | + $this->namespace, |
| 87 | + '/'.$this->rest_base.'/tickets', |
| 88 | + [ |
| 89 | + [ |
| 90 | + 'methods' => WP_REST_Server::CREATABLE, |
| 91 | + 'callback' => [$this, 'create_ticket'], |
| 92 | + 'permission_callback' => [$this, 'widget_enabled_check'], |
| 93 | + 'args' => [ |
| 94 | + 'name' => [ |
| 95 | + 'required' => true, |
| 96 | + 'type' => 'string', |
| 97 | + 'sanitize_callback' => 'sanitize_text_field', |
| 98 | + ], |
| 99 | + 'email' => [ |
| 100 | + 'required' => true, |
| 101 | + 'type' => 'string', |
| 102 | + 'sanitize_callback' => 'sanitize_email', |
| 103 | + ], |
| 104 | + 'subject' => [ |
| 105 | + 'required' => true, |
| 106 | + 'type' => 'string', |
| 107 | + 'sanitize_callback' => 'sanitize_text_field', |
| 108 | + ], |
| 109 | + 'description' => [ |
| 110 | + 'required' => true, |
| 111 | + 'type' => 'string', |
| 112 | + ], |
| 113 | + ], |
| 114 | + ], |
| 115 | + ] |
| 116 | + ); |
| 117 | + |
| 118 | + // Lookup ticket by reference. |
| 119 | + register_rest_route( |
| 120 | + $this->namespace, |
| 121 | + '/'.$this->rest_base.'/tickets/(?P<ref>[A-Z]+-\d+)', |
| 122 | + [ |
| 123 | + [ |
| 124 | + 'methods' => WP_REST_Server::READABLE, |
| 125 | + 'callback' => [$this, 'get_ticket'], |
| 126 | + 'permission_callback' => [$this, 'widget_enabled_check'], |
| 127 | + 'args' => [ |
| 128 | + 'ref' => [ |
| 129 | + 'required' => true, |
| 130 | + 'type' => 'string', |
| 131 | + ], |
| 132 | + ], |
| 133 | + ], |
| 134 | + ] |
| 135 | + ); |
| 136 | + } |
| 137 | + |
| 138 | + /** |
| 139 | + * Permission callback: check if the widget is enabled and rate limit. |
| 140 | + * |
| 141 | + * @return bool|\WP_Error |
| 142 | + */ |
| 143 | + public function widget_enabled_check() |
| 144 | + { |
| 145 | + if (! Setting::get_bool('widget_enabled', false)) { |
| 146 | + return new \WP_Error( |
| 147 | + 'escalated_widget_disabled', |
| 148 | + __('Widget is not enabled.', 'escalated'), |
| 149 | + ['status' => 403] |
| 150 | + ); |
| 151 | + } |
| 152 | + |
| 153 | + // Rate limit by IP address. |
| 154 | + $ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown'; |
| 155 | + $key = 'escalated_widget_rate_'.md5($ip); |
| 156 | + $limit = 30; // 30 requests per minute for widget. |
| 157 | + $current = (int) get_transient($key); |
| 158 | + |
| 159 | + if ($current >= $limit) { |
| 160 | + return new \WP_Error( |
| 161 | + 'escalated_rate_limited', |
| 162 | + __('Too many requests. Please try again later.', 'escalated'), |
| 163 | + ['status' => 429] |
| 164 | + ); |
| 165 | + } |
| 166 | + |
| 167 | + set_transient($key, $current + 1, MINUTE_IN_SECONDS); |
| 168 | + |
| 169 | + return true; |
| 170 | + } |
| 171 | + |
| 172 | + /** |
| 173 | + * Get widget configuration. |
| 174 | + */ |
| 175 | + public function get_config() |
| 176 | + { |
| 177 | + return $this->success([ |
| 178 | + 'color' => Setting::get('widget_color', '#3B82F6'), |
| 179 | + 'position' => Setting::get('widget_position', 'bottom-right'), |
| 180 | + 'greeting' => Setting::get('widget_greeting', __('Hi there! How can we help?', 'escalated')), |
| 181 | + ]); |
| 182 | + } |
| 183 | + |
| 184 | + /** |
| 185 | + * Get KB articles for the widget. |
| 186 | + */ |
| 187 | + public function get_articles(WP_REST_Request $request) |
| 188 | + { |
| 189 | + $search = $request->get_param('search'); |
| 190 | + |
| 191 | + $args = [ |
| 192 | + 'post_type' => 'escalated_article', |
| 193 | + 'post_status' => 'publish', |
| 194 | + 'posts_per_page' => 10, |
| 195 | + 'orderby' => 'date', |
| 196 | + 'order' => 'DESC', |
| 197 | + ]; |
| 198 | + |
| 199 | + if (! empty($search)) { |
| 200 | + $args['s'] = $search; |
| 201 | + } |
| 202 | + |
| 203 | + $query = new \WP_Query($args); |
| 204 | + $articles = []; |
| 205 | + |
| 206 | + foreach ($query->posts as $post) { |
| 207 | + $articles[] = [ |
| 208 | + 'id' => $post->ID, |
| 209 | + 'title' => $post->post_title, |
| 210 | + 'slug' => $post->post_name, |
| 211 | + 'excerpt' => wp_trim_words($post->post_content, 30), |
| 212 | + ]; |
| 213 | + } |
| 214 | + |
| 215 | + return $this->success($articles); |
| 216 | + } |
| 217 | + |
| 218 | + /** |
| 219 | + * Get a single KB article by slug. |
| 220 | + */ |
| 221 | + public function get_article(WP_REST_Request $request) |
| 222 | + { |
| 223 | + $slug = $request->get_param('slug'); |
| 224 | + |
| 225 | + $posts = get_posts([ |
| 226 | + 'post_type' => 'escalated_article', |
| 227 | + 'post_status' => 'publish', |
| 228 | + 'name' => $slug, |
| 229 | + 'numberposts' => 1, |
| 230 | + ]); |
| 231 | + |
| 232 | + if (empty($posts)) { |
| 233 | + return $this->error('escalated_not_found', __('Article not found.', 'escalated'), 404); |
| 234 | + } |
| 235 | + |
| 236 | + $post = $posts[0]; |
| 237 | + |
| 238 | + return $this->success([ |
| 239 | + 'id' => $post->ID, |
| 240 | + 'title' => $post->post_title, |
| 241 | + 'slug' => $post->post_name, |
| 242 | + 'content' => wp_kses_post($post->post_content), |
| 243 | + 'date' => $post->post_date, |
| 244 | + ]); |
| 245 | + } |
| 246 | + |
| 247 | + /** |
| 248 | + * Create a ticket via the widget (guest ticket). |
| 249 | + */ |
| 250 | + public function create_ticket(WP_REST_Request $request) |
| 251 | + { |
| 252 | + $service = new TicketService; |
| 253 | + |
| 254 | + try { |
| 255 | + $ticket = $service->create_guest([ |
| 256 | + 'subject' => $request->get_param('subject'), |
| 257 | + 'description' => wp_kses_post($request->get_param('description')), |
| 258 | + 'guest_name' => $request->get_param('name'), |
| 259 | + 'guest_email' => $request->get_param('email'), |
| 260 | + 'channel' => 'widget', |
| 261 | + ]); |
| 262 | + |
| 263 | + return $this->success([ |
| 264 | + 'message' => __('Ticket created successfully.', 'escalated'), |
| 265 | + 'reference' => $ticket->reference, |
| 266 | + 'guest_token' => $ticket->guest_token, |
| 267 | + ], 201); |
| 268 | + } catch (\Throwable $e) { |
| 269 | + return $this->error('escalated_create_failed', $e->getMessage(), 500); |
| 270 | + } |
| 271 | + } |
| 272 | + |
| 273 | + /** |
| 274 | + * Lookup a ticket by reference (requires guest_email for verification). |
| 275 | + */ |
| 276 | + public function get_ticket(WP_REST_Request $request) |
| 277 | + { |
| 278 | + $ref = sanitize_text_field($request->get_param('ref')); |
| 279 | + |
| 280 | + $ticket = Ticket::find_by_reference($ref); |
| 281 | + if (! $ticket) { |
| 282 | + return $this->error('escalated_not_found', __('Ticket not found.', 'escalated'), 404); |
| 283 | + } |
| 284 | + |
| 285 | + return $this->success([ |
| 286 | + 'reference' => $ticket->reference, |
| 287 | + 'subject' => $ticket->subject, |
| 288 | + 'status' => $ticket->status, |
| 289 | + 'created_at' => $ticket->created_at, |
| 290 | + 'updated_at' => $ticket->updated_at, |
| 291 | + ]); |
| 292 | + } |
| 293 | + |
| 294 | + /** |
| 295 | + * Get the widget settings. |
| 296 | + */ |
| 297 | + public static function get_settings(): array |
| 298 | + { |
| 299 | + return [ |
| 300 | + 'widget_enabled' => Setting::get_bool('widget_enabled', false), |
| 301 | + 'widget_color' => Setting::get('widget_color', '#3B82F6'), |
| 302 | + 'widget_position' => Setting::get('widget_position', 'bottom-right'), |
| 303 | + 'widget_greeting' => Setting::get('widget_greeting', __('Hi there! How can we help?', 'escalated')), |
| 304 | + ]; |
| 305 | + } |
| 306 | + |
| 307 | + /** |
| 308 | + * Update widget settings. |
| 309 | + * |
| 310 | + * @param array $settings Settings to update. |
| 311 | + */ |
| 312 | + public static function update_settings(array $settings): void |
| 313 | + { |
| 314 | + $allowed = ['widget_enabled', 'widget_color', 'widget_position', 'widget_greeting']; |
| 315 | + |
| 316 | + foreach ($allowed as $key) { |
| 317 | + if (isset($settings[$key])) { |
| 318 | + Setting::set($key, sanitize_text_field($settings[$key])); |
| 319 | + } |
| 320 | + } |
| 321 | + } |
| 322 | +} |
0 commit comments