['GET','bootstrap','__return_true'], '/register'=>['POST','register','public_permission'], '/manage'=>['POST','manage','public_permission'], '/update'=>['POST','update','public_permission'], '/cancel'=>['POST','cancel','public_permission'], '/staff/list'=>['POST','listing','staff_permission'], '/staff/checkin'=>['POST','checkin','staff_permission'], '/staff/login'=>['POST','staff_login','public_permission'], '/staff/session'=>['POST','staff_session','staff_permission'], '/staff/logout'=>['POST','staff_logout','staff_permission'], '/admin/staff-code'=>['POST','staff_code','admin_permission'], '/admin/settings'=>['POST','settings','admin_permission'], '/admin/save'=>['POST','save','admin_permission'], '/admin/cancel'=>['POST','admin_cancel','admin_permission'], '/admin/approve'=>['POST','approve','admin_permission'], '/admin/reject'=>['POST','reject','admin_permission'], '/admin/undo'=>['POST','undo','admin_permission'], '/admin/resend'=>['POST','resend','admin_permission'], '/admin/purge'=>['POST','purge','admin_permission'], ]; foreach($routes as $route=>$args) { register_rest_route('groupentry/v1',$route,['methods'=>$args[0], 'permission_callback'=>$args[2]==='__return_true'?'__return_true':[self::class,$args[2]], 'callback'=>static function($req) use($args) { try { return rest_ensure_response(call_user_func([self::class,$args[1]],$req)); } catch(InvalidArgumentException $e) { return new WP_Error('groupentry_invalid',$e->getMessage(),['status'=>400]); } catch(Throwable $e) { return new WP_Error('groupentry_error',__('The operation could not be completed. Try again or contact the administrator.', 'groupentry'), ['status'=>503]); } }]); } } public static function staff_permission($req = null) { if (current_user_can('groupentry_checkin') || current_user_can('manage_options')) { return true; } if (!$req instanceof WP_REST_Request) { return false; } $token=(string)$req->get_header('x-ge-staff'); if (!preg_match('/^[a-f0-9]{64}$/D',$token)) { return false; } $session=get_transient('groupentry_staff_'.hash('sha256',$token)); $code=get_option('groupentry_staff_access',[]); return is_array($session) && !empty($code['hash']) && ($session['expires']??0)>time() && hash_equals(hash('sha256',$code['hash']),(string)($session['generation']??'')); } public static function admin_permission() { return current_user_can('manage_options'); } public static function staff_code_enabled() { return !empty(get_option('groupentry_staff_access',[])['hash']); } private static function staff_display_key() { return hash_hmac('sha256','groupentry-staff-display-v1',wp_salt('auth'),true); } private static function staff_crypto() { if (!class_exists('ParagonIE_Sodium_Compat')) { require_once ABSPATH.WPINC.'/sodium_compat/autoload.php'; } } private static function encrypt_staff_code($code) { self::staff_crypto(); $nonce=random_bytes(24); $key=self::staff_display_key(); $cipher=ParagonIE_Sodium_Compat::crypto_secretbox($code,$nonce,$key); return ['version'=>1,'ciphertext'=>base64_encode($nonce.$cipher)]; } public static function stored_staff_code() { if (!current_user_can('manage_options')) { return null; } $access=get_option('groupentry_staff_access',[]); $display=$access['display']??null; if (empty($access['hash']) || !is_array($display) || ($display['version']??0)!==1 || !is_string($display['ciphertext']??null) || strlen($display['ciphertext'])>256) { return null; } $packed=base64_decode($display['ciphertext'],true); if ($packed===false || strlen($packed)<48 || strlen($packed)>168) { return null; } try { self::staff_crypto(); $nonce=substr($packed,0,24); $cipher=substr($packed,24); $key=self::staff_display_key(); $code=ParagonIE_Sodium_Compat::crypto_secretbox_open($cipher,$nonce,$key); return is_string($code) && wp_check_password($code,$access['hash']) ? $code : null; } catch (Throwable $e) { return null; } } public static function staff_code($req) { $d=self::data($req); if (!empty($d['disable'])) { if (!update_option('groupentry_staff_access',[],false) && self::staff_code_enabled()) { GroupEntry_Core::failure(__('Could not disable staff code access.', 'groupentry')); } } else { $code=$d['code']??null; if (!is_string($code) || mb_strlen($code)<8 || strlen($code)>128 || trim($code)!==$code) { GroupEntry_Core::invalid(__('Use a code with 8–128 characters and no leading or trailing spaces.', 'groupentry')); } $access=['hash'=>wp_hash_password($code),'display'=>self::encrypt_staff_code($code)]; if (!update_option('groupentry_staff_access',$access,false) && get_option('groupentry_staff_access')!==$access) { GroupEntry_Core::failure(__('Could not save the staff access code.', 'groupentry')); } } return ['enabled'=>self::staff_code_enabled(),'code'=>self::stored_staff_code()]; } public static function staff_login($req) { $host=wp_parse_url(home_url(),PHP_URL_HOST); if (!is_ssl() && wp_parse_url(home_url(),PHP_URL_SCHEME)!=='https' && !in_array($host,['localhost','127.0.0.1','::1'],true)) { return new WP_Error('groupentry_staff_https',__('Staff code access requires HTTPS.', 'groupentry'),['status'=>403]); } $d=self::data($req); if (!self::rate('staff-login-ip:'.self::remote_address(),10) || !self::rate('staff-login-global',100)) { return new WP_Error('groupentry_staff_rate',__('Too many login attempts. Try again in ten minutes.', 'groupentry'),['status'=>429]); } $access=get_option('groupentry_staff_access',[]); $code=$d['code']??null; if (empty($access['hash']) || !is_string($code) || strlen($code)>128 || !wp_check_password($code,$access['hash'])) { return new WP_Error('groupentry_staff_code',__('The access code is incorrect or code access is disabled.', 'groupentry'),['status'=>403]); } $token=bin2hex(random_bytes(32)); $expires=time()+12*HOUR_IN_SECONDS; if (!set_transient('groupentry_staff_'.hash('sha256',$token),['expires'=>$expires,'generation'=>hash('sha256',$access['hash'])],12*HOUR_IN_SECONDS)) { GroupEntry_Core::failure(__('Could not save the staff session.', 'groupentry')); } return ['token'=>$token,'expires'=>$expires]; } public static function staff_session() { return ['authenticated'=>true]; } public static function staff_logout($req) { $token=(string)$req->get_header('x-ge-staff'); if (preg_match('/^[a-f0-9]{64}$/D',$token)) { delete_transient('groupentry_staff_'.hash('sha256',$token)); } return ['message'=>__('Staff session ended.', 'groupentry')]; } public static function challenge() { $payload=time().'.'.bin2hex(random_bytes(16)); return $payload.'.'.hash_hmac('sha256',$payload,wp_salt('nonce')); } public static function public_permission($req) { $origin=$req->get_header('origin'); if($origin) { $a=wp_parse_url($origin); $b=wp_parse_url(home_url()); if(($a['host']??'')!==($b['host']??'') || ($a['scheme']??'')!==($b['scheme']??'') || ($a['port']??null)!==($b['port']??null)) { return new WP_Error('groupentry_origin',__('The request comes from a different website.', 'groupentry'),['status'=>403]); } } $challenge=$req->get_header('x-ge-challenge'); $parts=explode('.',(string)$challenge); if(count($parts)!==3 || !preg_match('/^[0-9]+$/D',$parts[0]) || (int)$parts[0]time()+60 || !hash_equals(hash_hmac('sha256',$parts[0].'.'.$parts[1],wp_salt('nonce')),$parts[2])) { return new WP_Error('groupentry_challenge',__('The form has expired. Refresh the page.', 'groupentry'),['status'=>403]); } if(strlen((string)$req->get_body())>131072) { return new WP_Error('groupentry_size',__('The request is too large.', 'groupentry'),['status'=>413]); } if(!self::rate('ip:'.self::remote_address(),100)) { return new WP_Error('groupentry_rate',__('Too many requests. Try again in ten minutes.', 'groupentry'),['status'=>429]); } return true; } public static function remote_address() { $address = isset($_SERVER['REMOTE_ADDR']) && is_string($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : ''; return filter_var($address, FILTER_VALIDATE_IP) !== false ? $address : 'unknown'; } public static function rate($identity,$limit) { global $wpdb; $t=GroupEntry_Core::table('rates'); $window=(int)floor(time()/600); $key=hash_hmac('sha256',$identity.':'.$window,wp_salt('auth')); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned transaction state and live capacity require fresh database access. $ok=$wpdb->query($wpdb->prepare('INSERT INTO %i (bucket,hits,expires_at) VALUES (%s,1,%d) ON DUPLICATE KEY UPDATE hits=hits+1',$t,$key,($window+1)*600)); if($ok===false) { return false; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned transaction state and live capacity require fresh database access. $hits=(int)$wpdb->get_var($wpdb->prepare('SELECT hits FROM %i WHERE bucket=%s',$t,$key)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned transaction state and live capacity require fresh database access. $wpdb->query($wpdb->prepare('DELETE FROM %i WHERE expires_at<%d',$t,time()-600)); return $hits<=$limit; } public static function data($req) { $d=$req->get_json_params(); if(!is_array($d)) { GroupEntry_Core::invalid(__('Invalid request.', 'groupentry')); } return $d; } public static function authorized($d) { $r=GroupEntry_Core::by_token($d['token']??''); if(!$r) { GroupEntry_Core::invalid(__('The link is invalid or has been replaced. Use the latest registration email.', 'groupentry')); } return $r; } public static function bootstrap() { $s=GroupEntry_Core::settings(); $slots=[]; foreach(GroupEntry_Core::slots() as $slot) { $slots[]=['id'=>(int)$slot->id,'label'=>substr($slot->starts_at,11,5).' – '.substr($slot->ends_at,11,5),'capacity'=>(int)$slot->capacity,'used'=>(int)$slot->used,'entered'=>(int)$slot->entered,'remaining'=>max(0,(int)$slot->capacity-(int)$slot->used),'started'=>GroupEntry_Core::slot_started($slot),'start_epoch'=>(new DateTimeImmutable($slot->starts_at,GroupEntry_Core::timezone($s)))->getTimestamp()]; } $public=array_intersect_key($s,array_flip(['title','subtitle','date','start','end','privacy','contact','intro_heading','intro_text','timezone','max_group_size','collect_age','min_age','max_age','confirmation_mode','registration_mode'])); $public['privacy']=GroupEntry_Privacy::notice_text($s); $public['open']=$s['open'] && !GroupEntry_Core::event_over($s); $public=array_merge($public,GroupEntry_Core::colors($s)); return ['settings'=>$public,'slots'=>$slots,'challenge'=>self::challenge(),'staff_code_enabled'=>self::staff_code_enabled()]; } public static function register($req) { $d=self::data($req); if(!empty($d['website'])) { GroupEntry_Core::invalid(__('Could not submit the registration.', 'groupentry')); } if(!self::rate('register-ip:'.self::remote_address(),20) || !self::rate('email:'.strtolower((string)($d['email']??'')),6)) { GroupEntry_Core::invalid(__('Too many registrations. Try again in ten minutes.', 'groupentry')); } if(!is_string($d['token']??null) || !preg_match('/^[a-f0-9]{64}$/D',$d['token'])) { GroupEntry_Core::invalid(__('Your browser could not create a secure identifier. Refresh the page.', 'groupentry')); } return GroupEntry_Core::save($d,0,false,$d['token']); } public static function manage($req) { $d=self::data($req); return GroupEntry_Core::present(self::authorized($d)); } public static function update($req) { $d=self::data($req); $r=self::authorized($d); return GroupEntry_Core::save($d,$r->id,false,$d['token']); } public static function cancel($req) { $d=self::data($req); $r=self::authorized($d); return GroupEntry_Core::cancel($r->id,false,$d['token']); } public static function listing($req) { global $wpdb; $d=self::data($req); $r=GroupEntry_Core::table('registrations'); $c=GroupEntry_Core::table('participants'); $slot_id=absint($d['slot_id']??0); $status=in_array($d['status']??'', ['pending','active','checked_in','cancelled','rejected'],true) ? $d['status'] : ''; $q=isset($d['q']) && is_string($d['q']) ? mb_substr(trim($d['q']),0,100):''; $like='%'.$wpdb->esc_like($q).'%'; $page=max(1,absint($d['page']??1)); $limit=100; $offset=($page-1)*$limit; // Optional filters are bound values in fixed SQL, including the empty-filter branches. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Live registration lists must reflect current transaction state. $total=(int)$wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM %i r WHERE (%d=0 OR r.slot_id=%d) AND (%s='' OR r.status=%s) AND (%s='' OR (CONCAT(r.contact_first,' ',r.contact_last) LIKE %s OR CONCAT(r.contact_last,' ',r.contact_first) LIKE %s OR r.code LIKE %s OR EXISTS (SELECT 1 FROM %i c WHERE c.registration_id=r.id AND (CONCAT(c.first_name,' ',c.last_name) LIKE %s OR CONCAT(c.last_name,' ',c.first_name) LIKE %s))))",$r,$slot_id,$slot_id,$status,$status,$q,$like,$like,$like,$c,$like,$like)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Live registration lists must reflect current transaction state. $rows=$wpdb->get_results($wpdb->prepare("SELECT r.* FROM %i r WHERE (%d=0 OR r.slot_id=%d) AND (%s='' OR r.status=%s) AND (%s='' OR (CONCAT(r.contact_first,' ',r.contact_last) LIKE %s OR CONCAT(r.contact_last,' ',r.contact_first) LIKE %s OR r.code LIKE %s OR EXISTS (SELECT 1 FROM %i c WHERE c.registration_id=r.id AND (CONCAT(c.first_name,' ',c.last_name) LIKE %s OR CONCAT(c.last_name,' ',c.first_name) LIKE %s)))) ORDER BY r.slot_id,r.contact_last,r.contact_first,r.id LIMIT %d OFFSET %d",$r,$slot_id,$slot_id,$status,$status,$q,$like,$like,$like,$c,$like,$like,$limit+1,$offset)); $has_more=count($rows)>$limit; $rows=array_slice($rows,0,$limit); $items=array_map([GroupEntry_Core::class,'present'],$rows); if(!current_user_can('manage_options')) { foreach($items as &$item) { unset($item['email'],$item['mail_status']); } unset($item); } return ['items'=>$items,'slots'=>self::bootstrap()['slots'],'page'=>$page,'total'=>$total,'has_more'=>$has_more]; } public static function checkin($req) { $d=self::data($req); if(!isset($d['version']) || !is_numeric($d['version'])) { GroupEntry_Core::invalid(__('Refresh the list before confirming check-in.', 'groupentry')); } $result=GroupEntry_Core::checkin(absint($d['id']??0),$d['version']); if(!current_user_can('manage_options')) { unset($result['email'],$result['mail_status']); } return $result; } public static function settings($req) { $d=self::data($req); if (isset($d['settings'])) { GroupEntry_Core::update_settings($d['settings']); } $s=GroupEntry_Core::settings(); $s['default_texts']=GroupEntry_Core::registration_text_defaults(); $s['staff_code_enabled']=self::staff_code_enabled(); $s['staff_code']=self::stored_staff_code(); return $s; } public static function save($req) { $d=self::data($req); $token=bin2hex(random_bytes(32)); return GroupEntry_Core::save($d,absint($d['id']??0),true,$token); } public static function admin_cancel($req) { $d=self::data($req); return GroupEntry_Core::cancel(absint($d['id']??0),true); } public static function approve($req) { $d=self::data($req); return GroupEntry_Core::decide(absint($d['id']??0),$d['version']??null,true); } public static function reject($req) { $d=self::data($req); return GroupEntry_Core::decide(absint($d['id']??0),$d['version']??null,false); } public static function undo($req) { $d=self::data($req); return GroupEntry_Core::undo(absint($d['id']??0)); } public static function resend($req) { $d=self::data($req); return GroupEntry_Core::resend(absint($d['id']??0)); } public static function purge($req) { $d=self::data($req); return GroupEntry_Core::purge($d['confirmation']??''); } }