All Downloads are FREE. Search and download functionalities are using the official Maven repository.

org.basex.http.HTTPConnection Maven / Gradle / Ivy

The newest version!
package org.basex.http;

import static jakarta.servlet.http.HttpServletResponse.*;
import static org.basex.util.Token.*;
import static org.basex.util.http.HTTPText.*;

import java.io.*;
import java.util.*;
import java.util.function.*;

import jakarta.servlet.*;
import jakarta.servlet.http.*;

import org.basex.core.*;
import org.basex.core.StaticOptions.*;
import org.basex.core.jobs.*;
import org.basex.core.users.*;
import org.basex.io.serial.*;
import org.basex.query.*;
import org.basex.server.*;
import org.basex.util.*;
import org.basex.util.Base64;
import org.basex.util.http.*;
import org.basex.util.list.*;
import org.basex.util.log.*;

/**
 * Single HTTP connection.
 *
 * @author BaseX Team 2005-24, BSD License
 * @author Christian Gruen
 */
public final class HTTPConnection implements ClientInfo {
  /** Forwarding headers. */
  private static final String[] FORWARDING_HEADERS = { "X-Forwarded-For", "Proxy-Client-IP",
      "WL-Proxy-Client-IP", "HTTP_CLIENT_IP", "HTTP_X_FORWARDED_FOR" };

  /** HTTP servlet request. */
  public final HttpServletRequest request;
  /** HTTP servlet response. */
  public final HttpServletResponse response;

  /** Current database context. */
  public final Context context;
  /** Request parameters. */
  public final RequestContext requestCtx;

  /** Performance. */
  private final Performance perf = new Performance();
  /** Authentication method. */
  private final AuthMethod authMethod;
  /** Path, starting with a slash. */
  private final String path;

  /** Request method. */
  public String method;
  /** Serialization parameters. */
  private SerializerOptions serializer;

  /**
   * Constructor.
   * @param request request
   * @param response response
   * @param authMethod authentication method (can be {@code null})
   */
  HTTPConnection(final HttpServletRequest request, final HttpServletResponse response,
      final AuthMethod authMethod) {

    this.request = request;
    this.response = response;

    context = new Context(HTTPContext.get().context(), this);
    method = request.getMethod();
    requestCtx = new RequestContext(request);

    // set UTF8 as default encoding (can be overwritten)
    response.setCharacterEncoding(Strings.UTF8);
    path = normalize(request.getPathInfo());

    // authentication method (servlet-specific or global)
    this.authMethod = authMethod != null ? authMethod :
      context.soptions.get(StaticOptions.AUTHMETHOD);
  }

  /**
   * Authorizes a request. Initializes the user if it is called for the first time.
   * @param username name of default servlet user (can be {@code null})
   * @throws IOException I/O exception
   */
  void authenticate(final String username) throws IOException {
    // choose admin user for OPTIONS requests, servlet-specific user, or global user (can be empty)
    String name = method.equals(Method.OPTIONS.name()) ? UserText.ADMIN : username;
    if(name == null) name = context.soptions.get(StaticOptions.USER);

    // look for existing user. if it does not exist, try to authenticate
    User user = context.users.get(name);
    if(user == null) user = login();

    // successful authentication: assign user
    context.user(user);

    // generate log entry
    final StringBuilder uri = new StringBuilder(uri());
    final String qs = request.getQueryString();
    if(qs != null) uri.append('?').append(qs);
    context.log.write(LogType.REQUEST, '[' + method + "] " + uri, null, context);
  }

  /**
   * Returns the content type of a request as media type.
   * @return content type
   */
  public MediaType mediaType() {
    return mediaType(request);
  }

  /**
   * Initializes the output and assigns the content type.
   */
  public void initResponse() {
    final SerializerOptions sopts = sopts();
    final MediaType mt = mediaType(sopts);
    response.setContentType((mt.parameter(CHARSET) == null ?
      new MediaType(mt + ";" + CHARSET + "=" + sopts.get(SerializerOptions.ENCODING)) :
      mt).toString());
  }

  /**
   * Returns the URL path. The path always starts with a slash.
   * @return path
   */
  public String path() {
    return path;
  }

  /**
   * Returns the database path (i.e., all path entries except for the first).
   * @return database path
   */
  public String dbpath() {
    final int i = path.indexOf('/', 1);
    return i == -1 ? "" : path.substring(i + 1);
  }

