database = $database; $this->table = Schema::tables( $database )['operations']; $this->hash_key = $hash_key ?? ( new LocalHashKey() )->derive(); } /** * @param list $data_classes Exact disclosed classes, never values. */ public function begin( int $run_id, string $service, string $disclosure_version, array $data_classes, int $actor_id, string $payload_fingerprint ): string { if ( $run_id < 1 || $actor_id < 1 || ! in_array( $service, self::SERVICES, true ) || 1 !== preg_match( '/^[0-9]+\.[0-9]+\.[0-9]+$/D', $disclosure_version ) ) { throw new \InvalidArgumentException( 'external_operation_invalid' ); } if ( array() === $data_classes || count( $data_classes ) > 10 || count( $data_classes ) !== count( array_unique( $data_classes ) ) ) { throw new \InvalidArgumentException( 'external_data_classes_invalid' ); } foreach ( $data_classes as $data_class ) { if ( 1 !== preg_match( '/^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$/D', $data_class ) ) { throw new \InvalidArgumentException( 'external_data_classes_invalid' ); } } $data_json = wp_json_encode( array_values( $data_classes ), JSON_UNESCAPED_SLASHES ); if ( false === $data_json || strlen( $data_json ) > 2048 || strlen( $payload_fingerprint ) > 4096 ) { throw new \InvalidArgumentException( 'external_operation_payload_invalid' ); } $uuid = wp_generate_uuid4(); $inserted = $this->database->insert( $this->table, array( 'operation_uuid' => $uuid, 'run_id' => $run_id, 'service' => $service, 'disclosure_version' => $disclosure_version, 'data_classes' => $data_json, 'initiator_id' => $actor_id, 'payload_hash' => hash_hmac( 'sha256', $service . '|' . $payload_fingerprint, $this->hash_key ), 'outcome' => 'pending', 'created_at' => current_time( 'mysql', true ), ), array( '%s', '%d', '%s', '%s', '%s', '%d', '%s', '%s', '%s' ) ); if ( false === $inserted ) { throw new StorageException( 'external_operation_create_failed' ); } return $uuid; } public function complete( string $uuid, string $outcome, ?string $request_id = null ): void { if ( ! wp_is_uuid( $uuid, 4 ) || ! in_array( $outcome, self::OUTCOMES, true ) ) { throw new \InvalidArgumentException( 'external_operation_outcome_invalid' ); } if ( null !== $request_id && 1 !== preg_match( '/^SA-[0-9]{8}-[A-F0-9]{10}$/D', $request_id ) ) { throw new \InvalidArgumentException( 'external_operation_request_id_invalid' ); } $data = array( 'outcome' => $outcome, 'completed_at' => current_time( 'mysql', true ), ); $formats = array( '%s', '%s' ); if ( null !== $request_id ) { $data['request_id'] = $request_id; $formats[] = '%s'; } $updated = $this->database->update( $this->table, $data, array( 'operation_uuid' => $uuid, 'outcome' => 'pending', ), $formats, array( '%s', '%s' ) ); if ( 1 !== $updated ) { throw new StorageException( 'external_operation_complete_conflict' ); } } /** * @return list> */ public function list_for_run( int $run_id ): array { if ( $run_id < 1 ) { throw new \InvalidArgumentException( 'invalid_run_id' ); } // Table is a compiled plugin-owned name; values are prepared. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared $rows = $this->database->get_results( $this->database->prepare( "SELECT operation_uuid, service, disclosure_version, data_classes, initiator_id, payload_hash, outcome, created_at, completed_at FROM {$this->table} WHERE run_id = %d ORDER BY id ASC LIMIT 20", $run_id ), ARRAY_A ); $result = array(); foreach ( is_array( $rows ) ? $rows : array() as $row ) { $classes = json_decode( (string) $row['data_classes'], true, 16 ); if ( ! is_array( $classes ) ) { throw new StorageException( 'external_operation_contract_invalid' ); } unset( $row['data_classes'] ); $row['data_classes'] = $classes; $result[] = $row; } return $result; } }