> */ public static function parse( string $data, string $extension ): array { if ( '' === trim( $data ) || strlen( $data ) > self::MAX_BYTES ) { throw new \DomainException( __( 'Use a non-empty file no larger than 5 MiB.', 'adparity-lite' ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception data is escaped at the admin HTML/JSON boundary. } $memory_limit = wp_convert_hr_to_bytes( ini_get( 'memory_limit' ) ); if ( $memory_limit > 0 && memory_get_usage( true ) + 33554432 > $memory_limit ) { throw new \DomainException( __( 'At least 32 MiB of free PHP memory is required. Increase the PHP memory limit or use a smaller site environment.', 'adparity-lite' ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Escaped at the admin boundary. } if ( str_contains( $data, "\0" ) || 1 !== preg_match( '//u', $data ) ) { throw new \DomainException( __( 'Only UTF-8 files are supported. Export the feed as UTF-8.', 'adparity-lite' ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception data is escaped at the admin HTML/JSON boundary. } $data = preg_replace( '/^\xEF\xBB\xBF/', '', $data ); if ( 'xml' === $extension ) { return self::xml( $data ); } if ( in_array( $extension, array( 'csv', 'tsv' ), true ) ) { return self::csv( $data, $extension ); } throw new \DomainException( __( 'Choose an XML, CSV or TSV file. ZIP and remote feed URLs are not supported.', 'adparity-lite' ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception data is escaped at the admin HTML/JSON boundary. } private static function row( array $values ): array { $row = array_fill_keys( self::FIELDS, '' ); foreach ( $row as $field => $_unused ) { $value = trim( (string) ( $values[ $field ] ?? '' ) ); if ( strlen( $value ) > ( 'link' === $field ? 2048 : 200 ) ) { throw new \DomainException( __( 'A feed field exceeds its safe length limit (200 bytes; URLs 2048 bytes).', 'adparity-lite' ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception data is escaped at the admin HTML/JSON boundary. } $row[ $field ] = $value; } return $row; } private static function append( array &$rows, array $row ): void { if ( count( $rows ) >= self::MAX_ROWS ) { throw new \DomainException( __( 'This version accepts at most 300 items per audit. Split your feed into smaller files.', 'adparity-lite' ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception data is escaped at the admin HTML/JSON boundary. } $rows[] = self::row( $row ); } private static function csv( string $data, string $extension ): array { $line = strtok( $data, "\r\n" ); if ( strlen( $line ) > self::MAX_ROW_BYTES ) { throw new \DomainException( __( 'The CSV header exceeds 64 KiB. Export a file with at most 64 columns.', 'adparity-lite' ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Escaped at the admin boundary. } $delimiter = "\t"; if ( 'tsv' !== $extension ) { $best = 0; foreach ( array( ',', "\t", ';' ) as $candidate ) { $count = count( str_getcsv( $line, $candidate, '"', '' ) ); if ( $count > $best ) { $best = $count; $delimiter = $candidate; } } } $handle = fopen( 'php://temp/maxmemory:1048576', 'w+' ); if ( false === $handle ) { throw new \DomainException( __( 'Temporary storage is unavailable.', 'adparity-lite' ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception data is escaped at the admin HTML/JSON boundary. } try { fwrite( $handle, $data ); // phpcs:ignore WordPress.WP.AlternativeFunctions -- PHP memory/output stream; WP_Filesystem does not support these streams. rewind( $handle ); $headers = fgetcsv( $handle, self::MAX_ROW_BYTES + 1, $delimiter, '"', '' ); $headers = array_map( static fn( $v ) => strtolower( str_replace( array( 'g:', ' ' ), array( '', '_' ), trim( $v ) ) ), false !== $headers ? $headers : array() ); if ( count( $headers ) > 64 || count( $headers ) !== count( array_unique( $headers ) ) || array_diff( array( 'id', 'price', 'availability', 'link' ), $headers ) ) { throw new \DomainException( __( 'CSV needs unique headers including id, price, availability and link.', 'adparity-lite' ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception data is escaped at the admin HTML/JSON boundary. } $rows = array(); while ( false !== ( $values = fgetcsv( $handle, self::MAX_ROW_BYTES + 1, $delimiter, '"', '' ) ) ) { // phpcs:ignore Generic.CodeAnalysis.AssignmentInCondition.FoundInWhileCondition -- Streaming CSV read until EOF. if ( array( null ) === $values ) { continue; } if ( count( $values ) !== count( $headers ) || strlen( implode( '', $values ) ) > self::MAX_ROW_BYTES ) { throw new \DomainException( __( 'A CSV row has the wrong number of columns or exceeds 64 KiB.', 'adparity-lite' ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception data is escaped at the admin HTML/JSON boundary. } self::append( $rows, array_combine( $headers, $values ) ); } } finally { fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions -- PHP memory/output stream; WP_Filesystem does not support these streams. } if ( empty( $rows ) ) { throw new \DomainException( __( 'No product items were found.', 'adparity-lite' ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception data is escaped at the admin HTML/JSON boundary. } return $rows; } private static function xml( string $data ): array { if ( ! class_exists( '\XMLReader' ) || ! class_exists( '\DOMDocument' ) ) { throw new \DomainException( __( 'Enable the PHP XMLReader and DOM extensions to read XML feeds.', 'adparity-lite' ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception data is escaped at the admin HTML/JSON boundary. } // Reject DTDs before parsing, including internal entities. Never enable NOENT, DTDLOAD or XInclude. if ( preg_match( '/XML( $data, 'UTF-8', LIBXML_NONET | LIBXML_COMPACT ) ) { throw new \DomainException( __( 'The XML feed is malformed.', 'adparity-lite' ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception data is escaped at the admin HTML/JSON boundary. } $reader->setParserProperty( \XMLReader::LOADDTD, false ); $reader->setParserProperty( \XMLReader::SUBST_ENTITIES, false ); $reader->setParserProperty( \XMLReader::VALIDATE, false ); $root = ''; while ( $reader->read() ) { if ( $reader->depth > 32 ) { throw new \DomainException( __( 'XML nesting exceeds the safe limit.', 'adparity-lite' ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception data is escaped at the admin HTML/JSON boundary. } if ( \XMLReader::ELEMENT !== $reader->nodeType ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Native DOM/XMLReader API. continue; } if ( 0 === $reader->depth ) { $root = $reader->localName; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Native DOM/XMLReader API. } if ( ! in_array( $reader->localName, array( 'item', 'entry' ), true ) ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Native DOM/XMLReader API. continue; } $fragment = $reader->readOuterXml(); if ( strlen( $fragment ) > self::MAX_ROW_BYTES ) { throw new \DomainException( __( 'An XML item exceeds 64 KiB.', 'adparity-lite' ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception data is escaped at the admin HTML/JSON boundary. } $doc = new \DOMDocument(); if ( ! $doc->loadXML( $fragment, LIBXML_NONET | LIBXML_COMPACT ) ) { throw new \DomainException( __( 'The XML feed is malformed.', 'adparity-lite' ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception data is escaped at the admin HTML/JSON boundary. } $values = array(); foreach ( $doc->documentElement->childNodes as $child ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Native DOM/XMLReader API. if ( ! $child instanceof \DOMElement || ! in_array( $child->localName, self::FIELDS, true ) ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Native DOM/XMLReader API. continue; } if ( ! in_array( $child->namespaceURI, array( null, '', 'http://base.google.com/ns/1.0', 'http://www.w3.org/2005/Atom' ), true ) ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Native DOM/XMLReader API. continue; } $value = $child->textContent; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Native DOM/XMLReader API. if ( 'link' === $child->localName && $child->hasAttribute( 'href' ) ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Native DOM/XMLReader API. $value = $child->getAttribute( 'href' ); } if ( isset( $values[ $child->localName ] ) && 'http://base.google.com/ns/1.0' !== $child->namespaceURI ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Native DOM/XMLReader API. continue; } $values[ $child->localName ] = $value; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Native DOM/XMLReader API. } self::append( $rows, $values ); } if ( libxml_get_errors() || ! in_array( $root, array( 'rss', 'feed' ), true ) ) { throw new \DomainException( __( 'Use a valid RSS or Atom Merchant XML feed.', 'adparity-lite' ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception data is escaped at the admin HTML/JSON boundary. } } finally { $reader->close(); libxml_clear_errors(); libxml_use_internal_errors( $previous ); } if ( empty( $rows ) ) { throw new \DomainException( __( 'No product items were found.', 'adparity-lite' ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception data is escaped at the admin HTML/JSON boundary. } return $rows; } }