  /**
   * Returns the addressed database (i.e., the first path entry).
   * @return database, or {@code null} if the root directory was specified
   */
  public String db() {
    final int i = path.indexOf('/', 1);
    return path.substring(1, i == -1 ? path.length() : i);
  }

  /**
   * Returns all accepted media types.
   * @return accepted media types
   */
  public ArrayList accepts() {
    final String accepts = request.getHeader(ACCEPT);
    final ArrayList list = new ArrayList<>();
    if(accepts == null) {
      list.add(MediaType.ALL_ALL);
    } else {
      for(final String accept : accepts.split("\\s*,\\s*")) {
        // check if quality factor was specified
        final MediaType type = new MediaType(accept);
        final String q = type.parameter("q");
        final double d = q != null ? toDouble(token(q)) : 1;
        // only accept media types with valid double values
        if(d > 0 && d <= 1) {
          final StringBuilder sb = new StringBuilder();
          final String main = type.main(), sub = type.sub();
          sb.append(main.isEmpty() ? "*" : main).append('/');
          sb.append(sub.isEmpty() ? "*" : sub).append("; q=").append(d);
          list.add(new MediaType(sb.toString()));
        }
      }
    }
    return list;
  }

  /**
   * Handles an error with an info message.
   * @param code status code
   * @param info info, will additionally be logged
   * @throws IOException I/O exception
   */
  public void error(final int code, final String info) throws IOException {
    log(code, info);
    status(code, null, info);
  }

  /**
   * Assigns serialization parameters.
   * @param opts serialization parameters
   */
  public void sopts(final SerializerOptions opts) {
    serializer = opts;
  }

  /**
   * Returns the serialization parameters.
   * @return serialization parameters
   */
  public SerializerOptions sopts() {
    if(serializer == null) serializer = new SerializerOptions();
    return serializer;
  }

  /**
   * Writes a log message.
   * @param status HTTP status code
   * @param info info string (can be {@code null})
   */
  public void log(final int status, final String info) {
    context.log.write(status, info, perf, context);
  }

  /**
   * Normalizes a redirection location. Prefixes absolute locations with the request URI.
   * @param location location
   * @return normalized representation
   */
  public String resolve(final String location) {
    String loc = location;
    if(Strings.startsWith(location, '/')) {
      final String uri = uri(), info = request.getPathInfo();
      loc = (info == null ? uri : uri.substring(0, uri.length() - info.length())) + location;
    }
    return loc;
  }

  /**
   * Returns the request URI.
   * @return request URI
   */
  public String uri() {
    // according to the documentation, the method should never return null. however.
    final String uri = request.getRequestURI();
    return uri != null ? uri : "";
  }

  /**
   * Sends a redirect.
   * @param location location
   * @throws IOException I/O exception
   */
  public void redirect(final String location) throws IOException {
    response.sendRedirect(resolve(location));
  }

  /**
   * Sends a forward.
   * @param location location
   * @throws IOException I/O exception
   * @throws ServletException servlet exception
   */
  public void forward(final String location) throws IOException, ServletException {
    request.setAttribute(HTTPText.FORWARD, requestCtx);
    request.getRequestDispatcher(resolve(location)).forward(request, response);
  }

  @Override
  public String clientAddress() {
    return getRemoteAddr() + ':' + request.getRemotePort();
  }

  @Override
  public String clientName() {
    // check for request id
    Object value = request.getAttribute(HTTPText.CLIENT_ID);

    // check for session id (DBA, global)
    if(value == null) {
      final HttpSession session = request.getSession(false);
      if(session != null) {
        final boolean dba = (path() + '/').contains('/' + HTTPText.DBA_CLIENT_ID + '/');
        value = session.getAttribute(dba ? HTTPText.DBA_CLIENT_ID : HTTPText.CLIENT_ID);
      }
    }
    return clientName(value, context);
  }

  /**
   * Sets 460 a proprietary status code and sends the exception message as info.
   * @param ex job exception
   * @throws IOException I/O exception
   */
  void stop(final JobException ex) throws IOException {
    final int code = 460;
    final String info = ex.getMessage();
    log(code, info);
    try {
      response.resetBuffer();
      response.setStatus(code);
      response.setContentType(MediaType.TEXT_PLAIN + "; " + CHARSET + '=' + Strings.UTF8);
      // client directive: do not cache result (HTTP 1.1, old clients)
      response.setHeader(CACHE_CONTROL, "no-cache, no-store, must-revalidate");
      response.setHeader(PRAGMA, "no-cache");
      response.setHeader(EXPIRES, "0");
      response.getOutputStream().write(token(info));
    } catch(final IllegalStateException e) {
      // too late (response has already been committed)
      logError(code, null, info, e);
    }
  }

