'POST','permission_callback'=>'__return_true','callback'=>[$this,'token']]); register_rest_route('digiter-builder/v1','/forms/submit',['methods'=>'POST','permission_callback'=>'__return_true','callback'=>[$this,'submit']]); } private function error(string $message,int $status=400): \WP_Error { return new \WP_Error('digibl_form',$message,['status'=>$status]); } private function reply(array $data): \WP_REST_Response { $response=new \WP_REST_Response($data);$response->header('Cache-Control','no-store, private');return $response; } private function ip_key(): string { // Forwarded headers are not trusted; reverse proxies must set REMOTE_ADDR correctly. $address = isset($_SERVER['REMOTE_ADDR']) && is_string($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : ''; $address = filter_var($address, FILTER_VALIDATE_IP) !== false ? $address : ''; return hash_hmac('sha256',$address,wp_salt('nonce')); } private function same_origin($r): bool { $origin=$r->get_header('origin');if(!$origin){return true;} $parts=wp_parse_url($origin);$site=wp_parse_url(home_url('/')); return is_array($parts)&&($parts['scheme']??'')===($site['scheme']??'')&&strtolower($parts['host']??'')===strtolower($site['host']??'')&&($parts['port']??null)===($site['port']??null); } private function definition($r) { if(!$this->same_origin($r)){return $this->error('This form must be submitted from this website.',403);} if(strlen($r->get_body())>80000){return $this->error('The form is too large.',413);} $chain=PostTypes::source_chain($r->get_param('sourceChain'));if($chain===null)return $this->error('This form is unavailable.',404); $id=absint($r->get_param('post'));$node=$r->get_param('node'); if(!$id||!is_string($node)||!preg_match('/^[a-zA-Z0-9_-]{1,80}$/D',$node)||!PostTypes::public_source($id,$chain)){return $this->error('This form is unavailable.',404);} $json=get_post_meta($id,'_digibl_published',true);$doc=Document::clean(json_decode($json,true)); if(is_wp_error($doc)){return $this->error('This form is unavailable.',404);} $found=null; $scan=function($nodes,bool $visible=true)use(&$scan,&$found,$node,$id){foreach($nodes as $n){$s=$n['style'];$show=$visible&&DisplayRules::visible($s,$id);if($n['id']===$node&&$n['type']==='form'&&$show){$found=$n['props'];return;}if(!in_array($n['type'],['loop','reusable','component','data-repeater','mega-menu'],true)){$scan($n['children'],$show);}}};$scan($doc['nodes']); if(!$found||!$found['fields']){return $this->error('This form is unavailable.',404);} return ['post'=>$id,'host'=>$chain[0]['post']??$id,'node'=>$node,'props'=>$found,'revision'=>hash('sha256',$json.'|'.wp_json_encode($chain))]; } private function sign(string $payload,array $def): string { // Authenticated requests already have a verified principal. Proxy egress // is not its identity; anonymous requests retain their address binding. $identity=is_user_logged_in()?hash_hmac('sha256','user:'.get_current_user_id().'|session:'.wp_get_session_token(),wp_salt('nonce')):$this->ip_key(); return hash_hmac('sha256',$payload.'|'.$def['post'].'|'.$def['node'].'|'.$def['revision'].'|'.$identity,wp_salt('auth')); } public function token($r) { $def=$this->definition($r);if(is_wp_error($def)){return $def;} $payload=time().'.'.bin2hex(random_bytes(16)); return $this->reply(['token'=>$payload.'.'.$this->sign($payload,$def),'wait'=>2]); } private function claim(string $key,int $expires): bool { if(!wp_next_scheduled('digibl_form_cleanup')){wp_schedule_event(time()+3600,'hourly','digibl_form_cleanup');} return add_option('digibl_form_limit_'.$key,(string)$expires,'',false); } public function submit($r) { $def=$this->definition($r);if(is_wp_error($def)){return $def;} $token=$r->get_param('token'); if(!is_string($token)||!preg_match('/^(\d{10})\.([a-f0-9]{32})\.([a-f0-9]{64})$/D',$token,$match)){return $this->error('Refresh this form and try again.',403);} $payload=$match[1].'.'.$match[2];$age=time()-(int)$match[1]; if($age<2){return $this->error('Please wait a moment before sending.',429);} if($age>1800||!hash_equals($this->sign($payload,$def),$match[3])){return $this->error('This form session expired. Refresh and try again.',403);} if($r->get_param('website')){return $this->reply(['message'=>$def['props']['success']]);} $raw=$r->get_param('values');if(!is_array($raw)){return $this->error('Complete the form fields.');} $lines=[];$reply='';$uploads=[];$files=$r->get_file_params();$visibility=FormFields::visibility($def['props']['fields'],$raw); foreach($def['props']['fields'] as $i=>$f){ if(empty($visibility[$i])){continue;} if($f['type']==='file'){$file=FormFields::validate_file($files['f'.$i]??null,$f['label'],$f['required']==='yes');if(is_wp_error($file)){return $file;}if($file){$uploads[]=$file;$lines[]=$f['label']."\n".$file['name'];}continue;} $v=$raw['f'.$i]??''; if(!is_scalar($v)){return $this->error(Language::text('Invalid field: ').$f['label']);} $v=trim(sanitize_textarea_field((string)$v)); if(strlen($v)>5000){return $this->error(Language::text('The field is too long: ').$f['label']);} if('yes'===$f['required']&&$v===''){return $this->error(Language::text('Complete the required field: ').$f['label']);} if($v!==''){ switch($f['type']){ case 'email':if(!is_email($v)){return $this->error(Language::text('Enter a valid email address: ').$f['label']);}if(!$reply){$reply=sanitize_email($v);}break; case 'number':if(!is_numeric($v)||!is_finite((float)$v)){return $this->error(Language::text('Enter a valid number: ').$f['label']);}break; case 'date':$date=\DateTimeImmutable::createFromFormat('!Y-m-d',$v);if(!$date||$date->format('Y-m-d')!==$v){return $this->error(Language::text('Enter a valid date: ').$f['label']);}break; case 'checkbox':if($v!=='yes'){return $this->error(Language::text('Invalid checkbox: ').$f['label']);}break; case 'select':$options=array_slice(array_values(array_filter(array_map('trim',preg_split('/\r?\n/',$f['options'])),static fn($x)=>$x!=='')),0,100);if(!in_array($v,$options,true)){return $this->error(Language::text('Choose a valid option: ').$f['label']);}break; } } $lines[]=$f['label']."\n".$v; } if($def['props']['consent']&&$r->get_param('consent')!=='yes'){return $this->error('Please confirm the consent checkbox.');} $recipient=FormEntries::settings()['recipient']?:get_option('admin_email');if(!is_email($recipient)){return $this->error('The contact recipient is not configured.',503);} // Atomic slots remain effective during concurrent requests and with a persistent object cache. $bucket=(int)floor(time()/600);$accepted=false;$slotKey=''; for($i=0;$i<5;$i++){$slotKey='rate_'.hash('sha256',$this->ip_key().'|'.$bucket.'|'.$i);if($this->claim($slotKey,time()+1200)){$accepted=true;break;}} if(!$accepted){return $this->error('Too many messages. Please try again in ten minutes.',429);} $once='once_'.hash('sha256',$token); if(!$this->claim($once,time()+3600)){delete_option('digibl_form_limit_'.$slotKey);return $this->error('This message has already been submitted.',409);} $subject=sanitize_text_field(get_bloginfo('name').' — '.($def['props']['title']?:'Contact form')); $body="Page: ".get_permalink($def['host'])."\n\n".implode("\n\n",$lines); if($def['props']['consent']){$body.="\n\nConsent: ".$def['props']['consent'];} $headers=['Content-Type: text/plain; charset=UTF-8'];if($reply){$headers[]='Reply-To: '.$reply;} $attachments=[];$temporary=[];$sent=false;$entry=0;$attempted=false;$uncertain=false; try{ // Never put visitor uploads in public wp-content/uploads or the Media Library. if($uploads){require_once ABSPATH.'wp-admin/includes/file.php';} foreach($uploads as $file){$path=wp_tempnam($file['name']);if(!$path||!TemporaryUpload::move($file,$path,['pdf'=>'application/pdf','jpg|jpeg'=>'image/jpeg','png'=>'image/png'])){if($path){@wp_delete_file($path);}throw new \RuntimeException('Temporary file storage failed.');}$temporary[]=$path;$attachments[count($temporary).'-'.$file['name']]=$path;} $entry=FormEntries::prepare($def,$lines,$reply,(bool)$def['props']['consent']); if(is_wp_error($entry)){ delete_option('digibl_form_limit_'.$once);delete_option('digibl_form_limit_'.$slotKey); return $entry; } $attempted=true; $sent=wp_mail($recipient,$subject,$body,$headers,$attachments); }catch(\Throwable $e){$sent=false;$uncertain=$attempted;} finally{foreach($temporary as $path){@wp_delete_file($path);}} if($uncertain){ FormEntries::delivery((int)$entry,'unconfirmed');FormEntries::issue('transport'); // A transport hook can throw after accepting mail. Keep the claim: never auto-retry it. return $this->error('Delivery could not be confirmed. Please contact the site owner before submitting again.',409); } FormEntries::delivery((int)$entry,$sent?'submitted':'failed'); if(!$sent){delete_option('digibl_form_limit_'.$once);delete_option('digibl_form_limit_'.$slotKey);return $this->error('The server could not send your message. Your text has been kept; try again later.',503);} // Extensions must not turn an already accepted email into a retryable failure. try{do_action('digibl_form_delivered',$def,$lines,$reply,(bool)$def['props']['consent']);} catch(\Throwable $error){FormEntries::issue('delivery_hook');} return $this->reply(['message'=>$def['props']['success']]); } public function cleanup(): void { global $wpdb; $like=$wpdb->esc_like('digibl_form_limit_').'%'; $keys=$wpdb->get_col($wpdb->prepare("SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s AND CAST(option_value AS UNSIGNED) < %d LIMIT 2000",$like,time())); foreach($keys as $key){delete_option($key);} } }