self::MAX_LENGTH ) { return false; } return 1 === preg_match( '/\A[A-Za-z0-9._\-]+\z/', (string) $code ); } /** * Decides the result for a code row. * * Pure function, unit tested. * * @param object|null $row Code row (needs ->status), or null when not found. * @param int $previous_checks How many times the code was checked before this check. * @param int $threshold Previous checks above this number count as suspicious. * @return string One of the RESULT_* constants. */ public static function resolve_result( $row, $previous_checks, $threshold ) { if ( ! is_object( $row ) ) { return self::RESULT_INVALID; } if ( isset( $row->status ) && NovoVerify_DB::STATUS_REVOKED === $row->status ) { return self::RESULT_REVOKED; } $previous_checks = max( 0, (int) $previous_checks ); $threshold = max( 1, (int) $threshold ); if ( 0 === $previous_checks ) { return self::RESULT_AUTHENTIC; } if ( $previous_checks > $threshold ) { return self::RESULT_SUSPICIOUS; } return self::RESULT_CHECKED_BEFORE; } /** * Normalizes input using the current settings. * * @param mixed $input Raw input. * @return string */ public static function normalize_with_settings( $input ) { return self::normalize( $input, (bool) NovoVerify_Settings::get( 'case_sensitive' ), (bool) NovoVerify_Settings::get( 'ignore_separators' ) ); } /** * Hashes a value (IP address, user agent) so it is never stored in plain text. * * @param string $value Value to hash. * @return string 64 character hex hash, or empty string for empty input. */ public static function hash_value( $value ) { $value = (string) $value; if ( '' === $value ) { return ''; } return hash_hmac( 'sha256', $value, wp_salt( 'auth' ) ); } /** * Adds a new code. * * @param string $raw_code Code as entered by the brand. * @param array $args Optional product_name, product_id, batch, meta, status. * @return int|WP_Error New code ID or error. */ public static function add( $raw_code, array $args = array() ) { $code = self::normalize_with_settings( $raw_code ); if ( ! self::is_valid( $code ) ) { return self::invalid_code_error(); } if ( NovoVerify_DB::get_code_by_value( $code ) ) { return new WP_Error( 'novoverify_duplicate_code', __( 'This code already exists.', 'novoverify' ) ); } $display = trim( sanitize_text_field( (string) $raw_code ) ); $id = NovoVerify_DB::insert_code( array_merge( $args, array( 'code' => $code, 'display_code' => '' !== $display ? mb_substr( $display, 0, 100 ) : $code, ) ) ); if ( ! $id ) { return new WP_Error( 'novoverify_db_error', __( 'The code could not be saved. Please try again.', 'novoverify' ) ); } /** * Fires after a code is added. * * @param int $id Code ID. * @param string $code Normalized code. */ do_action( 'novoverify_code_added', $id, $code ); return $id; } /** * Updates an existing code. * * @param int $id Code ID. * @param string $raw_code Code as entered by the brand. * @param array $args Optional product_name, batch, status, meta, product_id. * @return true|WP_Error */ public static function update( $id, $raw_code, array $args = array() ) { $existing = NovoVerify_DB::get_code( $id ); if ( ! $existing ) { return new WP_Error( 'novoverify_not_found', __( 'The code was not found. It may have been deleted.', 'novoverify' ) ); } $code = self::normalize_with_settings( $raw_code ); if ( ! self::is_valid( $code ) ) { return self::invalid_code_error(); } $other = NovoVerify_DB::get_code_by_value( $code ); if ( $other && (int) $other->id !== (int) $existing->id ) { return new WP_Error( 'novoverify_duplicate_code', __( 'This code already exists.', 'novoverify' ) ); } $display = trim( sanitize_text_field( (string) $raw_code ) ); $data = array_merge( array_intersect_key( $args, array_flip( array( 'product_name', 'batch', 'status', 'meta', 'product_id' ) ) ), array( 'code' => $code, 'display_code' => '' !== $display ? mb_substr( $display, 0, 100 ) : $code, ) ); if ( ! NovoVerify_DB::update_code( (int) $existing->id, $data ) ) { return new WP_Error( 'novoverify_db_error', __( 'The code could not be saved. Please try again.', 'novoverify' ) ); } /** * Fires after a code is updated. * * @param int $id Code ID. * @param string $code Normalized code. */ do_action( 'novoverify_code_updated', (int) $existing->id, $code ); return true; } /** * Error returned for codes with a wrong length or characters. * * @return WP_Error */ public static function invalid_code_error() { return new WP_Error( 'novoverify_invalid_code', sprintf( /* translators: 1: minimum length, 2: maximum length. */ __( 'Codes must be %1$d to %2$d characters long and may contain only letters, numbers, hyphens, underscores and dots.', 'novoverify' ), self::MIN_LENGTH, self::MAX_LENGTH ) ); } /** * Formats a UTC MySQL date for display in the site's timezone and language. * * @param string|null $utc_datetime UTC date in Y-m-d H:i:s format. * @param string $format Optional PHP date format. Defaults to the site's date and time formats. * @return string Formatted date, or an empty string when there is no date. */ public static function format_datetime( $utc_datetime, $format = '' ) { if ( empty( $utc_datetime ) ) { return ''; } $timestamp = strtotime( $utc_datetime . ' UTC' ); if ( false === $timestamp ) { return ''; } if ( '' === $format ) { $format = get_option( 'date_format' ) . ' ' . get_option( 'time_format' ); } return (string) wp_date( $format, $timestamp ); } /** * Checks a code and records the check. * * A repeated check by the same visitor (same IP and user agent hash) inside the * "repeat window" does not increase the counter, so reloading the result page * does not turn an authentic result into a warning. * * @param mixed $input Raw code entered by the customer. * @param array $context Optional ip_hash, user_agent_hash, country. * @return array{result: string, code: object|null, previous_checks: int, first_checked_at: string|null} */ public static function check( $input, array $context = array() ) { $context = wp_parse_args( $context, array( 'ip_hash' => '', 'user_agent_hash' => '', 'country' => '', ) ); $raw = is_scalar( $input ) ? sanitize_text_field( (string) $input ) : ''; $code = self::normalize_with_settings( $raw ); $row = self::is_valid( $code ) ? NovoVerify_DB::get_code_by_value( $code ) : null; $previous_checks = 0; $repeat = false; if ( $row ) { $previous_checks = (int) $row->check_count; $repeat = $previous_checks > 0 && NovoVerify_DB::visitor_checked_recently( (int) $row->id, $context['ip_hash'], $context['user_agent_hash'], (int) NovoVerify_Settings::get( 'repeat_window' ) ); if ( $repeat ) { // The visitor's own earlier check is not counted against them. $previous_checks = max( 0, $previous_checks - 1 ); } } $result = self::resolve_result( $row, $previous_checks, (int) NovoVerify_Settings::get( 'warning_threshold' ) ); if ( $row && ! $repeat && self::RESULT_REVOKED !== $result ) { NovoVerify_DB::increment_check_count( (int) $row->id ); } NovoVerify_DB::insert_check( array( 'code_id' => $row ? (int) $row->id : 0, 'code_input' => $raw, 'result' => $result, 'ip_hash' => $context['ip_hash'], 'user_agent_hash' => $context['user_agent_hash'], 'country' => $context['country'], ) ); $first_checked_at = $row && $row->first_checked_at ? $row->first_checked_at : null; $response = array( 'result' => $result, 'code' => $row, 'previous_checks' => $previous_checks, 'first_checked_at' => $first_checked_at, ); /** * Fires after a code was checked. * * @param array $response Check response. * @param array $context Request context (hashed IP, hashed user agent, country). */ do_action( 'novoverify_code_checked', $response, $context ); return $response; } }