FriendlyCaptchaVerifier.java

package org.drjekyll.friendlycaptcha;

import static org.drjekyll.friendlycaptcha.StringUtil.isEmpty;

import java.io.IOException;
import java.io.InputStream;
import java.net.InetSocketAddress;
import java.net.ProxySelector;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.concurrent.CompletableFuture;
import lombok.Builder;
import lombok.extern.slf4j.Slf4j;
import org.jspecify.annotations.NonNull;
import org.jspecify.annotations.Nullable;
import tools.jackson.databind.ObjectMapper;

/**
 * Verifier for Friendly Captcha solutions.
 *
 * <p>Use {@link #builder()} to configure and create an instance. The API version defaults to {@link
 * FriendlyCaptchaVersion#V1}. Set {@link
 * FriendlyCaptchaVerifierBuilder#version(FriendlyCaptchaVersion)} to {@link
 * FriendlyCaptchaVersion#V2} for the v2 API.
 *
 * <p>You will need an API key that you can create on the <a
 * href="https://friendlycaptcha.com/account">Friendly Captcha account page</a>.
 *
 * <p>Example:
 *
 * <pre>{@code
 * FriendlyCaptchaVerifier verifier = FriendlyCaptchaVerifier.builder()
 *     .apiKey("YOUR_API_KEY")
 *     .build();
 * boolean valid = verifier.verify(solution);
 * }</pre>
 */
@Slf4j
public class FriendlyCaptchaVerifier {

  private final URI effectiveEndpoint;

  private final Duration socketTimeout;

  private final boolean verbose;

  private final FriendlyCaptchaClient friendlyCaptchaClient;

  private final HttpClient httpClient;

  private final String userAgent;

  /**
   * @param apiKey An API key that proves it's you, create one on the Friendly Captcha website.
   * @param objectMapper A custom Jackson object mapper if you want to use it
   * @param verificationEndpoint The URI that points to the verification API endpoint. If not set,
   *     each version uses its own default endpoint.
   * @param connectTimeout The timeout until a connection is established. A timeout value of zero is
   *     interpreted as an infinite timeout. A {@code null} value is interpreted as undefined
   *     (system default if applicable).
   * @param socketTimeout The timeout for the entire request (connecting, sending, and receiving the
   *     response). A {@code null} value means no request timeout is applied. Default: 30 seconds
   * @param sitekey An optional sitekey that you want to make sure the puzzle was generated from.
   * @param proxyHost The hostname or IP address of an optional HTTP proxy. {@code proxyPort} must
   *     be configured as well.
   * @param proxyPort The port of an HTTP proxy. Must be > 0. {@code proxyHost} must be configured
   *     as well.
   * @param proxyUserName If the HTTP proxy requires a user name for basic authentication, it can be
   *     configured here. Proxy host, port and password must also be set.
   * @param proxyPassword The corresponding password for the basic auth proxy user. The proxy host,
   *     port and user name must be set as well.
   * @param verbose Logs INFO messages with detailed information.
   * @param version The Friendly Captcha API version to use. Defaults to API version 1 (V1)
   */
  @Builder
  public FriendlyCaptchaVerifier(
      @NonNull String apiKey,
      @Nullable ObjectMapper objectMapper,
      @Nullable URI verificationEndpoint,
      @Nullable Duration connectTimeout,
      @Nullable Duration socketTimeout,
      @Nullable String sitekey,
      @Nullable String proxyHost,
      int proxyPort,
      @Nullable String proxyUserName,
      @Nullable String proxyPassword,
      @Nullable String userAgent,
      boolean verbose,
      FriendlyCaptchaVersion version) {
    StringUtil.assertNotEmpty(apiKey, "API key must not be null or empty");
    this.socketTimeout = socketTimeout;
    this.userAgent = userAgent == null ? "FriendlyCaptchaJavaClient" : userAgent;
    this.verbose = verbose;
    VerificationResponseReader verificationResponseReader =
        new VerificationResponseReader(objectMapper == null ? new ObjectMapper() : objectMapper);
    FriendlyCaptchaParams friendlyCaptchaParams = new FriendlyCaptchaParams(apiKey, sitekey);
    if (version == FriendlyCaptchaVersion.V2) {
      this.friendlyCaptchaClient =
          new FriendlyCaptchaV2Client(friendlyCaptchaParams, verificationResponseReader);
    } else {
      this.friendlyCaptchaClient =
          new FriendlyCaptchaV1Client(friendlyCaptchaParams, verificationResponseReader);
    }
    this.effectiveEndpoint =
        verificationEndpoint == null
            ? friendlyCaptchaClient.getDefaultEndpoint()
            : requireHttpVerificationEndpointScheme(verificationEndpoint);
    HttpClient.Builder builder = HttpClient.newBuilder();
    if (connectTimeout != null) {
      builder.connectTimeout(connectTimeout);
    }
    if (!isEmpty(proxyHost) && proxyPort > 0) {
      builder.proxy(ProxySelector.of(new InetSocketAddress(proxyHost, proxyPort)));
      if (!isEmpty(proxyUserName) && !isEmpty(proxyPassword)) {
        builder.authenticator(new ProxyAuthenticator(proxyUserName, proxyPassword));
      }
    }
    this.httpClient = builder.build();
  }

  /**
   * Verifies the given captcha solution against the Friendly Captcha API.
   *
   * @param solution the captcha response value submitted by the user
   * @return {@code true} if the solution is valid, {@code false} if it was rejected by the API
   * @throws IllegalArgumentException if solution or API key is null or empty
   * @throws FriendlyCaptchaException if the API returns an error (authentication failure, bad
   *     request, etc.) or the response cannot be read
   */
  public boolean verify(@NonNull String solution) {
    StringUtil.assertNotEmpty(solution, "Solution must not be null or empty");
    if (verbose) {
      log.info("Verifying friendly captcha solution using endpoint {}", effectiveEndpoint);
    }
    try {
      HttpResponse<InputStream> response =
          httpClient.send(buildHttpRequest(solution), HttpResponse.BodyHandlers.ofInputStream());
      if (verbose) {
        log.info("Received response {} with status code {}", response, response.statusCode());
      }
      return friendlyCaptchaClient.processResponse(response.statusCode(), response.body());
    } catch (InterruptedException e) {
      Thread.currentThread().interrupt();
      throw new FriendlyCaptchaException("Interrupted while checking solution", e);
    } catch (IOException e) {
      throw new FriendlyCaptchaException("Could not check solution", e);
    }
  }

  /**
   * Verifies the given captcha solution against the Friendly Captcha API asynchronously.
   *
   * <p>The returned future completes with {@code true} if the solution is valid, or {@code false}
   * if it was rejected. It completes exceptionally with a {@link
   * java.util.concurrent.CompletionException} whose cause is always a {@link
   * FriendlyCaptchaException} — network failures are wrapped in one, consistent with {@link
   * #verify(String)}.
   *
   * @param solution the captcha response value submitted by the user
   * @return a future that resolves to {@code true} if the solution is accepted, {@code false} if
   *     rejected
   * @throws IllegalArgumentException if solution is null or empty
   */
  public CompletableFuture<Boolean> verifyAsync(@NonNull String solution) {
    StringUtil.assertNotEmpty(solution, "Solution must not be null or empty");
    if (verbose) {
      log.info("Verifying friendly captcha solution using endpoint {}", effectiveEndpoint);
    }
    return httpClient
        .sendAsync(buildHttpRequest(solution), HttpResponse.BodyHandlers.ofInputStream())
        .thenApply(
            response -> {
              if (verbose) {
                log.info(
                    "Received response {} with status code {}", response, response.statusCode());
              }
              return friendlyCaptchaClient.processResponse(response.statusCode(), response.body());
            })
        .exceptionallyCompose(
            ex -> {
              Throwable cause = ex.getCause() != null ? ex.getCause() : ex;
              if (cause instanceof FriendlyCaptchaException fce) {
                return CompletableFuture.failedFuture(fce);
              }
              return CompletableFuture.failedFuture(
                  new FriendlyCaptchaException("Could not check solution", cause));
            });
  }

  private HttpRequest buildHttpRequest(String solution) {
    HttpRequest.Builder builder =
        HttpRequest.newBuilder()
            .uri(effectiveEndpoint)
            .POST(
                HttpRequest.BodyPublishers.ofString(
                    friendlyCaptchaClient.buildRequestBody(solution)))
            .header("Content-Type", "application/x-www-form-urlencoded")
            .header("Accept", "application/json")
            .header("User-Agent", userAgent);
    if (socketTimeout != null) {
      builder.timeout(socketTimeout);
    }
    friendlyCaptchaClient.addVersionSpecificHeaders(builder);
    return builder.build();
  }

  private static URI requireHttpVerificationEndpointScheme(@NonNull URI endpoint) {
    String scheme = endpoint.getScheme();
    if ("http".equalsIgnoreCase(scheme) || "https".equalsIgnoreCase(scheme)) {
      return endpoint;
    }
    throw new FriendlyCaptchaException("Invalid verification endpoint URL");
  }
}