package org.basex.http;
import static javax.servlet.http.HttpServletResponse.*;
import static org.basex.http.HTTPText.*;
import static org.basex.util.Token.*;
import static org.basex.util.http.HttpText.*;
import java.io.*;
import java.net.*;
import java.util.*;
import javax.servlet.*;
import javax.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.out.*;
import org.basex.io.serial.*;
import org.basex.server.*;
import org.basex.server.Log.*;
import org.basex.util.*;
import org.basex.util.http.*;
/**
* Single HTTP connection.
*
* @author BaseX Team 2005-17, BSD License
* @author Christian Gruen
*/
public final class HTTPConnection implements ClientInfo {
/** Servlet request. */
public final HttpServletRequest req;
/** Servlet response. */
public final HttpServletResponse res;
/** Servlet instance. */
public final BaseXServlet servlet;
/** Current database context. */
public final Context context;
/** Request method. */
public final String method;
/** Request parameters. */
public final HTTPParams params;
/** Performance. */
private final Performance perf = new Performance();
/** Authentication method. */
private final AuthMethod auth;
/** Path, starting with a slash. */
private final String path;
/** Serialization parameters. */
private SerializerOptions serializer;
/** User name. */
private String username;
/**
* Constructor.
* @param req request
* @param res response
* @param servlet calling servlet instance
*/
HTTPConnection(final HttpServletRequest req, final HttpServletResponse res,
final BaseXServlet servlet) {
this.req = req;
this.res = res;
this.servlet = servlet;
context = new Context(HTTPContext.context(), this);
method = req.getMethod();
params = new HTTPParams(this);
// set UTF8 as default encoding (can be overwritten)
res.setCharacterEncoding(Strings.UTF8);
path = decode(normalize(req.getPathInfo()));
// authentication method
auth = servlet.auth != null ? servlet.auth : context.soptions.get(StaticOptions.AUTHMETHOD);
}
/**
* Authorizes a request. Initializes the user if it is called for the first time.
* @throws IOException I/O exception
*/
public void authenticate() throws IOException {
// choose admin user for OPTIONS requests, servlet-specific user, or global user (can be empty)
String name = method.equals(HttpMethod.OPTIONS.name()) ? UserText.ADMIN : servlet.user;
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; after that, generate servlet-specific user name
context.user(user);
username = servlet.username(this);
// generate log entry
final StringBuilder uri = new StringBuilder(req.getRequestURL());
final String qs = req.getQueryString();
if(qs != null) uri.append('?').append(qs);
context.log.write(address(), user(), LogType.REQUEST, '[' + method + "] " + uri, null);
}
/**
* Returns the content type of a request, or an empty string.
* @return content type
*/
public MediaType contentType() {
final String ct = req.getContentType();
return new MediaType(ct == null ? "" : ct);
}
/**
* Initializes the output. Sets the expected encoding and content type.
*/
public void initResponse() {
// set content type and encoding
final SerializerOptions opts = sopts();
final String enc = opts.get(SerializerOptions.ENCODING);
res.setCharacterEncoding(enc);
res.setContentType(new MediaType(mediaType(opts) + "; " + CHARSET + '=' + enc).toString());
}
/**
* Returns the URL path. The path always starts with a slash.
* @return path 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 s = path.indexOf('/', 1);
return s == -1 ? "" : path.substring(s + 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 s = path.indexOf('/', 1);
return path.substring(1, s == -1 ? path.length() : s);
}
/**
* Returns all accepted media types.
* @return accepted media types
*/
public MediaType[] accepts() {
final String accepts = req.getHeader(ACCEPT);
final ArrayList<MediaType> 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 qf = type.parameters().get("q");
final double d = qf != null ? toDouble(token(qf)) : 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.toArray(new MediaType[list.size()]);
}
/**
* Sends an error with an info message.
* @param code status code
* @param info info, sent as body
* @throws IOException I/O exception
*/
public void error(final int code, final String info) throws IOException {
status(code, null, info);
}
/**
* Sets the HTTP status code and message.
* @param code status code
* @param message status message
* @throws IOException I/O exception
*/
public void status(final int code, final String message) throws IOException {
status(code, message, null);
}
/**
* 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 type log type
* @param info info string (can be {@code null})
*/
void log(final int type, final String info) {
context.log.write(address(), user(), type, info, perf);
}
/**
* 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(location.startsWith("/")) {
final String uri = req.getRequestURI(), info = req.getPathInfo();
if(info == null) {
loc = uri + location;
} else {
loc = uri.substring(0, uri.length() - info.length()) + location;
}
}
return loc;
}
/**
* Sends a redirect.
* @param location location
* @throws IOException I/O exception
*/
public void redirect(final String location) throws IOException {
res.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 {
req.getRequestDispatcher(resolve(location)).forward(req, res);
}
@Override
public String address() {
return req.getRemoteAddr() + ':' + req.getRemotePort();
}
@Override
public String user() {
return username;
}
/**
* Decodes the specified path.
* @param path strings to be decoded
* @return argument
*/
public static String decode(final String path) {
try {
return URLDecoder.decode(path, Prop.ENCODING);
} catch(final UnsupportedEncodingException | IllegalArgumentException ex) {
return path;
}
}
/**
* 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;
}
// PRIVATE METHODS ====================================================================
/**
* Normalizes the specified path.
* @param path path, or {@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 {
final byte[] address = token(req.getRemoteAddr());
try {
final User user;
if(auth == AuthMethod.CUSTOM) {
// custom authentication
user = user(UserText.ADMIN);
} else {
// request authorization header, check authentication method
final String header = req.getHeader(AUTHORIZATION);
final String[] am = header != null ? Strings.split(header, ' ', 2) : new String[] { "" };
final AuthMethod meth = StaticOptions.AUTHMETHOD.get(am[0]);
if(auth != meth) throw new LoginException(WRONGAUTH_X, auth);
if(auth == AuthMethod.BASIC) {
final String details = am.length > 1 ? am[1] : "";
final String[] creds = Strings.split(org.basex.util.Base64.decode(details), ':', 2);
user = user(creds[0]);
if(creds.length < 2 || !user.matches(creds[1])) throw new LoginException();
} else {
final EnumMap<Request, String> map = HttpClient.digestHeaders(header);
user = user(map.get(Request.USERNAME));
final String nonce = map.get(Request.NONCE), cnonce = map.get(Request.CNONCE);
String ha1 = user.code(Algorithm.DIGEST, Code.HASH);
if(Strings.eq(map.get(Request.ALGORITHM), MD5_SESS))
ha1 = Strings.md5(ha1 + ':' + nonce + ':' + cnonce);
String h2 = method + ':' + map.get(Request.URI);
final String qop = map.get(Request.QOP);
if(Strings.eq(qop, AUTH_INT)) h2 += ':' + Strings.md5(params.body().toString());
final String ha2 = Strings.md5(h2);
final StringBuilder response = new StringBuilder(ha1).append(':').append(nonce);
if(Strings.eq(qop, AUTH, AUTH_INT)) {
response.append(':').append(map.get(Request.NC));
response.append(':').append(cnonce).append(':').append(qop);
}
response.append(':').append(ha2);
if(!Strings.md5(response.toString()).equals(map.get(Request.RESPONSE)))
throw new LoginException();
}
}
// accept and return user
context.blocker.remove(address);
return user;
} catch(final LoginException ex) {
// delay users with wrong passwords
context.blocker.delay(address);
throw ex;
}
}
/**
* Returns a user for the specified string, or an error.
* @param user user name (can be {@code null})
* @return user reference
* @throws LoginException login exception
*/
private User user(final String user) throws LoginException {
final User u = context.users.get(user);
if(u == null) throw new LoginException();
return u;
}
/**
* Sets 460 a proprietary status code and sends the exception message as info.
* @param ex job exception
* @throws IOException I/O exception
*/
public void stop(final JobException ex) throws IOException {
final int code = 460;
final String info = ex.getMessage();
log(code, info);
try {
res.resetBuffer();
res.setStatus(code);
res.setContentType(MediaType.TEXT_PLAIN.toString());
// client directive: do not cache result (HTTP 1.1, old clients)
res.setHeader(CACHE_CONTROL, "no-cache, no-store, must-revalidate");
res.setHeader(PRAGMA, "no-cache");
res.setHeader(EXPIRES, "0");
res.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 info info, sent as body (can be {@code null})
* @throws IOException I/O exception
*/
@SuppressWarnings("deprecation")
private void status(final int code, final String message, final String info) throws IOException {
log(code, message != null ? message : info != null ? info : "");
try {
res.resetBuffer();
if(code == SC_UNAUTHORIZED) {
final TokenBuilder header = new TokenBuilder(auth.toString());
header.add(' ').addExt(Request.REALM).add("=\"").add(Prop.NAME).add('"');
if(auth == AuthMethod.DIGEST) {
final String nonce = Strings.md5(Long.toString(System.nanoTime()));
header.add(",").addExt(Request.QOP).add("=\"").add(AUTH).add(',').add(AUTH_INT).add('"');
header.add(',').addExt(Request.NONCE).add("=\"").add(nonce).add('"');
}
res.setHeader(WWW_AUTHENTICATE, header.toString());
}
final int c = code < 0 || code > 999 ? 500 : code;
if(message == null) {
res.setStatus(c);
} else {
// do not allow Jetty to create a custom error html page
res.setStatus(c, message);
}
if(info != null) {
res.setContentType(MediaType.TEXT_PLAIN.toString());
try(ArrayOutput ao = new ArrayOutput()) {
ao.write(token(info));
res.getOutputStream().write(ao.normalize().finish());
}
}
} catch(final IllegalStateException | IllegalArgumentException ex) {
logError(code, message, info, ex);
}
}
/**
* Sets a status and sends an info message.
* @param code status code
* @param message status message (can be {@code null})
* @param info info, sent as body (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());
}
}