array( array('install_id'=>'char(32)','namespace'=>'char(32)','request_id'=>'bigint unsigned','page_no'=>'smallint unsigned','payload'=>'mediumtext','digest'=>'char(64)'), array('PRIMARY'=>array(0,array('install_id','namespace','request_id','page_no'))))); } public function installNativePageSchema(): array { try { $this->connect(1);$this->validateSchema(); if($this->rows('SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA=? AND TABLE_NAME=?',array($this->profile['database'],$this->prefix.'native_scope_pages'))){throw new OG_Privacy_Case_Refusal('native_pages_schema_exists');} $this->sql('CREATE TABLE '.$this->table('native_scope_pages').' (install_id CHAR(32) NOT NULL,namespace CHAR(32) NOT NULL,request_id BIGINT UNSIGNED NOT NULL,page_no SMALLINT UNSIGNED NOT NULL,payload MEDIUMTEXT NOT NULL,digest CHAR(64) NOT NULL,PRIMARY KEY(install_id,namespace,request_id,page_no)) ENGINE=InnoDB DEFAULT CHARSET=ascii COLLATE=ascii_bin'); $this->validateTableSpecs(self::nativePageSpecs()); return array('status'=>'native_pages_schema_installed','erasure_complete'=>false); }catch(Throwable $e){return $this->failure($e,'pages_install');}finally{$this->finish();} } private static function nativePageInput($page,$customerId,$previous,$end): void { if(!is_int($page) || $page<1 || $page>100 || !is_int($customerId) || $customerId<0 || !is_string($previous) || !preg_match('/^[a-f0-9]{64}$/D',$previous) || !is_bool($end)){throw new OG_Privacy_Case_Refusal('native_pages_invalid');} } private static function nativePageBody(array $binding,$page,$customerId,$previous,array $orders,$end): array { self::nativePageInput($page,$customerId,$previous,$end); $orders=self::scopeOrders($orders,$binding['email_hash']); if(!$end && count($orders)!==25){throw new OG_Privacy_Case_Refusal('native_pages_invalid');} if($page===100 && !$end){throw new OG_Privacy_Case_Refusal('native_pages_capacity');} return array('binding'=>$binding,'customer_id'=>$customerId,'previous_digest'=>$previous,'orders'=>$orders,'end'=>$end); } private function nativePages(array $binding): array { $args=array($binding['install'],$binding['namespace'],$binding['request_id']); $where=' WHERE install_id=? AND namespace=? AND request_id=? ORDER BY page_no LIMIT 101'; $meta=$this->rows('SELECT page_no,OCTET_LENGTH(payload) AS size FROM '.$this->table('native_scope_pages').$where,$args); if(count($meta)>100){throw new OG_Privacy_Case_Refusal('native_pages_damaged');} foreach($meta as $i=>$row){if((string)$row['page_no']!==(string)($i+1) || (int)$row['size']>32768){throw new OG_Privacy_Case_Refusal('native_pages_damaged');}} $raw=$this->rows('SELECT page_no,payload,digest FROM '.$this->table('native_scope_pages').$where,$args); if(count($raw)!==count($meta)){throw new OG_Privacy_Case_Refusal('native_pages_damaged');} $pages=array();$seen=array();$previous=str_repeat('0',64);$ended=false;$customerId=null; foreach($raw as $i=>$row){ try { if($ended || (string)$row['page_no']!==(string)($i+1) || !hash_equals(hash('sha256',$row['payload']),$row['digest'])){throw new RuntimeException('damage');} $body=json_decode($row['payload'],true,16,JSON_THROW_ON_ERROR); if(!is_array($body) || array_keys($body)!==array('binding','customer_id','previous_digest','orders','end') || $body['binding']!==$binding || !is_array($body['orders'])){throw new RuntimeException('damage');} $canonical=self::nativePageBody($binding,$i+1,$body['customer_id'],$body['previous_digest'],$body['orders'],$body['end']); if($canonical!==$body || json_encode($body,JSON_THROW_ON_ERROR)!==$row['payload'] || $body['previous_digest']!==$previous || ($customerId!==null && $customerId!==$body['customer_id'])){throw new RuntimeException('damage');} foreach($body['orders'] as $order){if(isset($seen[$order['ref']])){throw new RuntimeException('damage');}$seen[$order['ref']]=true;} $customerId=$body['customer_id'];$previous=$row['digest'];$ended=$body['end'];$pages[]=$body; }catch(Throwable $e){throw new OG_Privacy_Case_Refusal('native_pages_damaged');} } return array('pages'=>$pages,'seen_refs'=>array_map('strval',array_keys($seen)),'last_digest'=>$previous,'customer_id'=>$customerId,'observed_end'=>$ended); } /** Internal primitive: no WordPress capability/nonce authorisation. */ public function readNativePages(array $context,$requestId): array { try { $binding=$this->nativeScopeBinding($context,$requestId,self::nativePageSpecs()); $state=$this->nativePages($binding);$this->commit(); return array_merge(array('status'=>'native_pages_state','scope_complete'=>false,'erasure_complete'=>false,'dispatch_enabled'=>false),$state); }catch(Throwable $e){return $this->failure($e,'pages_read');}finally{$this->finish();} } /** Canonical, bounded identity material for post-receipt local stores. * The cursor is an offset into a sorted unique set, never caller-supplied * identity authority. The request email is included even for zero orders. */ public function readNativeIdentityHashPage(array $context,$requestId,$cursor,$limit=64): array { try { if(!is_int($cursor)||$cursor<0||!is_int($limit)||$limit<1||$limit>64){throw new OG_Privacy_Case_Refusal('native_identity_page_input');} $binding=$this->nativeScopeBinding($context,$requestId,self::nativePageSpecs()); $state=$this->nativePages($binding); if(!$state['observed_end']){throw new OG_Privacy_Case_Refusal('native_identity_page_incomplete');} $set=array($binding['email_hash']=>true); foreach($state['pages'] as $page){foreach($page['orders'] as $order){foreach($order['identity_hashes'] as $hash){$set[$hash]=true;}}} $hashes=array_keys($set);sort($hashes,SORT_STRING);$total=count($hashes); if($cursor>$total){throw new OG_Privacy_Case_Refusal('native_identity_page_input');} $items=array_slice($hashes,$cursor,$limit);$next=$cursor+count($items);$this->commit(); return array('status'=>'native_identity_hash_page','hashes'=>$items,'cursor'=>$cursor,'next_cursor'=>$next,'has_more'=>$next<$total,'total'=>$total,'erasure_complete'=>false); }catch(Throwable $e){return array_merge($this->failure($e,'identity_hash_page'),array('erasure_complete'=>false));}finally{$this->finish();} } public function saveNativePage(array $context,$requestId,$page,$customerId,$previous,array $orders,$end): array { try { self::nativePageInput($page,$customerId,$previous,$end); if(!array_is_list($orders) || count($orders)>25){throw new OG_Privacy_Case_Refusal('native_scope_capacity');} $binding=$this->nativeScopeBinding($context,$requestId,self::nativePageSpecs()); $body=self::nativePageBody($binding,$page,$customerId,$previous,$orders,$end); $payload=json_encode($body,JSON_THROW_ON_ERROR); if(strlen($payload)>32768){throw new OG_Privacy_Case_Refusal('native_pages_capacity');} $state=$this->nativePages($binding);$n=count($state['pages']);$replayed=false; if($page<=$n){ if($state['pages'][$page-1]!==$body){throw new OG_Privacy_Case_Refusal('native_pages_conflict');} $replayed=true; }else{ if($page!==$n+1 || $state['observed_end']){throw new OG_Privacy_Case_Refusal('native_pages_sequence');} if($previous!==$state['last_digest'] || ($n>0 && $customerId!==$state['customer_id'])){throw new OG_Privacy_Case_Refusal('native_pages_conflict');} foreach($orders as $order){if(in_array($order['ref'],$state['seen_refs'],true)){throw new OG_Privacy_Case_Refusal('native_pages_duplicate');}} if($this->sql('INSERT INTO '.$this->table('native_scope_pages').' (install_id,namespace,request_id,page_no,payload,digest) VALUES (?,?,?,?,?,?)',array($binding['install'],$binding['namespace'],$requestId,$page,$payload,hash('sha256',$payload)))!==1){throw new OG_Privacy_Case_Refusal('native_pages_conflict');} $check=$this->nativePages($binding); if(count($check['pages'])!==$page || $check['pages'][$page-1]!==$body){throw new OG_Privacy_Case_Refusal('native_pages_damaged');} } $this->commit();return array('status'=>'native_page_saved','page'=>$page,'candidate'=>$body,'replayed'=>$replayed,'observed_end'=>$end,'next_page'=>$end?null:$page+1,'scope_complete'=>false,'erasure_complete'=>false,'dispatch_enabled'=>false); }catch(Throwable $e){return $this->failure($e,'pages_save');}finally{$this->finish();} } }