(int) $status['time'], 'ok' => (bool) $status['ok'], 'message' => (string) $status['message'], ); } /** * The daily cron's entry point: download when switched on, configured, * and the last success is older than INTERVAL. A failure is retried the * next day, not in a loop. */ public static function maybe_update(): void { if ( ! self::enabled() || '' === self::account_id() || ! self::has_key() ) { return; } $last = get_option( self::OPTION_LAST_SUCCESS, 0 ); if ( is_numeric( $last ) && time() - (int) $last < self::INTERVAL && '' !== GeoDatabase::path() ) { return; } self::update(); } /** * Download, verify and install the chosen edition now. The outcome is * recorded as the status line and returned. * * @return array{time: int, ok: bool, message: string} */ public static function update(): array { $account = self::account_id(); $key = self::licence_key(); if ( '' === $account || '' === $key ) { return self::record( false, __( 'Enter your MaxMind account ID and licence key first.', '404ops-activity-log' ), $account, $key ); } if ( ! function_exists( 'wp_tempnam' ) ) { require_once ABSPATH . 'wp-admin/includes/file.php'; } $archive = ''; $extracted = ''; try { $edition = self::edition(); $checksum = self::fetch( $edition, 'tar.gz.sha256', $account, $key, null ); if ( '' !== $checksum['error'] ) { return self::record( false, $checksum['error'], $account, $key ); } if ( 1 !== preg_match( '/^([a-f0-9]{64})\b/', trim( $checksum['body'] ), $sha ) ) { return self::record( false, __( 'MaxMind sent no usable checksum.', '404ops-activity-log' ), $account, $key ); } $archive = (string) wp_tempnam( 'ops-al-geoip.tar.gz' ); $fetched = self::fetch( $edition, 'tar.gz', $account, $key, $archive ); if ( '' !== $fetched['error'] ) { return self::record( false, $fetched['error'], $account, $key ); } if ( ! hash_equals( $sha[1], (string) hash_file( 'sha256', $archive ) ) ) { return self::record( false, __( 'The download did not match MaxMind\'s checksum, so it was not installed.', '404ops-activity-log' ), $account, $key ); } $extracted = (string) wp_tempnam( 'ops-al-geoip.mmdb' ); if ( ! self::extract_mmdb( $archive, $extracted ) ) { return self::record( false, __( 'The download held no .mmdb database.', '404ops-activity-log' ), $account, $key ); } $installed = GeoDatabase::install( $extracted ); if ( ! $installed['ok'] ) { return self::record( false, $installed['error'], $account, $key ); } update_option( self::OPTION_LAST_SUCCESS, time(), false ); return self::record( true, sprintf( /* translators: 1: database type, e.g. GeoLite2-City; 2: the database's build date. */ __( 'Installed %1$s built %2$s.', '404ops-activity-log' ), $installed['type'], gmdate( 'Y-m-d', $installed['built'] ) ), $account, $key ); } finally { foreach ( array( $archive, $extracted ) as $temp ) { if ( '' !== $temp && file_exists( $temp ) ) { wp_delete_file( $temp ); } } } } /** * A message with the credentials taken out of it, whatever put them in. * * @param string $message Any text. * @param string $account The account ID. * @param string $key The licence key. */ public static function scrub( string $message, string $account, string $key ): string { $secrets = array_filter( array( $key, rawurlencode( $key ), '' !== $key || '' !== $account ? base64_encode( $account . ':' . $key ) : '', // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- the Basic-auth token, so it can be removed if a transport echoes it. $account, ), static fn( string $s ): bool => strlen( $s ) >= 4 ); return substr( str_replace( $secrets, '[redacted]', $message ), 0, 300 ); } /** * The stored key, decrypted; '' for none. */ private static function licence_key(): string { $stored = get_option( self::OPTION_KEY, '' ); return is_string( $stored ) ? Secret::decrypt( $stored ) : ''; } /** * Keep the outcome, scrubbed, and return it. * * @param bool $ok Whether it worked. * @param string $message What happened. * @param string $account The account ID, for scrubbing. * @param string $key The licence key, for scrubbing. * @return array{time: int, ok: bool, message: string} */ private static function record( bool $ok, string $message, string $account, string $key ): array { $status = array( 'time' => time(), 'ok' => $ok, 'message' => self::scrub( $message, $account, $key ), ); update_option( self::OPTION_STATUS, $status, false ); return $status; } /** * One MaxMind download. With $to, the body is streamed into that file; * without, it is returned. `error` is '' on success, otherwise what went * wrong -- unscrubbed; record() scrubs. * * @param string $edition Edition id. * @param string $suffix 'tar.gz' or 'tar.gz.sha256'. * @param string $account Account ID. * @param string $key Licence key. * @param string|null $to File to stream into. * @return array{error: string, body: string} */ private static function fetch( string $edition, string $suffix, string $account, string $key, ?string $to ): array { $args = array( 'timeout' => null === $to ? 30 : 300, 'redirection' => 0, 'headers' => array( 'Authorization' => 'Basic ' . base64_encode( $account . ':' . $key ), // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- HTTP Basic authentication, RFC 7617. ), ); if ( null !== $to ) { $args['stream'] = true; $args['filename'] = $to; $args['limit_response_size'] = self::MAX_ARCHIVE_BYTES; } $response = wp_safe_remote_get( sprintf( self::ENDPOINT, rawurlencode( $edition ), $suffix ), $args ); $code = (int) wp_remote_retrieve_response_code( $response ); if ( in_array( $code, array( 301, 302, 303, 307, 308 ), true ) ) { $location = wp_remote_retrieve_header( $response, 'location' ); $location = is_array( $location ) ? (string) reset( $location ) : $location; if ( 'https' !== wp_parse_url( $location, PHP_URL_SCHEME ) ) { return self::failed( __( 'MaxMind redirected the download somewhere that is not HTTPS; it was not followed.', '404ops-activity-log' ) ); } // The credentials stay behind: the redirect target is a // presigned URL on another host and needs none. unset( $args['headers'] ); $args['redirection'] = 2; $response = wp_safe_remote_get( $location, $args ); $code = (int) wp_remote_retrieve_response_code( $response ); } if ( is_wp_error( $response ) ) { return self::failed( sprintf( /* translators: %s: the transport's own error message. */ __( 'The download failed: %s', '404ops-activity-log' ), $response->get_error_message() ) ); } if ( 401 === $code || 403 === $code ) { return self::failed( __( 'MaxMind refused the account ID and licence key.', '404ops-activity-log' ) ); } if ( 200 !== $code ) { return self::failed( sprintf( /* translators: %d: HTTP status code. */ __( 'MaxMind answered HTTP %d.', '404ops-activity-log' ), $code ) ); } return array( 'error' => '', 'body' => null === $to ? (string) wp_remote_retrieve_body( $response ) : '', ); } /** * A failed fetch. * * @param string $message What went wrong. * @return array{error: string, body: string} */ private static function failed( string $message ): array { return array( 'error' => $message, 'body' => '', ); } /** * Copy the first `.mmdb` file out of a gzipped tarball, streaming, so a * City database never has to fit in memory. Reads only the entries' own * names to find it and writes only to $to -- an entry's path is never * used as a path. * * @param string $archive The .tar.gz. * @param string $to Where the database goes. */ public static function extract_mmdb( string $archive, string $to ): bool { $in = gzopen( $archive, 'rb' ); if ( false === $in ) { return false; } $found = false; // phpcs:disable WordPress.WP.AlternativeFunctions -- streaming a local temporary file; see this method's docblock. for ( $entry = 0; $entry < self::MAX_TAR_ENTRIES; $entry++ ) { $header = gzread( $in, 512 ); if ( false === $header || 512 !== strlen( $header ) || '' === trim( $header, "\0" ) ) { break; } $name = rtrim( substr( $header, 0, 100 ), "\0" ); $size = (int) octdec( trim( substr( $header, 124, 12 ), " \0" ) ); $type = $header[156]; if ( $size < 0 || $size > GeoDatabase::MAX_BYTES ) { break; } if ( ( '0' === $type || "\0" === $type ) && str_ends_with( strtolower( $name ), '.mmdb' ) ) { $out = fopen( $to, 'wb' ); if ( false === $out ) { break; } $left = $size; while ( $left > 0 ) { $chunk = gzread( $in, min( 1048576, $left ) ); if ( false === $chunk || '' === $chunk ) { break; } fwrite( $out, $chunk ); $left -= strlen( $chunk ); } fclose( $out ); $found = 0 === $left; break; } // Skip this entry's data, padded to 512 bytes. $skip = (int) ( ceil( $size / 512 ) * 512 ); while ( $skip > 0 ) { $chunk = gzread( $in, min( 1048576, $skip ) ); if ( false === $chunk || '' === $chunk ) { break 2; } $skip -= strlen( $chunk ); } } // phpcs:enable gzclose( $in ); return $found; } }