true,'data'=>$data]:array_filter(['ok'=>false,'error'=>is_string($errors)?$errors:'Request failed.','errors'=>is_array($errors)?$errors:null]),JSON_INVALID_UTF8_SUBSTITUTE); } $config=lab_config(); $owner=$config['workspace']??''; if (!is_string($owner)||$owner==='') { api_reply(false,null,['workspace'=>'Workspace is not configured.'],503); exit; } $method=$_SERVER['REQUEST_METHOD']??'GET'; $action=is_string($_GET['api']??null)?$_GET['api']:''; $input=$_POST; if ($method==='POST' && str_contains($_SERVER['CONTENT_TYPE']??'','application/json')) { $decoded=json_decode((string)file_get_contents('php://input'),true); if (is_array($decoded)) $input=$decoded; } $id=is_string($_GET['id']??null)?$_GET['id']:''; $result=null; switch($action) { case 'map': if($method!=='GET'){api_reply(false,null,['method'=>'GET required.'],405);exit;} $map=Maps::get($id);if(!$map){api_reply(false,null,['id'=>'Map version not found.'],404);exit;} $catalog=MapCatalog::schema();$metadata=['id'=>$map['id'],'field'=>$map['field'],'resolution'=>$map['resolution'],'name'=>$map['name'],'digest'=>$map['digest'],'field_metadata'=>$catalog['fields'][$map['field']]]; header('Content-Type: application/octet-stream');header('X-Map-Shape: '.$map['rows'].','.$map['cols']);header('X-Map-Metadata: '.base64_encode(json_encode($metadata)));echo $map['data'];exit; case 'maps': if($method==='GET'){$result=Maps::all((string)($_GET['resolution']??''));if(!empty($result['ok'])){$result['data']['fields']=ParameterCatalog::schema()['map_fields']??$result['data']['fields'];$result['data']['grid']=ParameterCatalog::schema()['map_grids'][$_GET['resolution']]??$result['data']['grid'];}api_reply($result['ok'],$result['data']??null,$result['errors']??null,empty($result['ok'])?422:200);exit;} if($method!=='POST'){api_reply(false,null,['method'=>'GET or POST required.'],405);exit;} if(!str_starts_with($_SERVER['CONTENT_TYPE']??'','application/octet-stream')){api_reply(false,null,['content_type'=>'Use application/octet-stream.'],415);exit;} $result=Maps::save((string)($_GET['field']??''),(string)($_GET['resolution']??''),(string)($_GET['name']??''),(string)file_get_contents('php://input'));api_reply($result['ok'],$result['data']??null,$result['errors']??null,empty($result['ok'])?422:201);exit; case 'schema': $result=ParameterCatalog::schema(); if(!$result) api_reply(false,null,['schema'=>'Parameter catalog unavailable.'],503); else api_reply(true,$result); exit; case 'experiments': if($method==='GET') api_reply(true,Experiment::all($owner)); else { $result=Experiment::save($owner,$input); api_reply($result['ok'],$result['data']??null,$result['errors']??$result['error']??null,empty($result['ok'])?422:200); } exit; case 'experiment_delete': $result=Experiment::delete($input['id']??'',$owner); api_reply($result['ok'],$result['data']??null,$result['errors']??null,empty($result['ok'])?404:200); exit; case 'jobs': if($method==='GET') api_reply(true,Job::all($owner)); else { $e=Experiment::get($input['experiment_id']??'',$owner); if(!$e) api_reply(false,null,['experiment'=>'Experiment not found.'],404); else { $result=Job::enqueue($owner,$e); api_reply($result['ok'],$result['data']??null,$result['errors']??null,empty($result['ok'])?422:200); } } exit; case 'generate': $spec=$input['spec']??null; $result=Job::enqueueKind($owner,'planet',$input['name']??'Generated planet',$spec); api_reply($result['ok'],isset($result['data'])?['job'=>$result['data']]:null,$result['errors']??$result['error']??null,empty($result['ok'])?422:200); exit; case 'cancel': $result=Job::cancel($input['id']??'',$owner); api_reply($result['ok'],$result['data']??null,$result['errors']??null,empty($result['ok'])?404:200); exit; case 'retry': $result=Job::retry($input['id']??'',$owner); api_reply($result['ok'],$result['data']??null,$result['errors']??null,empty($result['ok'])?422:200); exit; case 'log': $result=Job::log($id,$owner,$_GET['offset']??0); api_reply($result['ok'],$result['data']??null,$result['errors']??null,empty($result['ok'])?404:200); exit; case 'results': case 'series': case 'frame': case 'download': api_artifacts($action,$id,$owner); exit; default: api_reply(false,null,['route'=>'Not found.'],404); exit; } function owned_job($id,$owner){ return is_string($id)&&preg_match('/^[a-f0-9]{32}$/',$id)?Job::get($id,$owner):null; } function api_artifact_path(array $job,string $path): ?string { $root=realpath(Job::directory($job));$file=realpath($path); return $root!==false&&$file!==false&&is_file($file)&&is_readable($file)&&str_starts_with($file,$root.DIRECTORY_SEPARATOR)?$file:null; } function api_artifact_limit(): int { return max(0,(int)(lab_config()['max_result_bytes']??0)); } function api_artifact_available(array $job,string $path): bool { $file=api_artifact_path($job,$path);$size=$file===null?false:filesize($file); return $size!==false&&$size<=api_artifact_limit(); } function api_send_artifact(array $job,string $path,string $name): void { $file=api_artifact_path($job,$path); if($file===null){api_reply(false,null,['artifact'=>'Artifact not found or not allowed.'],404);return;} $stream=@fopen($file,'rb');$stat=$stream===false?false:fstat($stream);$limit=api_artifact_limit(); if($stat===false||$stat['size']>$limit){if($stream!==false)fclose($stream);api_reply(false,null,['artifact'=>'Artifact exceeds the download size limit.'],413);return;} $name=basename($name);header('Content-Type: application/octet-stream');header('Content-Disposition: attachment; filename="'.$name.'"');header('Content-Length: '.$stat['size']); $remaining=$stat['size'];while($remaining>0&&!feof($stream)){$chunk=fread($stream,min(1048576,$remaining));if($chunk===false||$chunk==='')break;echo $chunk;$remaining-=strlen($chunk);}fclose($stream); } function api_downloads(array $job,array $manifest,array $selected): array { $root=Job::directory($job);$artifacts=$root.'/artifacts';$links=[]; $add=static function(string $id,string $label,string $path)use(&$links,$job):void{if(api_artifact_available($job,$path))$links[]=['id'=>$id,'label'=>$label];}; $add('log','Run log',$root.'/worker.log'); foreach($job['spec']['maps']??[] as $field=>$version){$meta=ParameterCatalog::schema()['map_fields'][$field]??null;if(!$meta)continue;$add('input-map:'.$field,'Input map · '.$meta['label'],$root.'/inputs/maps/'.$field.'.f32');$add('native-input:'.$field,'Native input · '.$meta['label'],$root.'/inputs/native/'.$field.'.sra');} if(($job['kind']??'model')==='model'){ $add('model-config','Model configuration',$root.'/runtime/GUI.cfg'); $add('restart:plasim_restart','Model restart',$root.'/runtime/plasim_restart'); foreach(glob($root.'/runtime/MOST_REST*')?:[] as $path){$name=basename($path);if(preg_match('/^MOST_REST(?:\\.[0-9]{5})?$/',$name))$add('restart:'.$name,$name,$path);} } if(($job['kind']??'')==='planet'){ $add('terrain-recipe','Terrain recipe',$artifacts.'/planet.json'); foreach(glob($artifacts.'/planet-work/N???_surf_*.sra')?:[] as $path){$name=basename($path);if(preg_match('/^N[0-9]{3}_surf_(0129|0172)\.sra$/',$name))$add('sra:'.$name,$name,$path);} } foreach($manifest['datasets']??[] as $position=>$item){ $datasetId=$item['id']??'';if($datasetId!==($selected['id']??null))continue;$index=$selected; $add('dataset-index:'.$datasetId,'Dataset index · '.($item['label']??'Dataset'),$artifacts.'/datasets/'.$datasetId.'/index.json'); foreach($index['fields']??[] as $field){if(!is_array($field))continue;$name=$field['name']??'';if(preg_match('/^[A-Za-z][A-Za-z0-9_]*$/',$name))$add('field:'.$datasetId.':'.$name,($item['label']??'Dataset').' · '.($field['label']??$name),$artifacts.'/datasets/'.$datasetId.'/'.$name.'.f32');} if(($job['kind']??'model')==='model'){ $raw='MOST.'.sprintf('%05d',$position);$add('raw:'.$datasetId,'Raw model output · '.($item['label']??'Dataset'),$root.'/runtime/'.$raw.'.npz'); $add('raw-metadata:'.$datasetId,'Raw model metadata · '.($item['label']??'Dataset'),$root.'/runtime/'.$raw.'_metadata.npz'); } if(($job['kind']??'')==='planet')foreach(glob($artifacts.'/planet-work/surface-*.npz')?:[] as $path){$name=basename($path);if(preg_match('/^(surface-[a-f0-9]{32})\.npz$/',$name,$parts)){$add('raw:'.$datasetId.':'.$name,'Raw terrain preview',$path);$add('raw-metadata:'.$datasetId.':'.$parts[1],'Raw terrain metadata',$artifacts.'/planet-work/'.$parts[1].'_metadata.npz');}} } return $links; } function api_artifacts(string $route,$id,$owner): void { $job=owned_job($id,$owner);if(!$job){api_reply(false,null,['id'=>'Job not found.'],404);return;} $root=Job::directory($job);$base=$root.'/artifacts';$manifest=Results::manifest($job)??['datasets'=>[],'latest'=>null]; if($route==='download'){ $artifact=is_string($_GET['artifact']??null)?$_GET['artifact']:'';$path=null;$name=''; if($artifact==='log'){$path=$root.'/worker.log';$name='worker.log';} elseif(preg_match('/^(input-map|native-input):([a-z][a-z0-9_]*)$/',$artifact,$match)&&isset($job['spec']['maps'][$match[2]],ParameterCatalog::schema()['map_fields'][$match[2]])){$native=$match[1]==='native-input';$name=$match[2].($native?'.sra':'.f32');$path=$root.'/inputs/'.($native?'native':'maps').'/'.$name;} elseif($artifact==='model-config'&&($job['kind']??'model')==='model'){$path=$root.'/runtime/GUI.cfg';$name='GUI.cfg';} elseif($artifact==='terrain-recipe'&&($job['kind']??'')==='planet'){$path=$base.'/planet.json';$name='planet.json';} elseif(preg_match('/^dataset-index:([a-f0-9]{32})$/',$artifact,$match)&&Results::dataset($job,$match[1])){$path=$base.'/datasets/'.$match[1].'/index.json';$name='index.json';} elseif(preg_match('/^field:([a-f0-9]{32}):([A-Za-z][A-Za-z0-9_]*)$/',$artifact,$match)){$index=Results::dataset($job,$match[1]);foreach($index['fields']??[] as $field)if(($field['name']??null)===$match[2]){$path=$base.'/datasets/'.$match[1].'/'.$match[2].'.f32';$name=$match[2].'.f32';break;}} elseif(preg_match('/^raw:([a-f0-9]{32})(?::(surface-[a-f0-9]{32}\.npz))?$/',$artifact,$match)&&Results::dataset($job,$match[1])){ if(($job['kind']??'model')==='model'&&empty($match[2])){ foreach($manifest['datasets'] as $position=>$item)if(($item['id']??null)===$match[1]){$path=$root.'/runtime/'.sprintf('MOST.%05d.npz',$position);$name=basename($path);break;} }elseif(($job['kind']??'')==='planet'&&!empty($match[2])){$name=$match[2];$path=$base.'/planet-work/'.$name;} } elseif(preg_match('/^raw-metadata:([a-f0-9]{32})(?::(surface-[a-f0-9]{32}))?$/',$artifact,$match)&&Results::dataset($job,$match[1])){ if(($job['kind']??'model')==='model'&&empty($match[2])){ foreach($manifest['datasets'] as $position=>$item)if(($item['id']??null)===$match[1]){$name='MOST.'.sprintf('%05d',$position).'_metadata.npz';$path=$root.'/runtime/'.$name;break;} }elseif(($job['kind']??'')==='planet'&&!empty($match[2])){$name=$match[2].'_metadata.npz';$path=$base.'/planet-work/'.$name;} } elseif(preg_match('/^restart:(plasim_restart|MOST_REST(?:\.[0-9]{5})?)$/',$artifact,$match)&&($job['kind']??'model')==='model'){$name=$match[1];$path=$root.'/runtime/'.$name;} elseif(preg_match('/^sra:(N[0-9]{3}_surf_(?:0129|0172)\.sra)$/',$artifact,$match)&&($job['kind']??'')==='planet'){$name=$match[1];$path=$base.'/planet-work/'.$name;} if($path===null){api_reply(false,null,['artifact'=>'Artifact not found or not allowed.'],404);return;} api_send_artifact($job,$path,$name);return; } if($route==='results'){ $dataset=is_string($_GET['dataset']??null)?$_GET['dataset']:($manifest['latest']??'');$index=Results::dataset($job,$dataset); if(!$index){api_reply(false,null,['dataset'=>'Dataset not found.'],404);return;} api_reply(true,['index'=>$index,'dataset'=>$index,'datasets'=>$manifest['datasets'],'latest'=>$manifest['latest']??null,'downloads'=>api_downloads($job,$manifest,$index)]);return; } $dataset=is_string($_GET['dataset']??null)?$_GET['dataset']:($manifest['latest']??'');$index=Results::dataset($job,$dataset); if(!$index){api_reply(false,null,['dataset'=>'Dataset not found.'],404);return;} $field=is_string($_GET['field']??null)?$_GET['field']:'';$meta=null; foreach($index['fields']??[] as $candidate)if(($candidate['name']??null)===$field)$meta=$candidate; if(!$meta||!preg_match('/^[A-Za-z][A-Za-z0-9_]*$/',$field)){api_reply(false,null,['field'=>'Field not found.'],404);return;} $shape=$meta['shape']??[];$level=filter_var($_GET['level']??0,FILTER_VALIDATE_INT);$time=filter_var($_GET['time']??0,FILTER_VALIDATE_INT); if(!is_array($shape)||$level===false||$time===false||$level<0||$time<0||$level>=(int)($shape[1]??0)||$time>=(int)($shape[0]??0)){api_reply(false,null,['selection'=>'Selection out of range.'],416);return;} $dir=$base.'/datasets/'.$dataset; if($route==='series'){$stats=json_decode((string)@file_get_contents($dir.'/'.$field.'.json'),true)??[];api_reply(true,['time'=>$index['time']??[],'time_unit'=>$index['time_unit']??'','mean'=>array_column($stats['mean']??[],$level),'min'=>array_column($stats['min']??[],$level),'max'=>array_column($stats['max']??[],$level),'unit'=>$meta['unit']??'']);return;} if($route==='frame'){ $lat=count($index['lat']??[]);$lon=count($index['lon']??[]);$count=$lat*$lon;$bytes=$count*4;$limit=max(0,(int)(lab_config()['max_field_memory_bytes']??8388608));$file=api_artifact_path($job,$dir.'/'.$field.'.f32');$stream=$file===null?false:@fopen($file,'rb'); $offset=(($time*(int)$shape[1]+$level)*$count)*4; if(!$stream||$count<1||$bytes>$limit||fseek($stream,$offset)!==0){if($stream)fclose($stream);api_reply(false,null,['frame'=>'Frame unavailable.'],404);return;} $data=fread($stream,$bytes);fclose($stream);if($data===false||strlen($data)!==$bytes){api_reply(false,null,['frame'=>'Frame is incomplete.'],404);return;} header('Content-Type: application/octet-stream');header('X-Frame-Shape: '.$lat.','.$lon);echo $data;return; } api_reply(false,null,['route'=>'Not found.'],404); }