package hr.fer.zemris.java.webserver;
import hr.fer.zemris.java.custom.scripting.exec.SmartScriptEngine;
import hr.fer.zemris.java.custom.scripting.parser.SmartScriptParser;
import hr.fer.zemris.java.webserver.RequestContext.RCCookie;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileReader;
import java.io.IOException;
import java.io.OutputStream;
import java.io.PushbackInputStream;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Date;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.Random;
import java.util.Scanner;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.regex.Pattern;
/**
* Main server class used for configuring a HTTP server and creating server threads and tasks.
* @author Dario Miličić
*
*/
public class SmartHttpServer {
private String address;
private int port;
private int workerThreads;
private int sessionTimeout;
private Map<String,String> mimeTypes = new HashMap<String, String>();
private Map<String, IWebWorker> workersMap = new HashMap<>();
private ServerThread serverThread;
private ExecutorService threadPool;
private Path documentRoot;
private Map<String, SessionMapEntry> sessions = new HashMap<String, SmartHttpServer.SessionMapEntry>();
private Random sessionRandom = new Random();
/**
* Main program that starts this server
* @param args needs a path to the server configuration file
*/
public static void main(String[] args) {
new SmartHttpServer(args[0]);
}
/**
* Default constructor for this server
* @param configFileName path to the server configuration file
*/
public SmartHttpServer(String configFileName) {
Properties properties = new Properties();
try {
properties.load(new FileInputStream(configFileName));
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
System.out.println("I/O error has occured!");
System.exit(-1);
}
this.address = properties.getProperty("server.address");
this.port = Integer.parseInt(properties.getProperty("server.port"));
this.workerThreads = Integer.parseInt(properties.getProperty("server.workerThreads"));
this.sessionTimeout = Integer.parseInt(properties.getProperty("session.timeout"));
this.documentRoot = Paths.get(properties.getProperty("server.documentRoot"), (new String[0]));
String mimePath = properties.getProperty("server.mimeConfig");
loadMimeTypes(mimePath);
String workersPath = properties.getProperty("server.workers");
loadWorkers(workersPath);
start();
}
/**
* Loads the worker properties file and stores all the workers from the properties files to a workers map that can be called
* to get a particular worker.
* @param workersPath path to the worker properties file
*/
private void loadWorkers(String workersPath) {
Properties properties = new Properties();
try {
properties.load(new FileInputStream(workersPath));
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
System.out.println("I/O error has occured!");
System.exit(-1);
}
Enumeration<Object>e = properties.keys();
while(e.hasMoreElements()) {
String path = (String) e.nextElement();
String fqcn = properties.getProperty(path);
try {
Class<?> referenceToClass = this.getClass().getClassLoader().loadClass(fqcn);
Object newObject = referenceToClass.newInstance();
IWebWorker iww = (IWebWorker) newObject;
workersMap.put(path, iww);
} catch (ClassNotFoundException e1) {
e1.printStackTrace();
} catch (IllegalAccessException e2) {
e2.printStackTrace();
} catch (InstantiationException e3) {
e3.printStackTrace();
}
}
return;
}
/**
* Loads the mime properties file and retrieves all the supported mime types and stores them in a mime type map.
* @param path path to the mime properties file
*/
private void loadMimeTypes(String path) {
Properties properties = new Properties();
try {
properties.load(new FileInputStream(path));
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
System.out.println("I/O error has occured!");
System.exit(-1);
}
Enumeration<Object>e = properties.keys();
while(e.hasMoreElements()) {
String s = (String) e.nextElement();
mimeTypes.put(s, properties.getProperty(s));
}
}
/**
* Starts the server with a fixed thread pool available for its tasks.
*/
protected synchronized void start() {
threadPool = Executors.newFixedThreadPool(workerThreads);
serverThread = new ServerThread();
threadPool.submit(serverThread);
}
/**
* Shuts down the thread pool and stops the server.
*/
protected synchronized void stop() {
threadPool.shutdown();
}
/**
* Server thread used by a client to connect to the specified port and submitting jobs for execution.
* @author Dario Miličić
*/
protected class ServerThread extends Thread {
/**
* Connects to a server on a specified server port and creates client workers for server to execute
*/
@Override
public void run() {
ServerSocket serverSocket = null;
try {
serverSocket = new ServerSocket(port);
} catch (IOException e) {
System.err.println("Failed to connect to port: " + port);
System.exit(-1);
}
while(true) {
Socket client = null;
try {
client = serverSocket.accept();
} catch (IOException e) {
System.err.println("Connection failed!");
System.exit(-1);
}
ClientWorker cw = new ClientWorker(client);
threadPool.submit(cw);
}
}
}
/**
* A static class used for storing a session with a client
* @author Dario Miličić
*
*/
private static class SessionMapEntry {
String sid;
long validUntil;
Map<String, String> map;
/**
* Default session constructor
* @param sid session ID which is the name of this session entry
* @param validUntil value in miliseconds that represents the time when session will expire
* @param map a map of persistent parameters used by a client
*/
public SessionMapEntry(String sid, long validUntil,
Map<String, String> map) {
super();
this.sid = sid;
this.validUntil = validUntil;
this.map = map;
}
}
/**
* A server worker class used for handling client requests
* @author Dario Miličić
*
*/
private class ClientWorker implements Runnable {
private Socket csocket;
private PushbackInputStream istream;
private OutputStream ostream;
private String version;
private String method;
private Map<String,String> params = new HashMap<String, String>();
private Map<String,String> permParams = null;
private List<RCCookie> outputCookies = new ArrayList<RequestContext.RCCookie>();
private String SID;
/**
* Default constructor
* @param csocket
*/
public ClientWorker(Socket csocket) {
super();
this.csocket = csocket;
}
/**
* Main client worker method that handles all of the supported client requests
*/
@Override
public void run() {
List<String> request = null;
int responseStatus = 200;
try {
istream = new PushbackInputStream(csocket.getInputStream());
ostream = csocket.getOutputStream();
request = readRequest();
} catch (IOException e) {
System.err.println("I/O error has occured!");
System.exit(-1);
}
if(request.size() < 1) {
responseStatus = 400;
}
String firstLine = request.get(0);
String[] extract = firstLine.split(" ");
method = extract[0];
String requestedPath = extract[1];
version = extract[2];
if( !method.equals("GET") || (!version.equals("HTTP/1.0") && !version.equals("HTTP/1.1")) ) {
responseStatus = 400;
}
synchronized (SmartHttpServer.this) {
checkSession(request);
}
String path = null;
if (requestedPath.contains("?")) {
String[] splitter = requestedPath.split("\\?");
path = splitter[0];
String paramString = splitter[1];
parseParameters(paramString);
} else {
path = requestedPath;
}
File requestedFile = documentRoot.resolve(path.substring(1)).toFile();
if(checkPath(requestedFile, documentRoot.toFile()) == false) {
responseStatus = 403;
RequestContext rc = new RequestContext(ostream, params, permParams, outputCookies);
rc.setMimeType("text/html");
rc.setStatusCode(responseStatus);
try {
csocket.close();
} catch (IOException e) {
System.err.println("I/O error has occured!");
}
return;
}
if( Pattern.matches("/ext/.*", path) ) {
String s = path.substring(path.lastIndexOf("/") + 1, path.length());
Class<?> referenceToClass;
try {
referenceToClass = this.getClass().getClassLoader().loadClass("hr.fer.zemris.java.webserver.workers."+s);
Object newObject = referenceToClass.newInstance();
IWebWorker iww = (IWebWorker) newObject;
iww.processRequest(new RequestContext(ostream, params, permParams, outputCookies));
csocket.close();
} catch (ClassNotFoundException e) {
RequestContext rc = new RequestContext(ostream, params, permParams, outputCookies);
rc.setMimeType("text/html");
rc.setStatusCode(404);
rc.setStatusText("Not found");
try {
rc.write("<html><head><title>404 error!</title></head><body>ERROR: 404; FILE NOT FOUND!</body></html>");
csocket.close();
} catch (IOException e1) {
System.err.println("I/O error has occured!");
}
} catch (InstantiationException e) {
e.printStackTrace();
} catch (IllegalAccessException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
return;
}
if( workersMap.containsKey(path) ) {
workersMap.get(path).processRequest(new RequestContext(ostream, params, permParams, outputCookies));
try {
csocket.close();
} catch (IOException e) {
e.printStackTrace();
}
return;
}
String extension = "";
if( (!requestedFile.exists() || !requestedFile.isFile() || !requestedFile.canRead()) && responseStatus == 200 ) {
responseStatus = 404;
} else {
extension = requestedFile.toString().substring(requestedFile.toString().lastIndexOf(".") + 1, requestedFile.toString().length());
}
if( extension.equals("smscr") ) {
smartScript(requestedFile.toString());
return;
}
if( requestedFile.toString().equals(documentRoot.toString()) ) {
requestedFile = documentRoot.resolve("index.html").toFile();
extension = "html";
}
String mimeType = null;
if( mimeTypes.containsKey(extension) ) {
mimeType = mimeTypes.get(extension);
} else {
mimeType = "application/octet-stream";
}
RequestContext rc = new RequestContext(ostream, params, permParams, outputCookies);
rc.setMimeType(mimeType);
rc.setStatusCode(responseStatus);
try {
FileInputStream fstream = new FileInputStream(requestedFile);
byte[] buff = new byte[1024];
while(true) {
int b = fstream.read(buff);
if(b == -1) break;
rc.write(buff);
}
csocket.close();
} catch (FileNotFoundException e) {
System.err.println("File not found!");
rc.setMimeType("text/html");
rc.setStatusCode(404);
rc.setStatusText("Not found");
try {
rc.write("<html><head><title>404 error!</title></head><body>ERROR: 404; FILE NOT FOUND!</body></html>");
csocket.close();
} catch (IOException e1) {
System.err.println("Output error!");
System.exit(-1);
}
} catch (IOException e) {
System.err.println("Output error!");
System.exit(-1);
}
}
/**
* Checks if there is a stored session for this client. If there is a stored session, its expiry date is refreshed.
* If there was no session stored for this client then a new session is created. This method is not thread-safe.
* @param request
*/
private void checkSession(List<String> request) {
String sidCandidate = null;
for(String line : request) {
//System.out.println(line);
if(!line.startsWith("Cookie:")) continue;
if(line.contains("sid")) {
sidCandidate = line.substring(line.indexOf("sid") + 5, line.indexOf("sid") + 25);
}
}
if( sidCandidate != null ) {
SessionMapEntry sessionEntry = sessions.get(sidCandidate);
if(sessionEntry != null) {
Date now = new Date();
if(sessionEntry.validUntil < now.getTime()) {
sessions.remove(sidCandidate);
sidCandidate = null;
} else {
sessionEntry.validUntil = now.getTime() + sessionTimeout*1000;
outputCookies.add(new RCCookie("sid", sessionEntry.sid, (int) (sessionTimeout), address.toString(), "/"));
}
permParams = sessionEntry.map;
} else {
Date now = new Date();
sessionEntry = new SessionMapEntry(sidCandidate, now.getTime() + sessionTimeout*1000, new ConcurrentHashMap<String, String>());
sessions.put(sidCandidate, sessionEntry);
permParams = sessionEntry.map;
outputCookies.add(new RCCookie("sid", sessionEntry.sid, (int) (sessionTimeout), address.toString(), "/"));
}
}
if( sidCandidate == null ) {
StringBuilder sb = new StringBuilder(20);
for (int i = 0; i < 20; i++) {
sb.append((char)(sessionRandom.nextInt(26) + 65));
}
SID = sb.toString();
Date now = new Date();
SessionMapEntry sessionEntry = new SessionMapEntry(SID, now.getTime() + sessionTimeout*1000, new ConcurrentHashMap<String, String>());
sessions.put(SID, sessionEntry);
outputCookies.add(new RCCookie("sid", sessionEntry.sid, (int) (sessionTimeout), address.toString(), "/"));
permParams = sessionEntry.map;
}
return;
}
/**
* Opens a given smart script file and creates a new instance of smart script engine that executes this file. Execution result is then
* written to an output stream using the RequestConext class.
* @param file smart script file to be opened and executed.
*/
private void smartScript(String file) {
String documentBody = readFromDisk(file);
new SmartScriptEngine(new SmartScriptParser(documentBody).getDocumentNode(),
new RequestContext(ostream, params, permParams, outputCookies)
).execute();
try {
csocket.close();
} catch (IOException e) {
System.err.println("I/O error has occured!");
System.exit(-1);;
}
}
/**
* Opens a given smart script file and returns its content as a string to be parsed. Called by smartScript method.
* @param string file path to the smart script file
* @return returns the content of a smart script file as a string
*/
private String readFromDisk(String string) {
BufferedReader reader = null;
try {
reader = new BufferedReader(new FileReader(string));
} catch (FileNotFoundException e) {
System.err.println("File not found!");
System.exit(-1);
}
char[] cbuf = new char[1024];
StringBuilder sb = new StringBuilder();
while(true) {
int end = 0;
try {
end = reader.read(cbuf);
} catch (IOException e) {
System.err.println("I/O error has occured!");
System.exit(-1);;
}
if(end == -1) break;
sb.append(cbuf);
}
return sb.toString();
}
/**
* Checks if the path provided by the client is under the allowed server directory.
* @param child file requested by the client
* @param parent server main directory
* @return returns true if the file provided by the client is allowed, otherwise returns false
*/
private boolean checkPath(File child, File parent) {
try {
return child.getCanonicalPath().startsWith(parent.getCanonicalPath());
} catch (IOException e) {
System.err.println("I/O error has occured!");
System.exit(-1);
}
return false;
}
/**
* Parses the parameters given in the URL and puts them in a parameters map for this client
* @param s parameters string to be parsed
*/
private void parseParameters(String s) {
String[] arguments = s.split("&");
for (int i = 0; i < arguments.length; i++) {
String[] tmp = arguments[i].split("=");
params.put(tmp[0], tmp[1]);
}
}
/**
* Reads the request header the client provided when the address of the server was entered.
* @return returns all of the HTTP header lines
* @throws IOException
*/
private List<String> readRequest() throws IOException {
List<String> list = new ArrayList<>();
Scanner sc = new Scanner(istream);
while ( true ) {
String s = sc.nextLine();
if( s.equals("") ) break;
list.add(s);
}
return list;
}
}
}