package chatty.util;
import java.io.*;
import java.net.*;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.logging.Logger;
/**
* Simple multithreaded Webserver, that only supports the required GET request
* and delievers hardcoded files.
*
* Used for getting an access token.
*
* @author tduva
*/
public class Webserver implements Runnable {
private static final Logger LOGGER = Logger.getLogger(Webserver.class.getName());
public static final int ERROR_COULD_NOT_LISTEN_TO_PORT = 0;
private static final int SO_TIMEOUT = 10*1000;
private static int port = 61324;
private volatile boolean running = true;
private volatile ServerSocket serverSocket = null;
private final WebserverListener listener;
private int connectionCount = 0;
private final List<WebserverConnection> connections =
Collections.synchronizedList(new ArrayList<WebserverConnection>());
/**
* Main method for testing the server on it's own.
*
* @param args
*/
public static void main(String[] args) {
Webserver s = new Webserver(new WebserverListener() {
@Override
public void webserverStarted() {
System.out.println("Display a message that it's ready or whatever");
}
@Override
public void webserverStopped() {
System.out.println("Do whatever you want to do when the server was stopped");
}
@Override
public void webserverError(String error) {
System.out.println("Display a message that an error occured");
}
@Override
public void webserverTokenReceived(String token) {
System.out.println("Save token and stuff");
}
});
new Thread(s).start();
}
/**
* Construct a new webserver with the given client where data is sent. Still
* has to be started in a new Thread.
*
* @param listener Listener to return state information and data to
*/
public Webserver(WebserverListener listener) {
this.listener = listener;
}
/**
* Stops the server.
*
* If you call this after receiving the token, there should be small delay,
* so the page that removes the token from the browser history can still be
* delievered (token_received_no_redirect.html).
*/
public void stop() {
running = false;
close();
}
/**
* A debug message with "Webserver: " prepended.
*
* @param message
*/
private void debug(String message) {
message = "Webserver: "+message;
LOGGER.info(message);
}
/**
* Starts the Thread and the Webserver
*/
@Override
public void run() {
debug("Trying to start webserver at port "+port);
try {
// Since 127.0.0.1 is registered with Twitch, hardcode that instead
// of using InetAddress.getLoopbackAddress() which may also return
// "::1".
serverSocket = new ServerSocket(port, 0, InetAddress.getByName("127.0.0.1"));
} catch (IOException ex) {
debug("Could not listen to port "+port+" ("+ex.getLocalizedMessage()+")");
if (listener != null) {
listener.webserverError("Could not listen to port "+port);
}
stop();
return;
}
if (listener != null) {
listener.webserverStarted();
}
while (running) {
debug("Waiting for connections on "+serverSocket.toString());
Socket clientSocket = null;
try {
clientSocket = serverSocket.accept();
} catch (SocketException ex) {
debug("Accept interrupted: "+ex);
// After breaking out of the loop, there is a close() anyway
//close();
break;
} catch (IOException ex) {
debug("ServerSocket accept failed: "+ex);
// TODO: close() necessary?
// Why return instead of break?
//return;
break;
} catch (NullPointerException ex) {
// serverSocket apparently may be null in some circumstances
// (possibly a race condition when stopping the server), if so
// just stop
break;
}
// Connection established, work with it
newConnection(clientSocket);
}
close();
if (listener != null) {
listener.webserverStopped();
}
debug("Stopped");
}
/**
* Close the ServerSocket if necessary.
*/
private void close() {
closeConnections();
// Try to close ServerSocket
try {
if (serverSocket != null) {
serverSocket.close();
serverSocket = null;
}
} catch (IOException ex) {
debug("Error closing ServerSocket: "+ex.getLocalizedMessage());
}
}
/**
* Respond to a request by creating a new connection in a new Thread.
*
* @param clientSocket
*/
private void newConnection(Socket clientSocket) {
WebserverConnection connection =
new WebserverConnection(clientSocket, connectionCount++);
new Thread(connection).start();
connections.add(connection);
}
/**
* Close all connections.
*/
private void closeConnections() {
synchronized(connections) {
for (WebserverConnection c : connections) {
c.close();
}
connections.clear();
}
}
/**
* Handles a single connectino to a client in it's own Thread.
*/
class WebserverConnection implements Runnable {
/**
* The connection number so the connection can be recognized in
* debug messages.
*/
private final int connectionNumber;
/**
* The Socket for this connection.
*/
private final Socket connection;
/**
* Construct a new connection based on the Socket and the number of
* this connection for debug purposes. Still has to be started in a new
* Thread.
*
* @param connection
* @param connectionNumber
*/
WebserverConnection(Socket connection, int connectionNumber) {
this.connectionNumber = connectionNumber;
this.connection = connection;
}
/**
* Debug message for the connection with the connection number prepended.
*
* @param message
*/
private void debugConnection(String message) {
debug("["+connectionNumber+"] "+message);
}
/**
* Starting the connection thread here by reading in the request.
*/
@Override
public void run() {
debugConnection("Handling connection");
try (
BufferedReader input = new BufferedReader(
new InputStreamReader(connection.getInputStream()))
) {
connection.setSoTimeout(SO_TIMEOUT);
String request = input.readLine();
if (request != null) {
respond(request);
}
} catch (IOException ex) {
debugConnection("Error reading: "+ex);
}
debugConnection("Closed");
}
/**
* Respond to the given request. This opens an outgoing stream and
* sends the appropriate response.
*
* @param request
*/
private void respond(String request) {
debugConnection("Making response for "+removeToken(request));
try (OutputStream output = connection.getOutputStream()) {
String response = "";
// Check if there should be a token in there
if (StringUtil.toLowerCase(request).startsWith("get /token/")) {
String token = getToken(request);
if (token.isEmpty()) {
// No token, so show redirect page
response = makeResponse("token_redirect.html");
debugConnection("Token redirect");
} else {
// Token available, so show done page and send token
// to the client
response = makeResponse("token_received.html");
debugConnection("Token received");
if (listener != null) {
listener.webserverTokenReceived(token);
}
}
}
else if (StringUtil.toLowerCase(request).startsWith("get /tokenreceived/")) {
response = makeResponse("token_received_no_redirect.html");
}
else {
response = makeResponse(null);
}
// Send the response
output.write(response.getBytes("UTF-8"));
} catch (IOException ex) {
debugConnection("Error responding: "+ex.getLocalizedMessage());
}
}
/**
* Closes this connection prematurely.
*/
public void close() {
try {
connection.close();
} catch (IOException ex) {
debugConnection("Failed closing connection: "+ex);
}
}
/**
* Removes the token from the request string, if there is any.
*
* @param request
* @return
*/
private String removeToken(String request) {
if (getToken(request).isEmpty()) {
return request;
}
return request.replace(getToken(request), "<token>");
}
/**
* Gets the token from the request string. It is expected to be between
* "/token/" and the next "/" or the next space. Usually it should be
* like "/token/<token> "
*
* @param request
* @return The token or an empty String
*/
private String getToken(String request) {
// Check if token might be in there
int start = request.indexOf("/token/");
if (start == -1) {
// if not, return immediately
return "";
}
start += "/token/".length();
int end = request.indexOf(" ", start);
int end2 = request.indexOf("/", start);
// If a / comes earlier than a space, use that
if (end2 != -1 && end2 < end) {
end = end2;
}
if (end == -1) {
return "";
}
return request.substring(start, end).trim();
}
/**
* Make header and read the given file.
*
* @param fileName
* @return
*/
private String makeResponse(String fileName) {
if (fileName == null) {
return makeHeader(false) + "Nothing here..";
}
String content = "";
try {
// Read file to send back as content
InputStream input = getClass().getResourceAsStream(fileName);
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(input));
StringBuilder buffer = new StringBuilder();
String line;
while ((line = bufferedReader.readLine()) != null) {
buffer.append(line);
buffer.append("\n");
}
content = buffer.toString();
} catch (Exception ex) {
debug(ex.toString());
content = "<html><body>An error occured (couldn't read file)</body></html>";
}
return makeHeader(true) + content;
}
/**
* Make the http response header.
*
* @param ok Whether it was a valid request.
* @return
*/
private String makeHeader(boolean ok) {
String header = "";
if (ok) {
header += "HTTP/1.0 200 OK\n";
} else {
header += "HTTP/1.0 403 Forbidden\n";
}
header += "Server: ChattyWebserver\n";
header += "Content-Type: text/html; charset=UTF-8\n\n";
return header;
}
}
public static interface WebserverListener {
public void webserverStarted();
public void webserverStopped();
/**
* An error occured with the webserver. The webserver will already have
* stopped when this happens.
*
* @param error A message describing the error
*/
public void webserverError(String error);
/**
* The token has been received.
*
* @param token The token
*/
public void webserverTokenReceived(String token);
}
}