/* LanguageTool, a natural language style checker
* Copyright (C) 2011 Daniel Naber (http://www.danielnaber.de)
*
* This library is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 2.1 of the License, or (at your option) any later version.
*
* This library is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public
* License along with this library; if not, write to the Free Software
* Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301
* USA
*/
package org.languagetool.server;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import org.apache.commons.lang3.StringUtils;
import org.jetbrains.annotations.Nullable;
import org.languagetool.tools.StringTools;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.Reader;
import java.io.UnsupportedEncodingException;
import java.net.*;
import java.util.*;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeoutException;
import static org.languagetool.server.ServerTools.print;
class LanguageToolHttpHandler implements HttpHandler {
private static final String ENCODING = "utf-8";
private final Set<String> allowedIps;
private final RequestLimiter requestLimiter;
private final LinkedBlockingQueue<Runnable> workQueue;
private final TextChecker textCheckerV2;
private final HTTPServerConfig config;
private final Set<String> ownIps;
LanguageToolHttpHandler(HTTPServerConfig config, Set<String> allowedIps, boolean internal, RequestLimiter requestLimiter, LinkedBlockingQueue<Runnable> workQueue) {
this.config = config;
this.allowedIps = allowedIps;
this.requestLimiter = requestLimiter;
this.workQueue = workQueue;
if (config.getTrustXForwardForHeader()) {
this.ownIps = getServersOwnIps();
} else {
this.ownIps = new HashSet<>();
}
this.textCheckerV2 = new V2TextChecker(config, internal);
}
/** @since 2.6 */
void shutdown() {
}
@Override
public void handle(HttpExchange httpExchange) throws IOException {
String remoteAddress = null;
Map<String, String> parameters = new HashMap<>();
try {
URI requestedUri = httpExchange.getRequestURI();
String origAddress = httpExchange.getRemoteAddress().getAddress().getHostAddress();
String realAddressOrNull = getRealRemoteAddressOrNull(httpExchange);
remoteAddress = realAddressOrNull != null ? realAddressOrNull : origAddress;
// According to the Javadoc, "Closing an exchange without consuming all of the request body is
// not an error but may make the underlying TCP connection unusable for following exchanges.",
// so we consume the request now, even before checking for request limits:
parameters = getRequestQuery(httpExchange, requestedUri);
if (requestLimiter != null && !requestLimiter.isAccessOkay(remoteAddress)) {
String text = parameters.get("text");
String textSizeMessage = text != null ? " Text size: " + text.length() + "." : "";
String errorMessage = "Error: Access from " + remoteAddress + " denied - too many requests." +
textSizeMessage +
" Allowed maximum requests: " + requestLimiter.getRequestLimit() +
" requests per " + requestLimiter.getRequestLimitPeriodInSeconds() + " seconds";
sendError(httpExchange, HttpURLConnection.HTTP_FORBIDDEN, errorMessage);
print(errorMessage + " - useragent: " + parameters.get("useragent") +
" - HTTP UserAgent: " + getHttpUserAgent(httpExchange));
return;
}
if (config.getMaxWorkQueueSize() != 0 && workQueue.size() > config.getMaxWorkQueueSize()) {
String response = "Error: There are currently too many parallel requests. Please try again later.";
print(response + " Queue size: " + workQueue.size() + ", maximum size: " + config.getMaxWorkQueueSize());
sendError(httpExchange, HttpURLConnection.HTTP_UNAVAILABLE, "Error: " + response);
return;
}
if (allowedIps == null || allowedIps.contains(origAddress)) {
if (requestedUri.getRawPath().startsWith("/v2/")) {
ApiV2 apiV2 = new ApiV2(textCheckerV2, config.getAllowOriginUrl());
String pathWithoutVersion = requestedUri.getRawPath().substring("/v2/".length());
apiV2.handleRequest(pathWithoutVersion, httpExchange, parameters);
} else if (requestedUri.getRawPath().endsWith("/Languages")) {
throw new IllegalArgumentException("You're using an old version of our API that's not supported anymore. Please see https://languagetool.org/http-api/migration.php");
} else {
if (requestedUri.getRawPath().contains("/v2/")) {
throw new IllegalArgumentException("You have '/v2/' in your path, but not at the root. Try an URL like 'http://server/v2/...' ");
}
throw new IllegalArgumentException("You're using an old version of our API that's not supported anymore. Please see https://languagetool.org/http-api/migration.php");
}
} else {
String errorMessage = "Error: Access from " + StringTools.escapeXML(origAddress) + " denied";
sendError(httpExchange, HttpURLConnection.HTTP_FORBIDDEN, errorMessage);
throw new RuntimeException(errorMessage);
}
} catch (Exception e) {
String response;
int errorCode;
if (e instanceof TextTooLongException) {
errorCode = HttpURLConnection.HTTP_ENTITY_TOO_LARGE;
response = e.getMessage();
} else if (e instanceof IllegalArgumentException) {
errorCode = HttpURLConnection.HTTP_BAD_REQUEST;
response = e.getMessage();
} else if (e.getCause() != null && e.getCause() instanceof TimeoutException) {
errorCode = HttpURLConnection.HTTP_UNAVAILABLE;
response = "Checking took longer than " + config.getMaxCheckTimeMillis()/1000 + " seconds, which is this server's limit. " +
"Please make sure you have selected the proper language or consider submitting a shorter text.";
} else {
response = "Internal Error. Please contact the site administrator.";
errorCode = HttpURLConnection.HTTP_INTERNAL_ERROR;
}
logError(remoteAddress, e, errorCode, httpExchange, parameters);
sendError(httpExchange, errorCode, "Error: " + response);
} finally {
httpExchange.close();
}
}
private void logError(String remoteAddress, Exception e, int errorCode, HttpExchange httpExchange, Map<String, String> params) {
String message = "An error has occurred, sending HTTP code " + errorCode + ". ";
message += "Access from " + remoteAddress + ", ";
message += "HTTP user agent: " + getHttpUserAgent(httpExchange) + ", ";
message += "language: " + params.get("language") + ", ";
String text = params.get("text");
if (text != null) {
message += "text length: " + text.length() + ", ";
}
message += "Stacktrace follows:";
print(message, System.err);
//noinspection CallToPrintStackTrace
e.printStackTrace();
if (config.isVerbose() && text != null) {
print("Exception was caused by this text (" + text.length() + " chars, showing up to 500):\n" +
StringUtils.abbreviate(text, 500), System.err);
}
}
private String getHttpUserAgent(HttpExchange httpExchange) {
return httpExchange.getRequestHeaders().getFirst("User-Agent");
}
// Call only if really needed, seems to be slow on some Windows machines.
private Set<String> getServersOwnIps() {
Set<String> ownIps = new HashSet<>();
try {
Enumeration e = NetworkInterface.getNetworkInterfaces();
while (e.hasMoreElements()) {
NetworkInterface netInterface = (NetworkInterface) e.nextElement();
Enumeration addresses = netInterface.getInetAddresses();
while (addresses.hasMoreElements()) {
InetAddress address = (InetAddress) addresses.nextElement();
ownIps.add(address.getHostAddress());
}
}
} catch (SocketException e1) {
throw new RuntimeException("Could not get the servers own IP addresses", e1);
}
return ownIps;
}
/**
* A (reverse) proxy can set the 'X-forwarded-for' header so we can see a user's original IP.
* But that's just a common header than can also be set by the client. So we can
* only trust the last item in the list of proxies, as it was set by our proxy,
* which we can trust.
*/
@Nullable
private String getRealRemoteAddressOrNull(HttpExchange httpExchange) {
if (config.getTrustXForwardForHeader()) {
List<String> forwardedIpsStr = httpExchange.getRequestHeaders().get("X-forwarded-for");
if (forwardedIpsStr != null) {
String allForwardedIpsStr = String.join(", ", forwardedIpsStr);
List<String> allForwardedIps = Arrays.asList(allForwardedIpsStr.split(", "));
return getLastIpIgnoringOwn(allForwardedIps);
}
}
return null;
}
private String getLastIpIgnoringOwn(List<String> forwardedIps) {
String lastIp = null;
for (String ip : forwardedIps) {
if (ownIps.contains(ip)) {
// If proxy.php runs on this machine, our own IP will be listed. We want to ignore that
// because otherwise all requests would seem to be coming from the same address (our own),
// making the request limiter a bit useless: other users could send tons of requests and
// stop the service for everybody else.
continue;
}
lastIp = ip; // use last in the list, we assume we can trust our own proxy (other items can be faked)
}
return lastIp;
}
private void sendError(HttpExchange httpExchange, int httpReturnCode, String response) throws IOException {
ServerTools.setAllowOrigin(httpExchange, config.getAllowOriginUrl());
httpExchange.sendResponseHeaders(httpReturnCode, response.getBytes(ENCODING).length);
httpExchange.getResponseBody().write(response.getBytes(ENCODING));
}
private Map<String, String> getRequestQuery(HttpExchange httpExchange, URI requestedUri) throws IOException {
String query;
if ("post".equalsIgnoreCase(httpExchange.getRequestMethod())) {
try (InputStreamReader isr = new InputStreamReader(httpExchange.getRequestBody(), ENCODING)) {
query = readerToString(isr, config.getMaxTextLength());
}
} else {
query = requestedUri.getRawQuery();
}
return parseQuery(query, httpExchange);
}
private String readerToString(Reader reader, int maxTextLength) throws IOException {
StringBuilder sb = new StringBuilder();
int readBytes = 0;
char[] chars = new char[4000];
while (readBytes >= 0) {
readBytes = reader.read(chars, 0, 4000);
if (readBytes <= 0) {
break;
}
int generousMaxLength = maxTextLength * 3 + 1000; // once character can be encoded as e.g. "%D8" plus space for other parameters
if (generousMaxLength < 0) { // might happen as it can overflow
generousMaxLength = Integer.MAX_VALUE;
}
if (sb.length() > 0 && sb.length() > generousMaxLength) {
// don't stop at maxTextLength as that's the text length, but here also other parameters
// are included (still we need this check here so we don't OOM if someone posts a few hundred MB)...
throw new TextTooLongException("Your text exceeds this server's limit of " + maxTextLength + " characters.");
}
sb.append(new String(chars, 0, readBytes));
}
return sb.toString();
}
private Map<String, String> parseQuery(String query, HttpExchange httpExchange) throws UnsupportedEncodingException {
Map<String, String> parameters = new HashMap<>();
if (query != null) {
Map<String, String> parameterMap = getParameterMap(query, httpExchange);
parameters.putAll(parameterMap);
}
return parameters;
}
private Map<String, String> getParameterMap(String query, HttpExchange httpExchange) throws UnsupportedEncodingException {
String[] pairs = query.split("[&]");
Map<String, String> parameters = new HashMap<>();
for (String pair : pairs) {
int delimPos = pair.indexOf('=');
if (delimPos != -1) {
String param = pair.substring(0, delimPos);
String key = URLDecoder.decode(param, ENCODING);
try {
String value = URLDecoder.decode(pair.substring(delimPos + 1), ENCODING);
parameters.put(key, value);
} catch (IllegalArgumentException e) {
throw new RuntimeException("Could not decode query. Query length: " + query.length() +
" Request method: " + httpExchange.getRequestMethod(), e);
}
}
}
return parameters;
}
}