'POST','permission_callback'=>static fn()=>PostTypes::can_edit(),'callback'=>[$this,'export']]); register_rest_route('digiter-builder/v1','/packages/inspect',['methods'=>'POST','permission_callback'=>static fn()=>PostTypes::can_edit(),'callback'=>[$this,'inspect']]); register_rest_route('digiter-builder/v1','/packages/import',['methods'=>'POST','permission_callback'=>static fn()=>current_user_can('manage_options')&¤t_user_can('upload_files'),'callback'=>[$this,'import']]); } private static function fail(string $message): void {throw new \RuntimeException($message);} // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception is returned as WP_Error/JSON; HTML callers escape their output. private static function doc($raw): array { $doc=Document::clean($raw);if(is_wp_error($doc)){self::fail($doc->get_error_message());}return $doc; } private static function dependency(array $node): int { return match($node['type']){'component'=>(int)$node['props']['source'],'reusable'=>(int)$node['props']['postId'],'loop'=>empty($node['children'])?(int)$node['props']['template']:0,default=>0}; } private static function verify_dependencies(array $root,array $dependencies): void { $docs=[0=>$root];foreach($dependencies as $dep){$docs[$dep['source']]=$dep['doc'];} $graph=[]; foreach($docs as $source=>$doc){ $targets=[]; foreach(Components::map($doc['nodes']) as $node){ if(in_array($node['type'],['builder-template','mega-menu'],true)){self::fail('Convert external template references into reusable sections before packaging.');} $target=self::dependency($node); if($target&&!isset($docs[$target])){self::fail('A linked document is missing from the package.');} if($target){$targets[$target]=true;} } $graph[$source]=array_keys($targets); } $visiting=[];$heights=[]; $height=function(int $id)use(&$height,&$visiting,&$heights,$graph): int { if(isset($visiting[$id])){self::fail('Invalid linked document in the package.');} if(isset($heights[$id])){return $heights[$id];} $visiting[$id]=true;$max=0; foreach($graph[$id] as $target){$max=max($max,1+$height($target));} unset($visiting[$id]);return $heights[$id]=$max; }; // Validate all bundled documents, including unreferenced dependencies. foreach(array_keys($docs) as $id){if($height($id)+($id===0?0:1)>4){self::fail('The design package has too many nested dependencies.');}} } public function export($r) { try { if(strlen($r->get_body())>800000){self::fail('This document is larger than the 750 KB limit.');} $root=self::doc($r->get_param('doc'));$selected=$r->get_param('node'); if($selected){$map=Components::map($root['nodes']);if(!is_string($selected)||!isset($map[$selected])){self::fail('Select an existing section.');}$root['nodes']=[$map[$selected]];$root['settings']['component']=null;} $package=['format'=>self::FORMAT,'version'=>DIGIBL_VERSION,'name'=>$root['title']?:'Digiter design','source'=>home_url('/'),'doc'=>$root,'dependencies'=>[],'assets'=>[],'styles'=>[],'external'=>[]]; $seen=[];$styles=[];$assets=[];$external=[];$bytes=0; $visit=function(array &$doc,int $depth=0)use(&$visit,&$package,&$seen,&$styles,&$assets,&$external,&$bytes){ if($depth>4){self::fail('The design package has too many nested dependencies.');} $walk=function(&$nodes)use(&$walk,&$visit,&$package,&$seen,&$styles,$depth){ foreach($nodes as &$node){ $style=$node['style']['globalStyle']??''; if($style){$state=GlobalStyles::state();if(isset($state['items'][$style])){$styles[$style]=$state['items'][$style]['name'];}$node['style']=GlobalStyles::resolve($node['style']);$node['style']['globalStyle']='';$node['style']['globalOverrides']=[];} $id=self::dependency($node); if($id&&!isset($seen[$id])){ if(count($seen)>=12){self::fail('A design package supports at most twelve linked documents.');} if(get_post_status($id)!=='publish'||post_password_required($id)||!current_user_can('edit_post',$id)){self::fail('A linked document is unavailable or you cannot export it.');} $seen[$id]=true;$dependency=self::doc(json_decode(get_post_meta($id,'_digibl_published',true),true));$visit($dependency,$depth+1); $package['dependencies'][]=['source'=>$id,'name'=>$dependency['title']?:get_the_title($id),'doc'=>$dependency]; } if(in_array($node['type'],['builder-template','mega-menu'],true)){self::fail('Convert external template references into reusable sections before packaging.');} $walk($node['children']); } };$walk($doc['nodes']); $ids=[]; $scan=function($value,$key='')use(&$scan,&$ids,&$external){ if(is_array($value)){ if(isset($value['id'],$value['url'])&&is_numeric($value['id'])&&$value['id']){$ids[(int)$value['id']]=true;} foreach($value as $k=>$v){$scan($v,(string)$k);}return; } if(in_array($key,['bodyFont','titleFont'],true)&&is_numeric($value)&&$value){$ids[(int)$value]=true;} if(!is_string($value)){return;} preg_match_all('~https?://[^\s<>"\x27]+~',$value,$matches); foreach($matches[0] as $url){$url=html_entity_decode($url,ENT_QUOTES,'UTF-8');if(str_starts_with($url,DIGIBL_URL.'assets/')){continue;}$id=attachment_url_to_postid($url);if($id){$ids[$id]=true;}elseif(preg_match('/\.(?:jpe?g|png|gif|webp|avif|mp3|ogg|wav|woff2?)(?:[?#].*)?$/i',$url)){$external[$url]=true;}} };$scan($doc); $upload=wp_upload_dir();$base=realpath($upload['basedir']); foreach(array_keys($ids) as $id){ if(isset($assets[$id])){continue;} if(count($assets)>=30){self::fail('A design package supports at most thirty media files.');} if(!current_user_can('edit_post',$id)){self::fail('You cannot export a referenced media file.');} $path=get_attached_file($id);$real=$path?realpath($path):false;$mime=get_post_mime_type($id); if(!$base||!$real||!str_starts_with($real,$base.DIRECTORY_SEPARATOR)||!is_file($real)||!in_array($mime,self::MIMES,true)){self::fail('A referenced media file cannot be packaged.');} $size=filesize($real);if(!$size||$size>4000000||($bytes+=$size)>16000000){self::fail('Use media files below 4 MB and packages below 16 MB of media.');} $data=file_get_contents($real);$assets[$id]=['source'=>$id,'name'=>sanitize_file_name(basename($real)),'mime'=>$mime,'url'=>wp_get_attachment_url($id),'alt'=>(string)get_post_meta($id,'_wp_attachment_image_alt',true),'sha256'=>hash('sha256',$data),'data'=>base64_encode($data)]; } }; $visit($package['doc']);$package['assets']=array_values($assets);$package['styles']=array_values($styles);$package['external']=array_keys($external); self::verify_dependencies($package['doc'],$package['dependencies']); // Built-in images move through the plugin's portable token, not a source host. $portable=function(&$v)use(&$portable){if(is_array($v)){foreach($v as &$x){$portable($x);}}elseif(is_string($v)&&str_starts_with($v,DIGIBL_URL.'assets/templates/')){$v='dbs-asset:'.substr($v,strlen(DIGIBL_URL.'assets/'));}};$portable($package['doc']);foreach($package['dependencies'] as &$dep){$portable($dep['doc']);} if(strlen(wp_json_encode($package))>self::MAX){self::fail('The design package is too large.');} return StudioTools::response(['package'=>$package]); }catch(\RuntimeException $e){return StudioTools::error($e->getMessage());} } private function validate($r): array { if(strlen($r->get_body())>self::MAX){self::fail('The design package is too large.');} $p=$r->get_param('package'); if(!is_array($p)||($p['format']??'')!==self::FORMAT||!is_array($p['assets']??null)||!array_is_list($p['assets'])||count($p['assets'])>30||!is_array($p['dependencies']??null)||!array_is_list($p['dependencies'])||count($p['dependencies'])>12){self::fail('Unsupported design package.');} foreach(['styles','external'] as $key){if(isset($p[$key])&&!is_array($p[$key])){self::fail('Unsupported design package.');}} $seen=[];$seenURLs=[];$bytes=0; foreach($p['assets'] as &$asset){ if(!is_array($asset)||!is_int($asset['source']??null)||$asset['source']<1||isset($seen[$asset['source']])||!is_string($asset['data']??null)||!is_string($asset['name']??null)||!is_string($asset['url']??null)||!in_array($asset['mime']??'',self::MIMES,true)||strlen($asset['data'])>5400000){self::fail('Invalid media in the design package.');} $parts=wp_parse_url($asset['url']);if(!$parts||!in_array($parts['scheme']??'', ['http','https'],true)||empty($parts['host'])||isset($parts['user'])||isset($parts['pass'])||isset($seenURLs[$asset['url']])){self::fail('Invalid media in the design package.');}$seenURLs[$asset['url']]=true; $seen[$asset['source']]=true;$binary=base64_decode($asset['data'],true); if($binary===false||!strlen($binary)||strlen($binary)>4000000||($bytes+=strlen($binary))>16000000||!is_string($asset['sha256']??null)||!hash_equals(hash('sha256',$binary),$asset['sha256'])){self::fail('The package media checksum or size is invalid.');} $asset['name']=sanitize_file_name($asset['name']);$filetype=wp_check_filetype($asset['name']); if(!$filetype['ext']||!in_array($filetype['type'],self::MIMES,true)){self::fail('This media file type is not allowed.');} if(str_starts_with($filetype['type'],'image/')){$info=@getimagesizefromstring($binary);if(!$info||$info['mime']!==$filetype['type']||$info[0]*$info[1]>40000000){self::fail('The package contains an invalid or oversized image.');}} $asset['mime']=$filetype['type'];$asset['alt']=Document::text($asset['alt']??'',500); }unset($asset); // Source attachment IDs are not valid destination IDs. Preserve the // package URL until assets are imported, even when IDs collide here. $sources=[];foreach($p['assets'] as $asset){$sources[$asset['source']]=$asset['url'];} $portable=function(&$value)use(&$portable,$sources){if(!is_array($value)){return;}if(isset($value['id'],$value['url'])){$old=(int)$value['id'];if($old&&isset($sources[$old])){$value['url']=$sources[$old];}$value['id']=0;}foreach($value as &$item){if(is_array($item)){$portable($item);}}}; $portable($p['doc']);$p['doc']=self::doc($p['doc']??null); $seen=[]; foreach($p['dependencies'] as &$dep){ if(!is_array($dep)||!is_int($dep['source']??null)||$dep['source']<1||isset($seen[$dep['source']])){self::fail('Invalid linked document in the package.');} $seen[$dep['source']]=true;$portable($dep['doc']);$dep['doc']=self::doc($dep['doc']??null);$dep['name']=Document::text($dep['name']??'',200)?:'Imported component'; }unset($dep); self::verify_dependencies($p['doc'],$p['dependencies']); $p['name']=Document::text($p['name']??'',200)?:'Imported design'; return $p; } public function inspect($r) { try{$p=$this->validate($r);$conflicts=[];foreach($p['dependencies'] as $dep){$matches=get_posts(['post_type'=>'digibl_template','post_status'=>['publish','draft'],'title'=>$dep['name'],'numberposts'=>1]);if($matches){$conflicts[]=$dep['name'];}} return StudioTools::response(['name'=>$p['name'],'nodes'=>count(Components::map($p['doc']['nodes'])),'assets'=>array_map(static fn($a)=>['name'=>$a['name'],'bytes'=>strlen(base64_decode($a['data']))],$p['assets']),'dependencies'=>array_column($p['dependencies'],'name'),'conflicts'=>$conflicts,'styles'=>array_slice(array_filter($p['styles']??[],'is_string'),0,50),'external'=>array_slice(array_filter($p['external']??[],'is_string'),0,100),'html'=>(new Renderer(true))->render($this->preview_doc($p))]); }catch(\RuntimeException $e){return StudioTools::error($e->getMessage());} } private function preview_doc(array $p): array { $docs=[];foreach($p['dependencies'] as $dep){$docs[$dep['source']]=$dep['doc'];} $doc=$p['doc']; $scan=function(&$nodes,int $depth=0)use(&$scan,$docs){if($depth>4){$nodes=[];return;}foreach($nodes as &$n){$id=self::dependency($n);if(!$id){$scan($n['children'],$depth);continue;}$ref=$docs[$id];$children=$n['type']==='component'?Components::apply($ref,$n['props']['values'],$n['props']['variant']):$ref['nodes'];$children=Document::scope_nodes($children,'preview'.substr(hash('sha256',$n['id']),0,10),20,false);$scan($children,$depth+1);if($n['type']==='loop'){$n['children']=$children;$n['props']['template']=0;}else{$n['type']='section';$n['props']=[];$n['children']=$children;}}};$scan($doc['nodes']); // Never interpret source attachment IDs as destination attachment IDs in previews. $reset=function(&$value)use(&$reset){if(!is_array($value)){return;}if(isset($value['id'],$value['url'])){$value['id']=0;}foreach($value as $k=>&$item){if(in_array($k,['bodyFont','titleFont'],true)){$item=0;}elseif(is_array($item)){$reset($item);}}};$reset($doc); return self::doc($doc); } public function import($r) { $created=[];$media=[]; try{ if($r->get_param('accept')!==true){self::fail('Review the package before importing resources.');} $p=$this->validate($r);$mode=$r->get_param('names')??'copy';if(!in_array($mode,['copy','prefix'],true)){self::fail('Choose how to name imported components.');} $prefix=$mode==='prefix'?Document::text($r->get_param('prefix')??'',60):''; if($mode==='prefix'&&!$prefix){self::fail('Enter a prefix for imported component names.');} require_once ABSPATH.'wp-admin/includes/file.php';require_once ABSPATH.'wp-admin/includes/media.php';require_once ABSPATH.'wp-admin/includes/image.php'; $assetMap=[];$urls=[];$byURL=[]; foreach($p['assets'] as $asset){ $file=wp_upload_bits($asset['name'],null,base64_decode($asset['data']));if($file['error']){self::fail('A media file could not be imported.');} $check=wp_check_filetype_and_ext($file['file'],$asset['name']);if(!$check['ext']||!$check['type']||!in_array($check['type'],self::MIMES,true)){wp_delete_file($file['file']);self::fail('This media file type is not allowed.');} $id=wp_insert_attachment(['post_mime_type'=>$check['type'],'post_title'=>pathinfo($asset['name'],PATHINFO_FILENAME),'post_status'=>'inherit'],$file['file'],0,true); if(is_wp_error($id)){wp_delete_file($file['file']);self::fail('A media attachment could not be created.');}$media[]=$id; wp_update_attachment_metadata($id,wp_generate_attachment_metadata($id,$file['file']));update_post_meta($id,'_wp_attachment_image_alt',$asset['alt']); $assetMap[$asset['source']]=$id;$byURL[$asset['url']]=$id;$urls[$asset['url']]=wp_get_attachment_url($id); } $ids=[];$keys=[]; foreach($p['dependencies'] as $dep){ $title=$prefix?($prefix.' '.$dep['name']):($dep['name'].' — '.Language::text('Imported copy')); $id=wp_insert_post(wp_slash(['post_type'=>'digibl_template','post_status'=>'draft','post_title'=>$title,'post_author'=>get_current_user_id()]),true); if(is_wp_error($id)){self::fail('An imported component could not be created.');}$created[]=$id;$ids[$dep['source']]=$id; if(!empty($dep['doc']['settings']['component'])){$keys[$dep['source']]=wp_generate_uuid4();} } $rewrite=function(array $doc)use($assetMap,$urls,$ids,$keys,$byURL){ $values=function(&$v,$key='')use(&$values,$assetMap,$urls,$byURL){if(is_array($v)){if(isset($v['id'],$v['url'])){$v['id']=$byURL[$v['url']]??0;}foreach($v as $k=>&$x){$values($x,(string)$k);}}elseif(in_array($key,['bodyFont','titleFont'],true)){$v=$assetMap[(int)$v]??0;}elseif(is_string($v)){$v=strtr($v,$urls);}};$values($doc); $nodes=function(&$list)use(&$nodes,$ids,$keys){foreach($list as &$n){$source=self::dependency($n);if($source){if($n['type']==='component'){$n['props']['source']=$ids[$source];$n['props']['sourceKey']=$keys[$source]??'';}elseif($n['type']==='loop'){$n['props']['template']=$ids[$source];}else{$n['props']['postId']=$ids[$source];}}$n['style']['globalStyle']='';$nodes($n['children']);}};$nodes($doc['nodes']);return self::doc($doc); }; foreach($p['dependencies'] as $dep){ $id=$ids[$dep['source']];$doc=$rewrite($dep['doc']);$doc['title']=get_the_title($id);if(isset($keys[$dep['source']])){$doc['settings']['component']['key']=$keys[$dep['source']];} $json=wp_json_encode($doc); if(!add_post_meta($id,'_digibl_published',wp_slash($json),true)||!add_post_meta($id,'_digibl_draft',wp_slash(wp_json_encode(['revision'=>wp_generate_uuid4(),'doc'=>$doc])),true)){self::fail('An imported component could not be saved.');} } foreach($created as $id){$result=wp_update_post(['ID'=>$id,'post_status'=>'publish'],true);if(is_wp_error($result)||get_post_status($id)!=='publish'){self::fail('An imported component could not be published.');}} return StudioTools::response(['doc'=>$rewrite($p['doc']),'createdMedia'=>$media,'createdTemplates'=>$created,'message'=>'Resources imported. Insert the layout, then save or publish your page.']); }catch(\Throwable $e){foreach(array_reverse($created) as $id){wp_delete_post($id,true);}foreach(array_reverse($media) as $id){wp_delete_attachment($id,true);}return StudioTools::error($e instanceof \RuntimeException?$e->getMessage():'The package could not be imported. Newly created resources were removed.');} } }