  /**
   * Sets a status and sends an info message.
   * @param code status code
   * @param message status message (can be {@code null})
   * @param body message for response body (can be {@code null})
   * @throws IOException I/O exception
   */
  @SuppressWarnings("deprecation")
  public void status(final int code, final String message, final String body) throws IOException {
    try {
      response.resetBuffer();
      if(code == SC_UNAUTHORIZED && !response.containsHeader(WWW_AUTHENTICATE)) {
        final TokenBuilder header = new TokenBuilder().add(authMethod);
        header.add(' ').add(RequestAttribute.REALM).add("=\"").add(Prop.NAME).add('"');
        if(authMethod == AuthMethod.DIGEST) {
          final String nonce = Strings.md5(Long.toString(System.nanoTime()));
          header.add(",").add(RequestAttribute.QOP).add("=\"").add(AUTH).add(',').add(AUTH_INT);
          header.add('"').add(',').add(RequestAttribute.NONCE).add("=\"").add(nonce).add('"');
        }
        response.setHeader(WWW_AUTHENTICATE, header.toString());
      }

      final int c = code < 0 || code > 999 ? 500 : code;
      if(message == null) {
        response.setStatus(c);
      } else {
        // do not allow Jetty to create a custom error html page
        // control characters and non-ASCII codes will be removed (GH-1632)
        response.setStatus(c, message.replaceAll("[^\\x20-\\x7F]", "?"));
      }

      if(body != null) {
        response.setContentType(MediaType.TEXT_PLAIN + "; " + CHARSET + '=' + Strings.UTF8);
        response.getOutputStream().write(new TokenBuilder(token(body)).normalize().finish());
      }
    } catch(final IllegalStateException | IllegalArgumentException ex) {
      logError(code, message, body, ex);
    }
  }

  /**
   * Sets profiling information.
   * @param qi query info
   */
  public void timing(final QueryInfo qi) {
    final StringList list = new StringList(4);
    final BiConsumer add = (name, nano) ->
      list.add(name + ";dur=" + Performance.getTime(nano, 1));
    add.accept("parse", qi.parsing.get());
    add.accept("compile", qi.compiling.get());
    add.accept("optimize", qi.optimizing.get());
    add.accept("evaluate", qi.evaluating.get());
    add.accept("serialize", qi.serializing.get());
    response.setHeader(SERVER_TIMING, String.join(",", list.finish()));
  }

  /**
   * Returns the media type defined in the specified serialization parameters.
   * @param sopts serialization parameters
   * @return media type
   */
  public static MediaType mediaType(final SerializerOptions sopts) {
    // set content type
    final String type = sopts.get(SerializerOptions.MEDIA_TYPE);
    if(!type.isEmpty()) return new MediaType(type);

    // determine content type dependent on output method
    final SerialMethod sm = sopts.get(SerializerOptions.METHOD);
    if(sm == SerialMethod.BASEX || sm == SerialMethod.ADAPTIVE || sm == SerialMethod.XML)
      return MediaType.APPLICATION_XML;
    if(sm == SerialMethod.XHTML || sm == SerialMethod.HTML) return MediaType.TEXT_HTML;
    if(sm == SerialMethod.JSON) return MediaType.APPLICATION_JSON;
    return MediaType.TEXT_PLAIN;
  }

  /**
   * Returns the content type of a request as media type.
   * @param request servlet request
   * @return content type
   */
  public static MediaType mediaType(final HttpServletRequest request) {
    final String ct = request.getContentType();
    return ct == null ? MediaType.ALL_ALL : new MediaType(ct);
  }

  /**
   * Returns the address of the client that sent a request, or an empty string.
   * Evaluates the HTTP headers to find the original IP address.
   * @param request servlet request
   * @return remote address
   */
  public static String remoteAddress(final HttpServletRequest request) {
    for(final String header : FORWARDING_HEADERS) {
      final String value = request.getHeader(header);
      // header found: test last (most reliable) part first
      if(value != null && !value.isEmpty()) {
        String ip = null;
        final String[] entries = value.split("\\s*,\\s*");
        for(int e = entries.length; --e >= 0 && entries[e].matches("^\\[?[:.\\d]+\\]?$");) {
          ip = entries[e];
        }
        if(ip != null) return ip;
      }
    }
    return request.getRemoteAddr();
  }

  // PRIVATE METHODS ==============================================================================

