$payload @return array{token:string,expiresAt:string} */ function sogrA6R2IssueToken(array $payload,string $privateRoot): array { $now=time();$payload['_token']=['phase'=>SOGR_A6R2_PHASE,'version'=>SOGR_A6R2_VERSION,'issuedAt'=>$now,'expiresAt'=>$now+900,'nonce'=>bin2hex(random_bytes(18))];$body=sogrA6R2B64Encode(sogrA6R2Json($payload));$sig=hash_hmac('sha256',SOGR_A6R2_TOKEN_AAD.'.'.$body,sogrA6R2SigningKey($privateRoot),true);return ['token'=>$body.'.'.sogrA6R2B64Encode($sig),'expiresAt'=>gmdate('c',$now+900)]; } /** @return array */ function sogrA6R2VerifyToken(string $token,string $privateRoot): array { $parts=explode('.',trim($token));if(count($parts)!==2)throw new ApiException('TOKEN_PARTS_INVALID','A6-R2 token nema očekivanu strukturu.',400);[$body,$sigPart]=$parts;$actual=sogrA6R2B64Decode($sigPart);$expected=hash_hmac('sha256',SOGR_A6R2_TOKEN_AAD.'.'.$body,sogrA6R2SigningKey($privateRoot),true);if(!hash_equals($expected,$actual))throw new ApiException('TOKEN_AUTH_FAILED','A6-R2 token nije autentičan.',400);$payload=json_decode(sogrA6R2B64Decode($body),true,512,JSON_THROW_ON_ERROR);if(!is_array($payload)||!is_array($payload['_token']??null))throw new ApiException('TOKEN_PAYLOAD_INVALID','A6-R2 token nema očekivanu strukturu.',400);$meta=$payload['_token'];$now=time();if(($meta['phase']??'')!==SOGR_A6R2_PHASE||($meta['version']??'')!==SOGR_A6R2_VERSION)throw new ApiException('TOKEN_VERSION_MISMATCH','A6-R2 token pripada drugoj fazi.',409);if((int)($meta['issuedAt']??0)>$now+60||(int)($meta['expiresAt']??0)<$now)throw new ApiException('TOKEN_EXPIRED','A6-R2 token je istekao.',409);unset($payload['_token']);return $payload; } /** @return array */ function sogrA6R2FileGate(string $documentRoot,string $privateRoot): array { $paths=[ 'productionIndex'=>[$documentRoot.'/api/v1/index.php',SOGR_A6R2_PRODUCTION_INDEX_SHA256], 'productionClient'=>[$documentRoot.'/evidencija.html',SOGR_A6R2_PRODUCTION_CLIENT_SHA256], 'a6Endpoint'=>[$documentRoot.'/api/v1/phase7c-a6.php',SOGR_A6R2_A6_ENDPOINT_SHA256], 'a5r1Endpoint'=>[$documentRoot.'/api/v1/phase7c-a5-r1.php',SOGR_A6R2_A5R1_ENDPOINT_SHA256], 'a6r1Endpoint'=>[$documentRoot.'/api/v1/phase7c-a6-r1.php',SOGR_A6R2_A6R1_ENDPOINT_SHA256], 'a6r1Service'=>[$privateRoot.'/src/Service/Phase7CA6R1BackendRollbackService.php',SOGR_A6R2_A6R1_SERVICE_SHA256], 'a6r2Service'=>[$privateRoot.'/src/Service/Phase7CA6R2CutoverPreflightService.php',SOGR_A6R2_SERVICE_SHA256], 'baselineClient'=>[$privateRoot.'/backups/phase7c-a6/'.SOGR_A6R2_BASELINE_ID.'/production-client.html',SOGR_A6R2_PRODUCTION_CLIENT_SHA256], 'baselineIndex'=>[$privateRoot.'/backups/phase7c-a6/'.SOGR_A6R2_BASELINE_ID.'/api-v1-index.php',SOGR_A6R2_PRODUCTION_INDEX_SHA256], ]; $checks=[];$hashes=[]; foreach($paths as $name=>[$path,$expected]){$actual=sogrA6R2Hash($path);$checks[$name.'Exact']=$actual!==''&&hash_equals($expected,$actual);$hashes[$name]=['path'=>$path,'expectedSha256'=>$expected,'actualSha256'=>$actual,'sizeBytes'=>is_file($path)?filesize($path):null];} $manifestPath=$privateRoot.'/backups/phase7c-a6/'.SOGR_A6R2_BASELINE_ID.'/cutover-baseline.json'; $manifest=null;try{$manifest=is_file($manifestPath)?json_decode((string)file_get_contents($manifestPath),true,512,JSON_THROW_ON_ERROR):null;}catch(Throwable){$manifest=null;} $checks['baselineManifestReadable']=is_array($manifest); $checks['baselineManifestIdExact']=is_array($manifest)&&(string)($manifest['baselineId']??'')===SOGR_A6R2_BASELINE_ID; $keyPath=$privateRoot.'/phase7c-a6-r2-signing.key';$checks['signingKeyPresent']=is_file($keyPath)&&strlen(trim((string)file_get_contents($keyPath)))>=64; return ['valid'=>!in_array(false,$checks,true),'checks'=>$checks,'hashes'=>$hashes,'baselineManifestPath'=>$manifestPath,'baselineManifest'=>$manifest]; } function sogrA6R2ExtractAppsScriptUrl(string $clientPath): string { $text=is_file($clientPath)?(string)file_get_contents($clientPath):'';if(preg_match('~const\s+SCRIPT_URL\s*=\s*[\'"]([^\'"]+)[\'"]~',$text,$m)!==1)return '';return trim((string)$m[1]); } /** @return array */ function sogrA6R2AppsScriptHealth(string $url): array { $started=microtime(true);$body='';$http=0;$error=''; if($url==='')return ['reachable'=>false,'ok'=>false,'url'=>'','httpStatus'=>0,'error'=>'Apps Script URL nije pronađen u produkcionom klijentu.','durationMs'=>0]; if(function_exists('curl_init')){$ch=curl_init($url);curl_setopt_array($ch,[CURLOPT_RETURNTRANSFER=>true,CURLOPT_FOLLOWLOCATION=>true,CURLOPT_CONNECTTIMEOUT=>8,CURLOPT_TIMEOUT=>15,CURLOPT_HTTPHEADER=>['Accept: application/json'],CURLOPT_USERAGENT=>'SOGR-A6-R2-Preflight/1.0']);$result=curl_exec($ch);if(is_string($result))$body=$result;else$error=(string)curl_error($ch);$http=(int)curl_getinfo($ch,CURLINFO_RESPONSE_CODE);curl_close($ch);} else{$context=stream_context_create(['http'=>['method'=>'GET','timeout'=>15,'ignore_errors'=>true,'header'=>"Accept: application/json\r\nUser-Agent: SOGR-A6-R2-Preflight/1.0\r\n"]]);$result=@file_get_contents($url,false,$context);if(is_string($result))$body=$result;else$error='HTTP čitanje nije uspelo.';foreach($http_response_header??[] as $line)if(preg_match('~^HTTP/\S+\s+(\d+)~',$line,$m)===1)$http=(int)$m[1];} $json=null;try{$json=$body!==''?json_decode($body,true,64,JSON_THROW_ON_ERROR):null;}catch(Throwable $e){$error=$error!==''?$error:'Apps Script nije vratio JSON: '.$e->getMessage();} $reachable=$http>=200&&$http<400&&is_array($json);$ok=$reachable&&($json['ok']??false)===true; return ['reachable'=>$reachable,'ok'=>$ok,'url'=>$url,'httpStatus'=>$http,'response'=>is_array($json)?$json:null,'error'=>$error,'durationMs'=>(int)round((microtime(true)-$started)*1000),'writesPerformed'=>false]; } $router->post('login',static fn(Request $request):array=>(new AuthService())->login($request)); $router->post('logout',static fn(Request $request):array=>(new AuthService())->logout($request)); $router->get('me',static fn(Request $request):array=>(new AuthService())->me($request)); $router->post('me',static fn(Request $request):array=>(new AuthService())->me($request)); $requireOwner=static function(Request $request)use($authService):array{$session=$authService->requireUser($request);if(($session['user']['permissions']['canManageBackend']??false)!==true)throw new ApiException('FORBIDDEN','Samo vlasnik sistema može pokrenuti A6-R2.',403);return $session;}; $collect=static function()use($documentRoot,$privateRoot,$service):array{$fileGate=sogrA6R2FileGate($documentRoot,$privateRoot);$databaseStatus=$service->status();$url=sogrA6R2ExtractAppsScriptUrl($documentRoot.'/evidencija.html');$apps=sogrA6R2AppsScriptHealth($url);$valid=($fileGate['valid']??false)===true&&($databaseStatus['valid']??false)===true&&($apps['reachable']??false)===true&&($apps['ok']??false)===true;return ['valid'=>$valid,'fileGate'=>$fileGate,'databaseStatus'=>$databaseStatus,'appsScriptHealth'=>$apps];}; $health=static function(Request $request)use($collect):array{$state=$collect();return ['application'=>'Seoski odbor Gornje Rapče','service'=>'SOGR A6-R2 Final Cutover Preflight API','phase'=>SOGR_A6R2_PHASE,'version'=>SOGR_A6R2_VERSION,'mode'=>Phase7CA6R2CutoverPreflightService::MODE,'status'=>$state['valid']?'healthy':'blocked','ready'=>$state['valid'],'productionWriteRoutesRegistered'=>[],'productionClientChanged'=>false,'mysqlBusinessWrites'=>0,'googleSheetsWrites'=>0]+$state;}; $router->get('healthCheck',$health);$router->post('healthCheck',$health); $statusHandler=static function(Request $request)use($requireOwner,$collect):array{$requireOwner($request);$state=$collect();return ['phase'=>SOGR_A6R2_PHASE,'version'=>SOGR_A6R2_VERSION,'mode'=>Phase7CA6R2CutoverPreflightService::MODE,'valid'=>$state['valid'],'ready'=>$state['valid'],'writesPerformed'=>false,'productionApplicationChanged'=>false,'productionBusinessTablesWritten'=>false,'mysqlBusinessWrites'=>0,'googleSheetsCalls'=>1,'googleSheetsWrites'=>0,'fileGate'=>$state['fileGate'],'databaseStatus'=>$state['databaseStatus'],'appsScriptHealth'=>$state['appsScriptHealth'],'nextStep'=>$state['valid']?'PREVIEW_A6_R2_ACTIVATION_MANIFEST':'STOP_AND_REVIEW_A6_R2_GATE','checkedAtUtc'=>gmdate('c')];}; $router->get('phase7cA6R2Status',$statusHandler);$router->post('phase7cA6R2Status',$statusHandler); $previewHandler=static function(Request $request)use($requireOwner,$collect,$privateRoot):array{$session=$requireOwner($request);if($request->input('dryRun',true)!==true)throw new ApiException('DRY_RUN_REQUIRED','A6-R2 preview zahteva dryRun=true.',400);$state=$collect();if(($state['valid']??false)!==true)throw new ApiException('A6_R2_GATE_NOT_READY','A6-R2 kapija nije spremna.',409,$state);$userId=(int)($session['user']['id']??0);$plan=['phase'=>SOGR_A6R2_PHASE,'version'=>SOGR_A6R2_VERSION,'userId'=>$userId,'stateFingerprint'=>(string)$state['databaseStatus']['stateFingerprint'],'fileFingerprint'=>hash('sha256',sogrA6R2Json($state['fileGate']['hashes']??[])),'appsScriptUrl'=>(string)($state['appsScriptHealth']['url']??''),'confirmationRequired'=>SOGR_A6R2_CONFIRMATION];$token=sogrA6R2IssueToken($plan,$privateRoot);return ['phase'=>SOGR_A6R2_PHASE,'version'=>SOGR_A6R2_VERSION,'mode'=>'FINAL_CUTOVER_PREFLIGHT_PREVIEW','valid'=>true,'writesPerformed'=>false,'publicFilesChanged'=>false,'productionApplicationChanged'=>false,'productionBusinessTablesWritten'=>false,'mysqlBusinessWrites'=>0,'googleSheetsCalls'=>1,'googleSheetsWrites'=>0,'fileGate'=>$state['fileGate'],'databaseStatus'=>$state['databaseStatus'],'appsScriptHealth'=>$state['appsScriptHealth'],'confirmationRequired'=>SOGR_A6R2_CONFIRMATION,'manifestToken'=>$token['token'],'manifestTokenExpiresAt'=>$token['expiresAt'],'nextStep'=>'REVIEW_AND_CONFIRM_'.SOGR_A6R2_CONFIRMATION,'checkedAtUtc'=>gmdate('c')];}; $router->post('phase7cA6R2Preview',$previewHandler); $prepareHandler=static function(Request $request)use($requireOwner,$privateRoot,$documentRoot,$collect,$service):array{$session=$requireOwner($request);$plan=sogrA6R2VerifyToken((string)$request->input('manifestToken',''),$privateRoot);$confirmation=trim((string)$request->input('confirmation',''));if($confirmation!==SOGR_A6R2_CONFIRMATION||($plan['confirmationRequired']??'')!==SOGR_A6R2_CONFIRMATION)throw new ApiException('CONFIRMATION_INVALID','A6-R2 potvrda nije tačna.',400);$userId=(int)($session['user']['id']??0);if($userId!==(int)($plan['userId']??0))throw new ApiException('TOKEN_USER_MISMATCH','A6-R2 token pripada drugom korisniku.',403);$state=$collect();if(($state['valid']??false)!==true)throw new ApiException('A6_R2_GATE_CHANGED','A6-R2 kapija se promenila od preview-a.',409,$state);if(!hash_equals((string)($plan['stateFingerprint']??''),(string)$state['databaseStatus']['stateFingerprint']))throw new ApiException('STATE_CHANGED','Stanje baze se promenilo od preview-a.',409);$fileFingerprint=hash('sha256',sogrA6R2Json($state['fileGate']['hashes']??[]));if(!hash_equals((string)($plan['fileFingerprint']??''),$fileFingerprint))throw new ApiException('FILES_CHANGED','Zaštićeni fajlovi su se promenili od preview-a.',409);return $service->preparePrivateManifest($privateRoot,$documentRoot,$userId,(string)$plan['stateFingerprint'],$state['fileGate'],$state['appsScriptHealth']);}; $router->post('phase7cA6R2Prepare',$prepareHandler); $router->dispatch($request);