  /**
   * Normalizes the specified path.
   * @param path path (can be {@code null})
   * @return normalized path
   */
  private static String normalize(final String path) {
    final TokenBuilder tmp = new TokenBuilder();
    if(path != null) {
      final TokenBuilder tb = new TokenBuilder();
      final int pl = path.length();
      for(int p = 0; p < pl; p++) {
        final char ch = path.charAt(p);
        if(ch == '/') {
          if(tb.isEmpty()) continue;
          tmp.add('/').add(tb.toArray());
          tb.reset();
        } else {
          tb.add(ch);
        }
      }
      if(!tb.isEmpty()) tmp.add('/').add(tb.finish());
    }
    if(tmp.isEmpty()) tmp.add('/');
    return tmp.toString();
  }

  /**
   * Authenticates the user and returns a {@link User} instance or an exception.
   * @return user
   * @throws IOException I/O exception
   */
  private User login() throws IOException {
    try {
      final User user;
      if(authMethod == AuthMethod.CUSTOM) {
        // custom authentication
        user = user(UserText.ADMIN);
      } else {
        // request authorization header, check authentication method
        final String header = request.getHeader(AUTHORIZATION);
        final String[] am = header != null ? Strings.split(header, ' ', 2) : new String[] { "" };
        final AuthMethod meth = StaticOptions.AUTHMETHOD.get(am[0]);
        if(authMethod != meth) throw new LoginException(HTTPText.WRONGAUTH_X, authMethod);

        if(authMethod == AuthMethod.BASIC) {
          final String details = am.length > 1 ? am[1] : "";
          final String[] creds = Strings.split(Base64.decode(details), ':', 2);
          user = user(creds[0]);
          if(creds.length < 2 || !user.matches(creds[1])) throw new LoginException(user.name());

        } else {
          final EnumMap auth = Client.authHeaders(header);
          user = user(auth.get(RequestAttribute.USERNAME));

          final String nonce = auth.get(RequestAttribute.NONCE);
          final String cnonce = auth.get(RequestAttribute.CNONCE);
          String ha1 = user.code(Algorithm.DIGEST, Code.HASH);
          if(Strings.eq(auth.get(RequestAttribute.ALGORITHM), MD5_SESS))
            ha1 = Strings.md5(ha1 + ':' + nonce + ':' + cnonce);

          final StringBuilder h2 = new StringBuilder().append(method).append(':').
              append(auth.get(RequestAttribute.URI));
          final String qop = auth.get(RequestAttribute.QOP);
          if(Strings.eq(qop, AUTH_INT)) {
            h2.append(':').append(Strings.md5(requestCtx.body().toString()));
          }
          final String ha2 = Strings.md5(h2.toString());

          final StringBuilder sb = new StringBuilder(ha1).append(':').append(nonce);
          if(Strings.eq(qop, AUTH, AUTH_INT)) {
            sb.append(':').append(auth.get(RequestAttribute.NC));
            sb.append(':').append(cnonce).append(':').append(qop);
          }
          sb.append(':').append(ha2);

          if(!Strings.md5(sb.toString()).equals(auth.get(RequestAttribute.RESPONSE)))
            throw new LoginException(user.name());
        }
      }

      // accept and return user
      context.blocker.remove(token(getRemoteAddr()));
      return user;

    } catch(final LoginException ex) {
      // delay users with wrong passwords
      context.blocker.delay(token(getRemoteAddr()));
      throw ex;
    }
  }

  /**
   * Returns a user for the specified string, or an error.
   * @param name username (can be {@code null})
   * @return user reference
   * @throws LoginException login exception
   */
  private User user(final String name) throws LoginException {
    final User user = context.users.get(name);
    if(user == null || !user.enabled()) throw new LoginException(name);
    return user;
  }

  /**
   * Returns the remote address. Resolves proxy forwardings.
   * @return client address
   */
  private String getRemoteAddr() {
    return remoteAddress(request);
  }

  /**
   * Sets a status and sends an info message.
   * @param code status code
   * @param message status message (can be {@code null})
   * @param info detailed information (can be {@code null})
   * @param ex exception
   */
  private void logError(final int code, final String message, final String info,
      final Exception ex) {

    final StringBuilder sb = new StringBuilder();
    sb.append("Code: ").append(code);
    if(info != null) sb.append(", Info: ").append(info);
    if(message != null) sb.append(", Message: ").append(message);
    sb.append(", Error: ").append(Util.message(ex));
    log(SC_INTERNAL_SERVER_ERROR, sb.toString());
  }
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy