diff --git a/pom.xml b/pom.xml index 361b6e43..60f566fb 100644 --- a/pom.xml +++ b/pom.xml @@ -41,7 +41,8 @@ 1.6 1.6 - + + org.apache.maven.plugins maven-shade-plugin @@ -55,7 +56,9 @@ - org.dynmap:dynmap-api:jar:* + org.dynmap:dynmap-api:jar:* + org.eclipse.jetty:jetty-*:jar:* + javax.servlet:javax.servlet-api:jar:* @@ -107,5 +110,21 @@ jar compile + + javax.servlet + javax.servlet-api + 3.0.1 + + + org.eclipse.jetty + jetty-server + 8.0.1.v20110908 + + + org.eclipse.jetty + jetty-servlet + 8.0.1.v20110908 + + diff --git a/src/main/java/org/dynmap/DynmapPlugin.java b/src/main/java/org/dynmap/DynmapPlugin.java index 41ba81fc..f780f8b8 100644 --- a/src/main/java/org/dynmap/DynmapPlugin.java +++ b/src/main/java/org/dynmap/DynmapPlugin.java @@ -70,13 +70,20 @@ import org.dynmap.permissions.BukkitPermissions; import org.dynmap.permissions.NijikokunPermissions; import org.dynmap.permissions.OpPermissions; import org.dynmap.permissions.PermissionProvider; -import org.dynmap.web.HttpServer; -import org.dynmap.web.handlers.ClientConfigurationHandler; -import org.dynmap.web.handlers.FilesystemHandler; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.servlet.FilterHolder; +import org.eclipse.jetty.servlet.ServletContextHandler; +import org.eclipse.jetty.servlet.ServletHandler; +import org.eclipse.jetty.servlet.ServletHolder; + +import javax.servlet.*; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletResponse; public class DynmapPlugin extends JavaPlugin implements DynmapAPI { private String version; - public HttpServer webServer = null; + private Server webServer = null; + private ServletContextHandler webServerContextHandler = null; public MapManager mapManager = null; public PlayerList playerList; public ConfigurationNode configuration; @@ -123,10 +130,6 @@ public class DynmapPlugin extends JavaPlugin implements DynmapAPI { return mapManager; } - public HttpServer getWebServer() { - return webServer; - } - /* Add/Replace branches in configuration tree with contribution from a separate file */ private void mergeConfigurationBranch(ConfigurationNode cfgnode, String branch, boolean replace_existing, boolean islist) { Object srcbranch = cfgnode.getObject(branch); @@ -409,50 +412,120 @@ public class DynmapPlugin extends JavaPlugin implements DynmapAPI { } public void loadWebserver() { - InetAddress bindAddress; - { - String address = configuration.getString("webserver-bindaddress", "0.0.0.0"); - try { - bindAddress = address.equals("0.0.0.0") - ? null - : InetAddress.getByName(address); - } catch (UnknownHostException e) { - bindAddress = null; - } - } - int port = configuration.getInteger("webserver-port", 8123); + webServer = new Server(new InetSocketAddress(configuration.getString("webserver-bindaddress", "0.0.0.0"), configuration.getInteger("webserver-port", 8123))); + ServletContextHandler context = new ServletContextHandler(ServletContextHandler.SESSIONS); + context.setContextPath("/"); + webServer.setHandler(context); + webServerContextHandler = context; + boolean allow_symlinks = configuration.getBoolean("allow-symlinks", false); - boolean checkbannedips = configuration.getBoolean("check-banned-ips", true); int maxconnections = configuration.getInteger("max-sessions", 30); if(maxconnections < 2) maxconnections = 2; - /* Load customized response headers, if any */ - ConfigurationNode custhttp = configuration.getNode("http-response-headers"); - HashMap custhdrs = new HashMap(); - if(custhttp != null) { - for(String k : custhttp.keySet()) { - String v = custhttp.getString(k); - if(v != null) { - custhdrs.put(k, v); - } - } - } - HttpServer.setCustomHeaders(custhdrs); - + if(allow_symlinks) Log.verboseinfo("Web server is permitting symbolic links"); else - Log.verboseinfo("Web server is not permitting symbolic links"); - webServer = new HttpServer(bindAddress, port, checkbannedips, maxconnections, this); - webServer.handlers.put("/", new FilesystemHandler(getFile(configuration.getString("webpath", "web")), allow_symlinks)); - webServer.handlers.put("/tiles/", new FilesystemHandler(tilesDirectory, allow_symlinks)); - webServer.handlers.put("/up/configuration", new ClientConfigurationHandler(this)); + Log.verboseinfo("Web server is not permitting symbolic links"); + + org.eclipse.jetty.server.Server s = new org.eclipse.jetty.server.Server(); + ServletHandler handler = new org.eclipse.jetty.servlet.ServletHandler(); + s.setHandler(handler); + + /* Check for banned IPs */ + boolean checkbannedips = configuration.getBoolean("check-banned-ips", true); + if (checkbannedips) { + context.addFilter(new FilterHolder(new Filter() { + private HashSet banned_ips = new HashSet(); + private HashSet banned_ips_notified = new HashSet(); + private long last_loaded = 0; + private long lastmod = 0; + private static final long BANNED_RELOAD_INTERVAL = 15000; /* Every 15 seconds */ + + @Override + public void init(FilterConfig filterConfig) throws ServletException { } + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { + HttpServletResponse resp = (HttpServletResponse)response; + String ipaddr = request.getRemoteAddr(); + if (isIpBanned(ipaddr)) { + Log.info("Rejected connection by banned IP address - " + ipaddr); + resp.sendError(403); + } else { + chain.doFilter(request, response); + } + } + + private void loadBannedIPs() { + banned_ips.clear(); + banned_ips_notified.clear(); + banned_ips.addAll(getServer().getIPBans()); + } + + /* Return true if address is banned */ + public boolean isIpBanned(String ipaddr) { + long t = System.currentTimeMillis(); + if((t < last_loaded) || ((t-last_loaded) > BANNED_RELOAD_INTERVAL)) { + loadBannedIPs(); + last_loaded = t; + } + if(banned_ips.contains(ipaddr)) { + if(!banned_ips_notified.contains(ipaddr)) { + banned_ips_notified.add(ipaddr); + } + return true; + } + return false; + } + + @Override + public void destroy() { } + }), "/*", null); + } + + /* Load customized response headers, if any */ + final ConfigurationNode custhttp = configuration.getNode("http-response-headers"); + context.addFilter(new FilterHolder(new Filter() { + @Override + public void init(FilterConfig filterConfig) throws ServletException { } + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { + HttpServletResponse resp = (HttpServletResponse)response; + + if(custhttp != null) { + for(String k : custhttp.keySet()) { + String v = custhttp.getString(k); + if(v != null) { + resp.setHeader(k, v); + } + } + } + + chain.doFilter(request, response); + } + + @Override + public void destroy() { } + }), "/*", null); + + addServlet("/*", new org.dynmap.servlet.FileServlet(getFile(getWebPath()).getAbsolutePath(), allow_symlinks)); + addServlet("/tiles/*", new org.dynmap.servlet.FileServlet(tilesDirectory.getAbsolutePath(), allow_symlinks)); + addServlet("/up/configuration", new org.dynmap.servlet.ClientConfigurationServlet(this)); + } + + public void addServlet(String path, HttpServlet servlet) { + ServletHolder holder = new ServletHolder(servlet); + webServerContextHandler.getServletHandler().addServletWithMapping(holder, path); + } + public void startWebserver() { try { - webServer.startServer(); - } catch (IOException e) { - Log.severe("Failed to start WebServer on " + webServer.getAddress() + ":" + webServer.getPort() + "!"); + webServer.start(); + } catch (Exception e) { + Log.severe("Failed to start WebServer!", e); } } @@ -475,7 +548,11 @@ public class DynmapPlugin extends JavaPlugin implements DynmapAPI { } if (webServer != null) { - webServer.shutdown(); + try { + webServer.stop(); + } catch (Exception e) { + Log.severe("Failed to stop WebServer!", e); + } webServer = null; } /* Clean up all registered handlers */ diff --git a/src/main/java/org/dynmap/InternalClientUpdateComponent.java b/src/main/java/org/dynmap/InternalClientUpdateComponent.java index 86e2439a..34e723fb 100644 --- a/src/main/java/org/dynmap/InternalClientUpdateComponent.java +++ b/src/main/java/org/dynmap/InternalClientUpdateComponent.java @@ -1,8 +1,7 @@ package org.dynmap; -import org.dynmap.Event.Listener; -import org.dynmap.web.handlers.ClientUpdateHandler; -import org.dynmap.web.handlers.SendMessageHandler; +import org.dynmap.servlet.ClientUpdateServlet; +import org.dynmap.servlet.SendMessageServlet; import org.json.simple.JSONObject; import static org.dynmap.JSONUtils.*; @@ -10,12 +9,11 @@ public class InternalClientUpdateComponent extends ClientUpdateComponent { public InternalClientUpdateComponent(final DynmapPlugin plugin, final ConfigurationNode configuration) { super(plugin, configuration); - final boolean allowwebchat = configuration.getBoolean("allowwebchat", false); - final boolean hidewebchatip = configuration.getBoolean("hidewebchatip", false); - final boolean trust_client_name = configuration.getBoolean("trustclientname", false); - final boolean useplayerloginip = configuration.getBoolean("use-player-login-ip", true); - final boolean checkuserban = configuration.getBoolean("block-banned-player-chat", true); - final boolean requireplayerloginip = configuration.getBoolean("require-player-login-ip", false); + plugin.addServlet("/up/world/*", new ClientUpdateServlet(plugin)); + + final Boolean allowwebchat = configuration.getBoolean("allowwebchat", false); + final Boolean hidewebchatip = configuration.getBoolean("hidewebchatip", false); + final Boolean trust_client_name = configuration.getBoolean("trustclientname", false); final float webchatInterval = configuration.getFloat("webchat-interval", 1); final String spammessage = plugin.configuration.getString("spammessage", "You may only chat once every %interval% seconds."); @@ -26,38 +24,31 @@ public class InternalClientUpdateComponent extends ClientUpdateComponent { s(t, "webchat-interval", webchatInterval); } }); - - plugin.webServer.handlers.put("/up/", new ClientUpdateHandler(plugin)); - + if (allowwebchat) { - SendMessageHandler messageHandler = new SendMessageHandler() {{ + SendMessageServlet messageHandler = new SendMessageServlet() {{ maximumMessageInterval = (int)(webchatInterval * 1000); spamMessage = "\""+spammessage+"\""; hideip = hidewebchatip; - this.plug_in = plugin; this.trustclientname = trust_client_name; - this.use_player_login_ip = useplayerloginip; - this.require_player_login_ip = requireplayerloginip; - this.check_user_ban = checkuserban; - onMessageReceived.addListener(new Listener() { + onMessageReceived.addListener(new Event.Listener () { @Override public void triggered(Message t) { webChat(t.name, t.message); } }); }}; - - plugin.webServer.handlers.put("/up/sendmessage", messageHandler); + plugin.addServlet("/up/sendmessage", messageHandler); } } - + protected void webChat(String name, String message) { if(plugin.mapManager == null) return; // TODO: Change null to something meaningful. plugin.mapManager.pushUpdate(new Client.ChatMessage("web", null, name, message, null)); Log.info(unescapeString(plugin.configuration.getString("webprefix", "\u00A72[WEB] ")) + name + ": " + unescapeString(plugin.configuration.getString("websuffix", "\u00A7f")) + message); - ChatEvent event = new ChatEvent("web", name, message); + ChatEvent event = new ChatEvent("web", name, message); plugin.events.trigger("webchat", event); } } diff --git a/src/main/java/org/dynmap/servlet/ClientConfigurationServlet.java b/src/main/java/org/dynmap/servlet/ClientConfigurationServlet.java new file mode 100644 index 00000000..9f21e69e --- /dev/null +++ b/src/main/java/org/dynmap/servlet/ClientConfigurationServlet.java @@ -0,0 +1,52 @@ +package org.dynmap.servlet; + +import java.io.IOException; +import java.util.Date; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.dynmap.DynmapPlugin; +import org.dynmap.DynmapWorld; +import org.dynmap.Event; +import org.json.simple.JSONObject; + +public class ClientConfigurationServlet extends HttpServlet { + private static final long serialVersionUID = 9106801553080522469L; + private DynmapPlugin plugin; + private byte[] cachedConfiguration = null; + public ClientConfigurationServlet(DynmapPlugin plugin) { + this.plugin = plugin; + plugin.events.addListener("worldactivated", new Event.Listener() { + @Override + public void triggered(DynmapWorld t) { + cachedConfiguration = null; + } + }); + } + + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse res) throws ServletException, IOException { + byte[] outputBytes = cachedConfiguration; + if (outputBytes == null) { + JSONObject json = new JSONObject(); + plugin.events.trigger("buildclientconfiguration", json); + + String s = json.toJSONString(); + + outputBytes = s.getBytes("UTF-8"); + } + if (outputBytes != null) { + cachedConfiguration = outputBytes; + } + String dateStr = new Date().toString(); + res.addHeader("Date", dateStr); + res.setContentType("text/plain; charset=utf-8"); + res.addHeader("Expires", "Thu, 01 Dec 1994 16:00:00 GMT"); + res.addHeader("Last-modified", dateStr); + res.setContentLength(outputBytes.length); + res.getOutputStream().write(outputBytes); + } +} diff --git a/src/main/java/org/dynmap/servlet/ClientUpdateServlet.java b/src/main/java/org/dynmap/servlet/ClientUpdateServlet.java new file mode 100644 index 00000000..b3cb91da --- /dev/null +++ b/src/main/java/org/dynmap/servlet/ClientUpdateServlet.java @@ -0,0 +1,74 @@ +package org.dynmap.servlet; + +import static org.dynmap.JSONUtils.s; + +import java.io.IOException; +import java.util.Date; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.dynmap.ClientUpdateEvent; +import org.dynmap.DynmapPlugin; +import org.dynmap.DynmapWorld; +import org.dynmap.Log; +import org.dynmap.web.HttpField; +import org.json.simple.JSONObject; + +public class ClientUpdateServlet extends HttpServlet { + private DynmapPlugin plugin; + + public ClientUpdateServlet(DynmapPlugin plugin) { + this.plugin = plugin; + } + + Pattern updatePathPattern = Pattern.compile("/([^/]+)/([0-9]*)"); + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { + String path = req.getPathInfo(); + Matcher match = updatePathPattern.matcher(path); + + if (!match.matches()) { + resp.sendError(404, "World not found"); + return; + } + + String worldName = match.group(1); + String timeKey = match.group(2); + + DynmapWorld dynmapWorld = null; + if(plugin.mapManager != null) { + dynmapWorld = plugin.mapManager.getWorld(worldName); + } + if (dynmapWorld == null || dynmapWorld.world == null) { + resp.sendError(404, "World not found"); + return; + } + long current = System.currentTimeMillis(); + long since = 0; + + try { + since = Long.parseLong(timeKey); + } catch (NumberFormatException e) { + } + + JSONObject u = new JSONObject(); + s(u, "timestamp", current); + plugin.events.trigger("buildclientupdate", new ClientUpdateEvent(since, dynmapWorld, u)); + + byte[] bytes = u.toJSONString().getBytes("UTF-8"); + + String dateStr = new Date().toString(); + resp.addHeader(HttpField.Date, dateStr); + resp.addHeader(HttpField.ContentType, "text/plain; charset=utf-8"); + resp.addHeader(HttpField.Expires, "Thu, 01 Dec 1994 16:00:00 GMT"); + resp.addHeader(HttpField.LastModified, dateStr); + resp.addHeader(HttpField.ContentLength, Integer.toString(bytes.length)); + + resp.getOutputStream().write(bytes); + } +} diff --git a/src/main/java/org/dynmap/servlet/FileServlet.java b/src/main/java/org/dynmap/servlet/FileServlet.java new file mode 100644 index 00000000..8f4e34bd --- /dev/null +++ b/src/main/java/org/dynmap/servlet/FileServlet.java @@ -0,0 +1,578 @@ +/* + * net/balusc/webapp/FileServlet.java + * + * Copyright (C) 2009 BalusC + * + * This program 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 3 + * 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, see . + */ + +package org.dynmap.servlet; + +import java.io.Closeable; +import java.io.File; +import java.io.IOException; +import java.io.OutputStream; +import java.io.RandomAccessFile; +import java.net.URLDecoder; +import java.security.InvalidParameterException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.zip.GZIPOutputStream; + +import javax.servlet.ServletException; +import javax.servlet.ServletOutputStream; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +/** + * A file servlet supporting resume of downloads and client-side caching and GZIP of text content. + * This servlet can also be used for images, client-side caching would become more efficient. + * This servlet can also be used for text files, GZIP would decrease network bandwidth. + * + * @author BalusC + * @link http://balusc.blogspot.com/2009/02/fileservlet-supporting-resume-and.html + */ +public class FileServlet extends HttpServlet { + + // Constants ---------------------------------------------------------------------------------- + + private static final int DEFAULT_BUFFER_SIZE = 10240; // ..bytes = 10KB. + private static final String MULTIPART_BOUNDARY = "MULTIPART_BYTERANGES"; + + // Properties --------------------------------------------------------------------------------- + + private String basePath = null; + private boolean allow_symlinks = true; + private String[] indexFiles = new String[] { + "index.html" + }; + + // Actions ------------------------------------------------------------------------------------ + + public FileServlet() { + } + + public FileServlet(String basePath, boolean allow_symlinks) { + this.basePath = new File(basePath).getAbsolutePath(); + this.allow_symlinks = allow_symlinks; + } + + /** + * Initialize the servlet. + * @see HttpServlet#init(). + */ + public void init() throws ServletException { + if (basePath == null) { + setBasePath(new File(getServletContext().getRealPath(getInitParameter("basePath"))).getAbsolutePath()); + } + } + + public void setBasePath(String basePath) { + // Validate base path. + if (basePath == null) { + throw new InvalidParameterException("'basePath' is required."); + } else { + File path = new File(basePath); + if (!path.exists()) { + throw new InvalidParameterException("'basePath' value '" + + basePath + "' does actually not exist in file system."); + } else if (!path.isDirectory()) { + throw new InvalidParameterException("'basePath' value '" + + basePath + "' is actually not a directory in file system."); + } else if (!path.canRead()) { + throw new InvalidParameterException("'basePath' value '" + + basePath + "' is actually not readable in file system."); + } + } + this.basePath = basePath; + } + + /** + * Process HEAD request. This returns the same headers as GET request, but without content. + * @see HttpServlet#doHead(HttpServletRequest, HttpServletResponse). + */ + protected void doHead(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException + { + // Process request without content. + processRequest(request, response, false); + } + + /** + * Process GET request. + * @see HttpServlet#doGet(HttpServletRequest, HttpServletResponse). + */ + protected void doGet(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException + { + // Process request with content. + processRequest(request, response, true); + } + + private static String getNormalizedPath(String p) { + p = p.replace('\\', '/'); + String[] tok = p.split("/"); + int i, j; + for(i = 0, j = 0; i < tok.length; i++) { + if((tok[i] == null) || (tok[i].length() == 0) || (tok[i].equals("."))) { + tok[i] = null; + } + else if(tok[i].equals("..")) { + if(j > 0) { j--; tok[j] = null; } + tok[i] = null; + } + else { + tok[j] = tok[i]; + j++; + } + } + String path = ""; + for(i = 0; i < j; i++) { + if(tok[i] != null) + path = path + "/" + tok[i]; + } + return path; + } + + /** + * Process the actual request. + * @param request The request to be processed. + * @param response The response to be created. + * @param content Whether the request body should be written (GET) or not (HEAD). + * @throws IOException If something fails at I/O level. + */ + private void processRequest + (HttpServletRequest request, HttpServletResponse response, boolean content) + throws IOException + { + // Validate the requested file ------------------------------------------------------------ + + // Get requested file by path info. + String requestedFile = request.getPathInfo(); + + if (requestedFile != null) + requestedFile = getNormalizedPath(requestedFile); + + // Check if file is actually supplied to the request URL. + if (requestedFile == null) { + // Do your thing if the file is not supplied to the request URL. + // Throw an exception, or send 404, or show default/warning page, or just ignore it. + response.sendError(HttpServletResponse.SC_NOT_FOUND); + return; + } + + // URL-decode the file name (might contain spaces and on) and prepare file object. + File file = new File(basePath, URLDecoder.decode(requestedFile, "UTF-8")); + + String fpath = null; + if(allow_symlinks) + fpath = file.getAbsolutePath(); + else + fpath = file.getCanonicalPath(); + + if (!fpath.startsWith(basePath)) { + response.sendError(HttpServletResponse.SC_NOT_FOUND); + return; + } + + if (file.isDirectory()) { + File directory = file; + for (int i = 0; i < indexFiles.length; i++) { + file = new File(directory, indexFiles[i]); + if (file.isFile()) + break; + } + } + + // Check if file actually exists in filesystem. + if (!file.exists()) { + // Do your thing if the file appears to be non-existing. + // Throw an exception, or send 404, or show default/warning page, or just ignore it. + response.sendError(HttpServletResponse.SC_NOT_FOUND); + return; + } + + // Prepare some variables. The ETag is an unique identifier of the file. + String fileName = file.getName(); + long length = file.length(); + long lastModified = file.lastModified(); + String eTag = fileName + "_" + length + "_" + lastModified; + + + // Validate request headers for caching --------------------------------------------------- + + // If-None-Match header should contain "*" or ETag. If so, then return 304. + String ifNoneMatch = request.getHeader("If-None-Match"); + if (ifNoneMatch != null && matches(ifNoneMatch, eTag)) { + response.setHeader("ETag", eTag); // Required in 304. + response.sendError(HttpServletResponse.SC_NOT_MODIFIED); + return; + } + + // If-Modified-Since header should be greater than LastModified. If so, then return 304. + // This header is ignored if any If-None-Match header is specified. + long ifModifiedSince = request.getDateHeader("If-Modified-Since"); + if (ifNoneMatch == null && ifModifiedSince != -1 && ifModifiedSince + 1000 > lastModified) { + response.setHeader("ETag", eTag); // Required in 304. + response.sendError(HttpServletResponse.SC_NOT_MODIFIED); + return; + } + + + // Validate request headers for resume ---------------------------------------------------- + + // If-Match header should contain "*" or ETag. If not, then return 412. + String ifMatch = request.getHeader("If-Match"); + if (ifMatch != null && !matches(ifMatch, eTag)) { + response.sendError(HttpServletResponse.SC_PRECONDITION_FAILED); + return; + } + + // If-Unmodified-Since header should be greater than LastModified. If not, then return 412. + long ifUnmodifiedSince = request.getDateHeader("If-Unmodified-Since"); + if (ifUnmodifiedSince != -1 && ifUnmodifiedSince + 1000 <= lastModified) { + response.sendError(HttpServletResponse.SC_PRECONDITION_FAILED); + return; + } + + + // Validate and process range ------------------------------------------------------------- + + // Prepare some variables. The full Range represents the complete file. + Range full = new Range(0, length - 1, length); + List ranges = new ArrayList(); + + // Validate and process Range and If-Range headers. + String range = request.getHeader("Range"); + if (range != null) { + + // Range header should match format "bytes=n-n,n-n,n-n...". If not, then return 416. + if (!range.matches("^bytes=\\d*-\\d*(,\\d*-\\d*)*$")) { + response.setHeader("Content-Range", "bytes */" + length); // Required in 416. + response.sendError(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE); + return; + } + + // If-Range header should either match ETag or be greater then LastModified. If not, + // then return full file. + String ifRange = request.getHeader("If-Range"); + if (ifRange != null && !ifRange.equals(eTag)) { + try { + long ifRangeTime = request.getDateHeader("If-Range"); // Throws IAE if invalid. + if (ifRangeTime != -1 && ifRangeTime + 1000 < lastModified) { + ranges.add(full); + } + } catch (IllegalArgumentException ignore) { + ranges.add(full); + } + } + + // If any valid If-Range header, then process each part of byte range. + if (ranges.isEmpty()) { + String[] rangesParts = range.substring(6).split(","); + + if (rangesParts.length > 1) { + response.setHeader("Content-Range", "bytes */" + length); // Required in 416. + response.sendError(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE); + return; + } + for (String part : rangesParts) { + // Assuming a file with length of 100, the following examples returns bytes at: + // 50-80 (50 to 80), 40- (40 to length=100), -20 (length-20=80 to length=100). + long start = sublong(part, 0, part.indexOf("-")); + long end = sublong(part, part.indexOf("-") + 1, part.length()); + + if (start == -1) { + start = length - end; + end = length - 1; + } else if (end == -1 || end > length - 1) { + end = length - 1; + } + + // Check if Range is syntactically valid. If not, then return 416. + if (start > end) { + response.setHeader("Content-Range", "bytes */" + length); // Required in 416. + response.sendError(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE); + return; + } + + // Add range. + ranges.add(new Range(start, end, length)); + } + } + } + + + // Prepare and initialize response -------------------------------------------------------- + + // Get content type by file name and set default GZIP support and content disposition. + boolean acceptsGzip = false; + String disposition = "inline"; + + String contentType = getContentType(fileName); + + // If content type is text, then determine whether GZIP content encoding is supported by + // the browser and expand content type with the one and right character encoding. + if (contentType.startsWith("text")) { + String acceptEncoding = request.getHeader("Accept-Encoding"); + acceptsGzip = acceptEncoding != null && accepts(acceptEncoding, "gzip"); + contentType += ";charset=UTF-8"; + } + // Else, expect for images, determine content disposition. If content type is supported by + // the browser, then set to inline, else attachment which will pop a 'save as' dialogue. + else if (!contentType.startsWith("image")) { + String accept = request.getHeader("Accept"); + disposition = accept != null && accepts(accept, contentType) ? "inline" : "attachment"; + } + + // Initialize response. + response.reset(); + response.setBufferSize(DEFAULT_BUFFER_SIZE); + response.setHeader("Content-Disposition", disposition + ";filename=\"" + fileName + "\""); + response.setHeader("Accept-Ranges", "bytes"); + response.setHeader("ETag", eTag); + response.setDateHeader("Last-Modified", lastModified); + + + // Send requested file (part(s)) to client ------------------------------------------------ + + // Prepare streams. + RandomAccessFile input = null; + OutputStream output = null; + + try { + // Open streams. + input = new RandomAccessFile(file, "r"); + output = response.getOutputStream(); + + if (ranges.isEmpty() || ranges.get(0) == full) { + + // Return full file. + Range r = full; + response.setContentType(contentType); + response.setHeader("Content-Range", "bytes " + r.start + "-" + r.end + "/" + r.total); + + if (content) { + if (acceptsGzip) { + // The browser accepts GZIP, so GZIP the content. + response.setHeader("Content-Encoding", "gzip"); + output = new GZIPOutputStream(output, DEFAULT_BUFFER_SIZE); + } else { + // Content length is not directly predictable in case of GZIP. + // So only add it if there is no means of GZIP, else browser will hang. + response.setHeader("Content-Length", String.valueOf(r.length)); + } + + // Copy full range. + copy(input, output, r.start, r.length); + } + + } else if (ranges.size() == 1) { + + // Return single part of file. + Range r = ranges.get(0); + response.setContentType(contentType); + response.setHeader("Content-Range", "bytes " + r.start + "-" + r.end + "/" + r.total); + response.setHeader("Content-Length", String.valueOf(r.length)); + response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT); // 206. + + if (content) { + // Copy single part range. + copy(input, output, r.start, r.length); + } + + } else { + + // Return multiple parts of file. + response.setContentType("multipart/byteranges; boundary=" + MULTIPART_BOUNDARY); + response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT); // 206. + + if (content) { + // Cast back to ServletOutputStream to get the easy println methods. + ServletOutputStream sos = (ServletOutputStream) output; + + // Copy multi part range. + for (Range r : ranges) { + // Add multipart boundary and header fields for every range. + sos.println(); + sos.println("--" + MULTIPART_BOUNDARY); + sos.println("Content-Type: " + contentType); + sos.println("Content-Range: bytes " + r.start + "-" + r.end + "/" + r.total); + + // Copy single part range of multi part range. + copy(input, output, r.start, r.length); + } + + // End with multipart boundary. + sos.println(); + sos.println("--" + MULTIPART_BOUNDARY + "--"); + } + } + } finally { + // Gently close streams. + close(output); + close(input); + } + } + + // Helpers (can be refactored to public utility class) ---------------------------------------- + + + final static Map mimeTypes = new HashMap() {{ + this.put(".html", "text/html"); + this.put(".htm", "text/html"); + this.put(".js", "text/javascript"); + this.put(".png", "image/png"); + this.put(".jpg", "image/jpeg"); + this.put(".css", "text/css"); + this.put(".txt", "text/plain"); + }}; + public String getContentType(String fileName) { + // Don't use getServetContext! + /*String contentType = getServletContext().getMimeType(fileName); + */ + String contentType = null; + int i = fileName.lastIndexOf('.'); + if (i >= 0) { + String extension = fileName.substring(i); + contentType = mimeTypes.get(extension); + } + + if (contentType == null) { + contentType = "application/octet-stream"; + } + + return contentType; + } + + /** + * Returns true if the given accept header accepts the given value. + * @param acceptHeader The accept header. + * @param toAccept The value to be accepted. + * @return True if the given accept header accepts the given value. + */ + private static boolean accepts(String acceptHeader, String toAccept) { + String[] acceptValues = acceptHeader.split("\\s*(,|;)\\s*"); + Arrays.sort(acceptValues); + return Arrays.binarySearch(acceptValues, toAccept) > -1 + || Arrays.binarySearch(acceptValues, toAccept.replaceAll("/.*$", "/*")) > -1 + || Arrays.binarySearch(acceptValues, "*/*") > -1; + } + + /** + * Returns true if the given match header matches the given value. + * @param matchHeader The match header. + * @param toMatch The value to be matched. + * @return True if the given match header matches the given value. + */ + private static boolean matches(String matchHeader, String toMatch) { + String[] matchValues = matchHeader.split("\\s*,\\s*"); + Arrays.sort(matchValues); + return Arrays.binarySearch(matchValues, toMatch) > -1 + || Arrays.binarySearch(matchValues, "*") > -1; + } + + /** + * Returns a substring of the given string value from the given begin index to the given end + * index as a long. If the substring is empty, then -1 will be returned + * @param value The string value to return a substring as long for. + * @param beginIndex The begin index of the substring to be returned as long. + * @param endIndex The end index of the substring to be returned as long. + * @return A substring of the given string value as long or -1 if substring is empty. + */ + private static long sublong(String value, int beginIndex, int endIndex) { + String substring = value.substring(beginIndex, endIndex); + return (substring.length() > 0) ? Long.parseLong(substring) : -1; + } + + /** + * Copy the given byte range of the given input to the given output. + * @param input The input to copy the given range to the given output for. + * @param output The output to copy the given range from the given input for. + * @param start Start of the byte range. + * @param length Length of the byte range. + * @throws IOException If something fails at I/O level. + */ + private static void copy(RandomAccessFile input, OutputStream output, long start, long length) + throws IOException + { + byte[] buffer = new byte[DEFAULT_BUFFER_SIZE]; + int read; + + if (input.length() == length) { + // Write full range. + while ((read = input.read(buffer)) > 0) { + output.write(buffer, 0, read); + } + } else { + // Write partial range. + input.seek(start); + long toRead = length; + + while ((read = input.read(buffer)) > 0) { + if ((toRead -= read) > 0) { + output.write(buffer, 0, read); + } else { + output.write(buffer, 0, (int) toRead + read); + break; + } + } + } + } + + /** + * Close the given resource. + * @param resource The resource to be closed. + */ + private static void close(Closeable resource) { + if (resource != null) { + try { + resource.close(); + } catch (IOException ignore) { + // Ignore IOException. If you want to handle this anyway, it might be useful to know + // that this will generally only be thrown when the client aborted the request. + } + } + } + + // Inner classes ------------------------------------------------------------------------------ + + /** + * This class represents a byte range. + */ + protected class Range { + long start; + long end; + long length; + long total; + + /** + * Construct a byte range. + * @param start Start of the byte range. + * @param end End of the byte range. + * @param total Total length of the byte source. + */ + public Range(long start, long end, long total) { + this.start = start; + this.end = end; + this.length = end - start + 1; + this.total = total; + } + + } + +} + diff --git a/src/main/java/org/dynmap/servlet/JSONServlet.java b/src/main/java/org/dynmap/servlet/JSONServlet.java new file mode 100644 index 00000000..7aea11e1 --- /dev/null +++ b/src/main/java/org/dynmap/servlet/JSONServlet.java @@ -0,0 +1,18 @@ +package org.dynmap.servlet; + +import java.io.IOException; +import java.io.PrintWriter; + +import javax.servlet.http.HttpServletResponse; + +import org.json.simple.JSONAware; +import org.json.simple.JSONStreamAware; + +public class JSONServlet { + public static void respond(HttpServletResponse response, JSONStreamAware json) throws IOException { + response.setContentType("application/json"); + PrintWriter writer = response.getWriter(); + json.writeJSONString(writer); + writer.close(); + } +} diff --git a/src/main/java/org/dynmap/servlet/MainServlet.java b/src/main/java/org/dynmap/servlet/MainServlet.java new file mode 100644 index 00000000..26b2ca89 --- /dev/null +++ b/src/main/java/org/dynmap/servlet/MainServlet.java @@ -0,0 +1,146 @@ +package org.dynmap.servlet; + +import java.io.IOException; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletRequestWrapper; +import javax.servlet.http.HttpServletResponse; + +import org.dynmap.Log; + +public class MainServlet extends HttpServlet { + public static class Header { + public String name; + public String value; + public Header(String name, String value) { + this.name = name; + this.value = value; + } + } + + private static class Registration { + public String pattern; + public HttpServlet servlet; + + public Registration(String pattern, HttpServlet servlet) { + this.pattern = pattern; + this.servlet = servlet; + } + } + + List registrations = new LinkedList(); + public List
customHeaders = new LinkedList
(); + + public void addServlet(String pattern, HttpServlet servlet) { + registrations.add(new Registration(pattern, servlet)); + } + + @Override + protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { + HashMap properties = new HashMap(); + String path = req.getPathInfo(); + + for(Header header : customHeaders) { + resp.setHeader(header.name, header.value); + } + + Registration bestMatch = null; + String bestMatchPart = null; + HashMap bestProperties = null; + + for (Registration r : registrations) { + String matchingPart = match(r.pattern, path, properties); + if (matchingPart != null) { + if (bestMatchPart == null || bestMatchPart.length() < matchingPart.length()) { + bestMatch = r; + bestMatchPart = matchingPart; + bestProperties = properties; + properties = new HashMap(); + } + } + } + if (bestMatch == null) { + resp.sendError(404); + } else { + String leftOverPath = path.substring(bestMatchPart.length()); + HttpServletRequest newreq = new RequestWrapper(req, leftOverPath); + for(String key : bestProperties.keySet()) { + newreq.setAttribute(key, bestProperties.get(key)); + } + bestMatch.servlet.service(newreq, resp); + } + } + + public String match(String pattern, String path, Map properties) { + int patternStart = 0; + int pathStart = 0; + while (patternStart < pattern.length()) { + if (pattern.charAt(patternStart) == '{') { + // Found a variable. + int endOfVariable = pattern.indexOf('}', patternStart+1); + String variableName = pattern.substring(patternStart+1, endOfVariable); + + int endOfSection = indexOfAny(path, new char[] { '/', '?' }, pathStart); + if (endOfSection < 0) { + endOfSection = path.length(); + } + String variableValue = path.substring(pathStart, endOfSection); + + // Store variable. + properties.put(variableName, variableValue); + + patternStart = endOfVariable+1; + pathStart = endOfSection; + } else { + int endOfLiteral = pattern.indexOf('{', patternStart); + if (endOfLiteral < 0) { + endOfLiteral = pattern.length(); + } + String literal = pattern.substring(patternStart, endOfLiteral); + int endOfPathLiteral = pathStart + literal.length(); + if (endOfPathLiteral > path.length()) { + return null; + } + String matchingLiteral = path.substring(pathStart, endOfPathLiteral); + if (!literal.equals(matchingLiteral)) { + return null; + } + + patternStart = endOfLiteral; + pathStart = endOfPathLiteral; + } + } + // Return the part of the url that matches the pattern. (if the pattern does not contain any variables, this will be equal to the pattern) + return path.substring(0, pathStart); + } + + private int indexOfAny(String s, char[] cs, int startIndex) { + for(int i = startIndex; i < s.length(); i++) { + char c = s.charAt(i); + for(int j = 0; j < cs.length; j++) { + if (c == cs[j]) { + return i; + } + } + } + return -1; + } + + class RequestWrapper extends HttpServletRequestWrapper { + String pathInfo; + public RequestWrapper(HttpServletRequest request, String pathInfo) { + super(request); + this.pathInfo = pathInfo; + } + @Override + public String getPathInfo() { + return pathInfo; + } + } +} diff --git a/src/main/java/org/dynmap/web/handlers/SendMessageHandler.java b/src/main/java/org/dynmap/servlet/SendMessageServlet.java similarity index 60% rename from src/main/java/org/dynmap/web/handlers/SendMessageHandler.java rename to src/main/java/org/dynmap/servlet/SendMessageServlet.java index 088cc79c..f6b2efc7 100644 --- a/src/main/java/org/dynmap/web/handlers/SendMessageHandler.java +++ b/src/main/java/org/dynmap/servlet/SendMessageServlet.java @@ -1,5 +1,19 @@ -package org.dynmap.web.handlers; +package org.dynmap.servlet; +import org.bukkit.OfflinePlayer; +import org.dynmap.DynmapPlugin; +import org.dynmap.Event; +import org.dynmap.Log; +import org.dynmap.web.HttpStatus; +import org.json.simple.JSONObject; +import org.json.simple.parser.JSONParser; +import org.json.simple.parser.ParseException; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; import java.io.InputStreamReader; import java.nio.charset.Charset; import java.util.HashMap; @@ -7,96 +21,81 @@ import java.util.LinkedList; import java.util.List; import java.util.logging.Logger; -import org.bukkit.OfflinePlayer; -import org.dynmap.DynmapPlugin; -import org.dynmap.Event; -import org.dynmap.Log; -import org.dynmap.web.HttpField; -import org.dynmap.web.HttpHandler; -import org.dynmap.web.HttpMethod; -import org.dynmap.web.HttpRequest; -import org.dynmap.web.HttpResponse; -import org.dynmap.web.HttpStatus; -import org.json.simple.JSONObject; -import org.json.simple.parser.JSONParser; - -public class SendMessageHandler implements HttpHandler { +public class SendMessageServlet extends HttpServlet { protected static final Logger log = Logger.getLogger("Minecraft"); private static final JSONParser parser = new JSONParser(); - public Event onMessageReceived = new Event(); + public Event onMessageReceived = new Event(); private Charset cs_utf8 = Charset.forName("UTF-8"); public int maximumMessageInterval = 1000; public boolean hideip = false; public boolean trustclientname = false; - public boolean use_player_login_ip = false; - public boolean require_player_login_ip = false; - public boolean check_user_ban = false; - public DynmapPlugin plug_in; + public String spamMessage = "\"You may only chat once every %interval% seconds.\""; private HashMap disallowedUsers = new HashMap(); private LinkedList disallowedUserQueue = new LinkedList(); private Object disallowedUsersLock = new Object(); private HashMap useralias = new HashMap(); private int aliasindex = 1; - + public boolean use_player_login_ip = false; + public boolean require_player_login_ip = false; + public boolean check_user_ban = false; + public DynmapPlugin plug_in; + + @Override - public void handle(String path, HttpRequest request, HttpResponse response) throws Exception { - if (!request.method.equals(HttpMethod.Post)) { - response.status = HttpStatus.MethodNotAllowed; - response.fields.put(HttpField.Accept, HttpMethod.Post); + protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { + InputStreamReader reader = new InputStreamReader(request.getInputStream(), cs_utf8); + + JSONObject o = null; + try { + o = (JSONObject)parser.parse(reader); + } catch (ParseException e) { + response.sendError(HttpStatus.BadRequest.getCode()); return; } - InputStreamReader reader = new InputStreamReader(request.body, cs_utf8); - - JSONObject o = (JSONObject)parser.parse(reader); final Message message = new Message(); - + message.name = ""; if(trustclientname) { message.name = String.valueOf(o.get("name")); } boolean isip = true; if((message.name == null) || message.name.equals("")) { - /* If proxied client address, get original */ - if(request.fields.containsKey("X-Forwarded-For")) - message.name = request.fields.get("X-Forwarded-For"); - /* If from loopback, we're probably getting from proxy - need to trust client */ - else if(request.rmtaddr.getAddress().isLoopbackAddress()) - message.name = String.valueOf(o.get("name")); - else - message.name = request.rmtaddr.getAddress().getHostAddress(); + /* If proxied client address, get original */ + if(request.getHeader("X-Forwarded-For") != null) + message.name = request.getHeader("X-Forwarded-For"); + /* If from loopback, we're probably getting from proxy - need to trust client */ + else if(request.getRemoteAddr() == "127.0.0.1") + message.name = String.valueOf(o.get("name")); + else + message.name = request.getRemoteAddr(); } - if(use_player_login_ip) { + if (use_player_login_ip) { List ids = plug_in.getIDsForIP(message.name); - if(ids != null) { + if (ids != null) { String id = ids.get(0); - if(check_user_ban) { + if (check_user_ban) { OfflinePlayer p = plug_in.getServer().getOfflinePlayer(id); - if((p != null) && p.isBanned()) { + if ((p != null) && p.isBanned()) { Log.info("Ignore message from '" + message.name + "' - banned player (" + id + ")"); - response.fields.put("Content-Length", "0"); - response.status = HttpStatus.Forbidden; - response.getBody(); + response.sendError(HttpStatus.Forbidden.getCode()); return; } } message.name = ids.get(0); isip = false; - } - else if(require_player_login_ip) { + } else if (require_player_login_ip) { Log.info("Ignore message from '" + message.name + "' - no matching player login recorded"); - response.fields.put("Content-Length", "0"); - response.status = HttpStatus.Forbidden; - response.getBody(); + response.sendError(HttpStatus.Forbidden.getCode()); return; } } - if(hideip && isip) { /* If hiding IP, find or assign alias */ - synchronized(disallowedUsersLock) { + if (hideip && isip) { /* If hiding IP, find or assign alias */ + synchronized (disallowedUsersLock) { String n = useralias.get(message.name); - if(n == null) { /* Make ID */ + if (n == null) { /* Make ID */ n = String.format("web-%03d", aliasindex); aliasindex++; useralias.put(message.name, n); @@ -108,7 +107,7 @@ public class SendMessageHandler implements HttpHandler { final long now = System.currentTimeMillis(); - synchronized(disallowedUsersLock) { + synchronized (disallowedUsersLock) { // Allow users that user that are now allowed to send messages. while (!disallowedUserQueue.isEmpty()) { WebUser wu = disallowedUserQueue.getFirst(); @@ -122,25 +121,21 @@ public class SendMessageHandler implements HttpHandler { WebUser user = disallowedUsers.get(message.name); if (user == null) { - user = new WebUser() {{ - name = message.name; - nextMessageTime = now+maximumMessageInterval; - }}; + user = new WebUser() { + { + name = message.name; + nextMessageTime = now + maximumMessageInterval; + } + }; disallowedUsers.put(user.name, user); disallowedUserQueue.add(user); } else { - response.fields.put("Content-Length", "0"); - response.status = HttpStatus.Forbidden; - response.getBody(); + response.sendError(HttpStatus.Forbidden.getCode()); return; } } onMessageReceived.trigger(message); - - response.fields.put(HttpField.ContentLength, "0"); - response.status = HttpStatus.OK; - response.getBody(); } public static class Message { diff --git a/src/main/java/org/dynmap/web/HttpHandler.java b/src/main/java/org/dynmap/web/HttpHandler.java deleted file mode 100644 index 274217dd..00000000 --- a/src/main/java/org/dynmap/web/HttpHandler.java +++ /dev/null @@ -1,6 +0,0 @@ -package org.dynmap.web; - - -public interface HttpHandler { - void handle(String path, HttpRequest request, HttpResponse response) throws Exception; -} diff --git a/src/main/java/org/dynmap/web/HttpRequest.java b/src/main/java/org/dynmap/web/HttpRequest.java deleted file mode 100644 index dc8db7bb..00000000 --- a/src/main/java/org/dynmap/web/HttpRequest.java +++ /dev/null @@ -1,15 +0,0 @@ -package org.dynmap.web; - -import java.io.InputStream; -import java.util.HashMap; -import java.util.Map; -import java.net.InetSocketAddress; - -public class HttpRequest { - public String method; - public String path; - public String version; - public Map fields = new HashMap(); - public InputStream body; - public InetSocketAddress rmtaddr; -} diff --git a/src/main/java/org/dynmap/web/HttpResponse.java b/src/main/java/org/dynmap/web/HttpResponse.java deleted file mode 100644 index 5b0c80b8..00000000 --- a/src/main/java/org/dynmap/web/HttpResponse.java +++ /dev/null @@ -1,29 +0,0 @@ -package org.dynmap.web; - -import java.io.IOException; -import java.io.OutputStream; -import java.util.HashMap; -import java.util.Map; - -public class HttpResponse { - private HttpServerConnection connection; - public String version = "1.1"; - public HttpStatus status = null; - public Map fields = new HashMap(); - - private OutputStream body; - public OutputStream getBody() throws IOException { - if (body != null) { - connection.writeResponseHeader(this); - OutputStream b = body; - body = null; - return b; - } - return null; - } - - public HttpResponse(HttpServerConnection connection, OutputStream body) { - this.connection = connection; - this.body = body; - } -} diff --git a/src/main/java/org/dynmap/web/HttpServer.java b/src/main/java/org/dynmap/web/HttpServer.java deleted file mode 100644 index 9261b76a..00000000 --- a/src/main/java/org/dynmap/web/HttpServer.java +++ /dev/null @@ -1,195 +0,0 @@ -package org.dynmap.web; - -import java.io.IOException; -import java.net.InetAddress; -import java.net.ServerSocket; -import java.net.Socket; -import java.net.SocketAddress; -import java.util.Collections; -import java.util.HashMap; -import java.util.HashSet; -import java.util.Map; -import java.util.SortedMap; -import java.util.TreeMap; -import java.util.logging.Logger; - -import org.bukkit.plugin.Plugin; -import org.dynmap.Log; - -public class HttpServer extends Thread { - protected static final Logger log = Logger.getLogger("Minecraft"); - - private ServerSocket sock = null; - private Thread listeningThread; - - private InetAddress bindAddress; - private int port; - private boolean check_banned_ips; - private int max_sessions; - - public SortedMap handlers = new TreeMap(Collections.reverseOrder()); - - private Object lock = new Object(); - private HashSet active_connections = new HashSet(); - private HashSet keepalive_connections = new HashSet(); - private Plugin plugin; - private static Map headers = new HashMap(); - - public HttpServer(InetAddress bindAddress, int port, boolean check_banned_ips, int max_sessions, Plugin plg) { - this.bindAddress = bindAddress; - this.port = port; - this.check_banned_ips = check_banned_ips; - this.max_sessions = max_sessions; - this.plugin = plg; - } - - public InetAddress getAddress() { - return bindAddress; - } - - public int getPort() { - return port; - } - - public void startServer() throws IOException { - sock = new ServerSocket(port, 50, bindAddress); /* 5 too low - more than a couple users during render will get connect errors on some tile loads */ - listeningThread = this; - start(); - Log.info("Dynmap WebServer started on " + bindAddress + ":" + port); - } - - public void run() { - try { - ServerSocket s = sock; - while (listeningThread == Thread.currentThread()) { - try { - Socket socket = s.accept(); - if(checkForBannedIp(socket.getRemoteSocketAddress())) { - try { socket.close(); } catch (IOException iox) {} - socket = null; - } - - HttpServerConnection requestThread = new HttpServerConnection(socket, this); - synchronized(lock) { - active_connections.add(requestThread); - requestThread.start(); - /* If we're at limit, wait here until we're free to accept another */ - while((listeningThread == Thread.currentThread()) && - (active_connections.size() >= max_sessions)) { - lock.wait(500); - } - } - } catch (IOException e) { - if(listeningThread != null) /* Only report this if we didn't initiate the shutdown */ - Log.info("map WebServer.run() stops with IOException"); - break; - } - } - Log.info("Webserver shut down."); - } catch (Exception ex) { - Log.severe("Exception on WebServer-thread", ex); - } - } - - public void shutdown() { - Log.info("Shutting down webserver..."); - listeningThread = null; - try { - if (sock != null) { - sock.close(); - sock = null; - } - /* And kill off the active connections */ - HashSet sc; - synchronized(lock) { - sc = new HashSet(active_connections); - } - for(HttpServerConnection c : sc) { - c.shutdownConnection(); - } - } catch (IOException e) { - Log.warning("Exception while closing socket for webserver shutdown", e); - } - } - - public boolean canKeepAlive(HttpServerConnection c) { - synchronized(lock) { - /* If less than half of our limit are keep-alive, approve */ - if(keepalive_connections.size() < (max_sessions/2)) { - keepalive_connections.add(c); - return true; - } - } - return false; - } - - public void connectionEnded(HttpServerConnection c) { - synchronized(lock) { - active_connections.remove(c); - keepalive_connections.remove(c); - lock.notifyAll(); - } - } - - private HashSet banned_ips = new HashSet(); - private HashSet banned_ips_notified = new HashSet(); - private long last_loaded = 0; - private long lastmod = 0; - private static final long BANNED_RELOAD_INTERVAL = 15000; /* Every 15 seconds */ - - private void loadBannedIPs() { - banned_ips.clear(); - banned_ips_notified.clear(); - banned_ips.addAll(plugin.getServer().getIPBans()); - } - - /* Return true if address is banned */ - public boolean checkForBannedIp(SocketAddress socketAddress) { - if(!check_banned_ips) - return false; - - long t = System.currentTimeMillis(); - if((t < last_loaded) || ((t-last_loaded) > BANNED_RELOAD_INTERVAL)) { - loadBannedIPs(); - last_loaded = t; - } - /* Follow same technique as MC uses - toString the SocketAddress and clip out string between "/" and ":" */ - String ip = socketAddress.toString(); - ip = ip.substring(ip.indexOf("/") + 1); - ip = ip.substring(0, ip.indexOf(":")); - if(banned_ips.contains(ip)) { - if(banned_ips_notified.contains(ip) == false) { - Log.info("Rejected connection by banned IP address - " + socketAddress.toString()); - banned_ips_notified.add(ip); - } - return true; - } - return false; - } - /* Return true if address is banned */ - public boolean checkForBannedIp(String ipaddr) { - if(!check_banned_ips) - return false; - - long t = System.currentTimeMillis(); - if((t < last_loaded) || ((t-last_loaded) > BANNED_RELOAD_INTERVAL)) { - loadBannedIPs(); - last_loaded = t; - } - if(banned_ips.contains(ipaddr)) { - if(banned_ips_notified.contains(ipaddr) == false) { - Log.info("Rejected connection by banned IP address - " + ipaddr); - banned_ips_notified.add(ipaddr); - } - return true; - } - return false; - } - - public static Map getCustomHeaders() { - return headers; - } - public static void setCustomHeaders(Map hdrs) { - headers = hdrs; - } -} diff --git a/src/main/java/org/dynmap/web/HttpServerConnection.java b/src/main/java/org/dynmap/web/HttpServerConnection.java deleted file mode 100644 index 814f7ff5..00000000 --- a/src/main/java/org/dynmap/web/HttpServerConnection.java +++ /dev/null @@ -1,282 +0,0 @@ -package org.dynmap.web; - -import java.io.BufferedOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import java.io.PrintStream; -import java.io.StringWriter; -import java.net.Socket; -import java.net.URLDecoder; -import java.util.Map.Entry; -import java.util.logging.Logger; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -import org.dynmap.Log; -import org.dynmap.debug.Debug; -import java.net.InetSocketAddress; - -public class HttpServerConnection extends Thread { - protected static final Logger log = Logger.getLogger("Minecraft"); - - private static Pattern requestHeaderLine = Pattern.compile("^(\\S+)\\s+(\\S+)\\s+HTTP/(.+)$"); - private static Pattern requestHeaderField = Pattern.compile("^([^:]+):\\s*(.+)$"); - - private Socket socket; - private HttpServer server; - private boolean do_shutdown; - private boolean can_keepalive; - - private PrintStream printOut; - private StringWriter sw = new StringWriter(); - private Matcher requestHeaderLineMatcher; - private Matcher requestHeaderFieldMatcher; - - public HttpServerConnection(Socket socket, HttpServer server) { - this.socket = socket; - this.server = server; - do_shutdown = false; - can_keepalive = false; - } - - private final static void readLine(InputStream in, StringWriter sw) throws IOException { - int readc; - while((readc = in.read()) > 0) { - char c = (char)readc; - if (c == '\n') - break; - else if (c != '\r') - sw.append(c); - } - } - - private final String readLine(InputStream in) throws IOException { - readLine(in, sw); - String r = sw.toString(); - sw.getBuffer().setLength(0); - return r; - } - - private final boolean readRequestHeader(InputStream in, HttpRequest request) throws IOException { - String statusLine = readLine(in); - - if (statusLine == null) - return false; - - if (requestHeaderLineMatcher == null) { - requestHeaderLineMatcher = requestHeaderLine.matcher(statusLine); - } else { - requestHeaderLineMatcher.reset(statusLine); - } - - Matcher m = requestHeaderLineMatcher; - if (!m.matches()) - return false; - request.method = m.group(1); - request.path = m.group(2); - request.version = m.group(3); - - String line; - while (!(line = readLine(in)).equals("")) { - if (requestHeaderFieldMatcher == null) { - requestHeaderFieldMatcher = requestHeaderField.matcher(line); - } else { - requestHeaderFieldMatcher.reset(line); - } - - m = requestHeaderFieldMatcher; - // Warning: unknown lines are ignored. - if (m.matches()) { - String fieldName = m.group(1); - String fieldValue = m.group(2); - // TODO: Does not support duplicate field-names. - request.fields.put(fieldName, fieldValue); - } - } - return true; - } - - public static final void writeResponseHeader(PrintStream out, HttpResponse response) throws IOException { - out.append("HTTP/"); - out.append(response.version); - out.append(" "); - out.append(String.valueOf(response.status.getCode())); - out.append(" "); - out.append(response.status.getText()); - out.append("\r\n"); - for (Entry field : response.fields.entrySet()) { - out.append(field.getKey()); - out.append(": "); - out.append(field.getValue()); - out.append("\r\n"); - } - for(Entry custom : HttpServer.getCustomHeaders().entrySet()) { - out.append(custom.getKey()); - out.append(": "); - out.append(custom.getValue()); - out.append("\r\n"); - } - out.append("\r\n"); - out.flush(); - } - - public final void writeResponseHeader(HttpResponse response) throws IOException { - writeResponseHeader(printOut, response); - } - - public void run() { - try { - if (socket == null) - return; - socket.setSoTimeout(5000); - socket.setTcpNoDelay(true); - InetSocketAddress rmtaddr = (InetSocketAddress)socket.getRemoteSocketAddress(); /* Get remote address */ - InputStream in = socket.getInputStream(); - BufferedOutputStream out = new BufferedOutputStream(socket.getOutputStream(), 40960); - - printOut = new PrintStream(out, false); - while (true) { - /* Check for start of each request - kicks out persistent connections */ - if(server.checkForBannedIp(rmtaddr)) { - return; - } - - HttpRequest request = new HttpRequest(); - request.rmtaddr = rmtaddr; - if (!readRequestHeader(in, request)) { - return; - } - String fwd_for = request.fields.get("X-Forwarded-For"); - if(fwd_for != null) { - String[] ff = fwd_for.split(","); - for(int i = 0; i < ff.length; i++) { - if(server.checkForBannedIp(ff[i])) - return; - } - } - - long bound = -1; - BoundInputStream boundBody = null; - { - String contentLengthStr = request.fields.get(HttpField.ContentLength); - if (contentLengthStr != null) { - try { - bound = Long.parseLong(contentLengthStr); - } catch (NumberFormatException e) { - } - if (bound >= 0) { - request.body = boundBody = new BoundInputStream(in, bound); - } else { - request.body = in; - } - } - } - boolean iskeepalive = false; - String keepalive = request.fields.get(HttpField.Connection); - if((keepalive != null) && (keepalive.toLowerCase().indexOf("keep-alive") >= 0)) { - /* See if we're clear to do keepalive */ - if(!iskeepalive) - iskeepalive = server.canKeepAlive(this); - } - - // TODO: Optimize HttpHandler-finding by using a real path-aware tree. - HttpHandler handler = null; - String relativePath = null; - for (Entry entry : server.handlers.entrySet()) { - String key = entry.getKey(); - boolean directoryHandler = key.endsWith("/"); - if (directoryHandler && request.path.startsWith(entry.getKey()) || !directoryHandler && request.path.equals(entry.getKey())) { - relativePath = request.path.substring(entry.getKey().length()); - relativePath = URLDecoder.decode(relativePath,"utf-8"); - handler = entry.getValue(); - break; - } - /* Wildcard handler for non-directory matches */ - else if(key.endsWith("*") && request.path.startsWith(key.substring(0, key.length()-1))) { relativePath = request.path.substring(entry.getKey().length()); - relativePath = request.path.substring(entry.getKey().length()-1); - relativePath = URLDecoder.decode(relativePath,"utf-8"); - handler = entry.getValue(); - break; - } - } - - if (handler == null) { - return; - } - - HttpResponse response = new HttpResponse(this, out); - - if(iskeepalive) { - response.fields.put(HttpField.Connection, "keep-alive"); - response.fields.put("Keep-Alive", "timeout=5"); - } - else { - response.fields.put(HttpField.Connection, "close"); - } - try { - handler.handle(relativePath, request, response); - } catch (IOException e) { - throw e; - } catch (Exception e) { - Log.severe("HttpHandler '" + handler + "' has thown an exception", e); - out.flush(); - return; - } - - if (bound > 0 && boundBody.skip(bound) < bound) { - Debug.debug("Incoming stream was only read partially by handler '" + handler + "'."); - //socket.close(); - //return; - } - - boolean isKeepalive = iskeepalive && !"close".equals(request.fields.get(HttpField.Connection)) && !"close".equals(response.fields.get(HttpField.Connection)); - String contentLength = response.fields.get("Content-Length"); - if (isKeepalive && contentLength == null) { - // A handler has been a bad boy, but we're here to fix it. - response.fields.put("Content-Length", "0"); - OutputStream responseBody = response.getBody(); - - // The HttpHandler has already send the headers and written to the body without setting the Content-Length. - if (responseBody == null) { - Debug.debug("Response was given without Content-Length by '" + handler + "' for path '" + request.path + "'."); - out.flush(); - return; - } - } - - out.flush(); - - if (!isKeepalive) { - return; - } - } - } catch (IOException e) { - - } catch (Exception e) { - if(!do_shutdown) { - Log.severe("Exception while handling request: ", e); - e.printStackTrace(); - } - } finally { - if (socket != null) { - try { - socket.close(); - } catch (IOException ex) { - } - } - server.connectionEnded(this); - } - } - public void shutdownConnection() { - try { - do_shutdown = true; - if(socket != null) { - socket.close(); - } - join(); /* Wait for thread to die */ - } catch (IOException iox) { - } catch (InterruptedException ix) { - } - } -} diff --git a/src/main/java/org/dynmap/web/handlers/ClientConfigurationHandler.java b/src/main/java/org/dynmap/web/handlers/ClientConfigurationHandler.java deleted file mode 100644 index 9a899f07..00000000 --- a/src/main/java/org/dynmap/web/handlers/ClientConfigurationHandler.java +++ /dev/null @@ -1,50 +0,0 @@ -package org.dynmap.web.handlers; - -import java.io.BufferedOutputStream; -import java.util.Date; -import org.dynmap.DynmapPlugin; -import org.dynmap.DynmapWorld; -import org.dynmap.Event; -import org.dynmap.web.HttpHandler; -import org.dynmap.web.HttpRequest; -import org.dynmap.web.HttpResponse; -import org.dynmap.web.HttpStatus; -import org.json.simple.JSONObject; - -public class ClientConfigurationHandler implements HttpHandler { - private DynmapPlugin plugin; - private byte[] cachedConfiguration = null; - public ClientConfigurationHandler(DynmapPlugin plugin) { - this.plugin = plugin; - plugin.events.addListener("worldactivated", new Event.Listener() { - @Override - public void triggered(DynmapWorld t) { - cachedConfiguration = null; - } - }); - } - @Override - public void handle(String path, HttpRequest request, HttpResponse response) throws Exception { - if (cachedConfiguration == null) { - JSONObject configurationObject = new JSONObject(); - plugin.events.trigger("buildclientconfiguration", configurationObject); - - String s = configurationObject.toJSONString(); - - cachedConfiguration = s.getBytes("UTF-8"); - } - String dateStr = new Date().toString(); - - response.fields.put("Date", dateStr); - response.fields.put("Content-Type", "text/plain; charset=utf-8"); - response.fields.put("Expires", "Thu, 01 Dec 1994 16:00:00 GMT"); - response.fields.put("Last-modified", dateStr); - response.fields.put("Content-Length", Integer.toString(cachedConfiguration.length)); - response.status = HttpStatus.OK; - - BufferedOutputStream out = null; - out = new BufferedOutputStream(response.getBody()); - out.write(cachedConfiguration); - out.flush(); - } -} diff --git a/src/main/java/org/dynmap/web/handlers/ClientUpdateHandler.java b/src/main/java/org/dynmap/web/handlers/ClientUpdateHandler.java deleted file mode 100644 index 9c6ab99f..00000000 --- a/src/main/java/org/dynmap/web/handlers/ClientUpdateHandler.java +++ /dev/null @@ -1,78 +0,0 @@ -package org.dynmap.web.handlers; - -import java.io.BufferedOutputStream; -import java.util.Date; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -import org.dynmap.ClientUpdateEvent; -import org.dynmap.DynmapPlugin; -import org.dynmap.DynmapWorld; -import org.dynmap.web.HttpField; -import org.dynmap.web.HttpHandler; -import org.dynmap.web.HttpRequest; -import org.dynmap.web.HttpResponse; -import org.dynmap.web.HttpStatus; -import org.json.simple.JSONObject; -import static org.dynmap.JSONUtils.*; - -public class ClientUpdateHandler implements HttpHandler { - private DynmapPlugin plugin; - - public ClientUpdateHandler(DynmapPlugin plugin) { - this.plugin = plugin; - } - - Pattern updatePathPattern = Pattern.compile("world/([^/]+)/([0-9]*)"); - private static final HttpStatus WorldNotFound = new HttpStatus(HttpStatus.NotFound.getCode(), "World Not Found"); - @Override - public void handle(String path, HttpRequest request, HttpResponse response) throws Exception { - - Matcher match = updatePathPattern.matcher(path); - - if (!match.matches()) { - response.status = HttpStatus.Forbidden; - return; - } - - String worldName = match.group(1); - String timeKey = match.group(2); - - DynmapWorld dynmapWorld = null; - if(plugin.mapManager != null) { - dynmapWorld = plugin.mapManager.getWorld(worldName); - } - if (dynmapWorld == null || dynmapWorld.world == null) { - response.status = WorldNotFound; - return; - } - long current = System.currentTimeMillis(); - long since = 0; - - if (path.length() > 0) { - try { - since = Long.parseLong(timeKey); - } catch (NumberFormatException e) { - } - } - - JSONObject u = new JSONObject(); - s(u, "timestamp", current); - plugin.events.trigger("buildclientupdate", new ClientUpdateEvent(since, dynmapWorld, u)); - - byte[] bytes = u.toJSONString().getBytes("UTF-8"); - - String dateStr = new Date().toString(); - response.fields.put(HttpField.Date, dateStr); - response.fields.put(HttpField.ContentType, "text/plain; charset=utf-8"); - response.fields.put(HttpField.Expires, "Thu, 01 Dec 1994 16:00:00 GMT"); - response.fields.put(HttpField.LastModified, dateStr); - response.fields.put(HttpField.ContentLength, Integer.toString(bytes.length)); - response.status = HttpStatus.OK; - - BufferedOutputStream out = null; - out = new BufferedOutputStream(response.getBody()); - out.write(bytes); - out.flush(); - } -} diff --git a/src/main/java/org/dynmap/web/handlers/FileHandler.java b/src/main/java/org/dynmap/web/handlers/FileHandler.java deleted file mode 100644 index e00ea5dc..00000000 --- a/src/main/java/org/dynmap/web/handlers/FileHandler.java +++ /dev/null @@ -1,121 +0,0 @@ -package org.dynmap.web.handlers; - -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import java.util.HashMap; -import java.util.LinkedList; -import java.util.Map; -import java.util.logging.Logger; - -import org.dynmap.web.HttpField; -import org.dynmap.web.HttpHandler; -import org.dynmap.web.HttpRequest; -import org.dynmap.web.HttpResponse; -import org.dynmap.web.HttpStatus; - -public abstract class FileHandler implements HttpHandler { - protected static final Logger log = Logger.getLogger("Minecraft"); - - private LinkedList bufferpool = new LinkedList(); - private Object lock = new Object(); - private static final int MAX_FREE_IN_POOL = 2; - - private static Map mimes = new HashMap(); - static { - mimes.put(".html", "text/html"); - mimes.put(".htm", "text/html"); - mimes.put(".js", "text/javascript"); - mimes.put(".png", "image/png"); - mimes.put(".jpg", "image/jpeg"); - mimes.put(".css", "text/css"); - mimes.put(".txt", "text/plain"); - } - - public static final String getMimeTypeFromExtension(String extension) { - String m = mimes.get(extension); - if (m != null) - return m; - return "application/octet-steam"; - } - - protected abstract InputStream getFileInput(String path, HttpRequest request, HttpResponse response); - - protected void closeFileInput(String path, InputStream in) throws IOException { - in.close(); - } - - protected String getExtension(String path) { - int dotindex = path.lastIndexOf('.'); - if (dotindex > 0) - return path.substring(dotindex); - return null; - } - - protected final String formatPath(String path) { - int qmark = path.indexOf('?'); - if (qmark >= 0) - path = path.substring(0, qmark); - - if (path.startsWith("/") || path.startsWith(".")) - return null; - if (path.length() == 0) - path = getDefaultFilename(path); - return path; - } - - protected String getDefaultFilename(String path) { - return path + "index.html"; - } - - private byte[] allocateReadBuffer() { - byte[] buf; - synchronized(lock) { - buf = bufferpool.poll(); - } - if(buf == null) { - buf = new byte[40960]; - } - return buf; - } - - private void freeReadBuffer(byte[] buf) { - synchronized(lock) { - if(bufferpool.size() < MAX_FREE_IN_POOL) - bufferpool.push(buf); - } - } - - @Override - public void handle(String path, HttpRequest request, HttpResponse response) throws Exception { - InputStream fileInput = null; - try { - path = formatPath(path); - fileInput = getFileInput(path, request, response); - if (fileInput == null) { - response.status = HttpStatus.NotFound; - return; - } - - String extension = getExtension(path); - String mimeType = getMimeTypeFromExtension(extension); - - response.fields.put(HttpField.ContentType, mimeType); - response.status = HttpStatus.OK; - OutputStream out = response.getBody(); - byte[] readBuffer = allocateReadBuffer(); - try { - int readBytes; - while ((readBytes = fileInput.read(readBuffer)) > 0) { - out.write(readBuffer, 0, readBytes); - } - } finally { - freeReadBuffer(readBuffer); - } - } finally { - if (fileInput != null) { - try { closeFileInput(path, fileInput); fileInput = null; } catch (IOException ex) { } - } - } - } -} diff --git a/src/main/java/org/dynmap/web/handlers/FilesystemHandler.java b/src/main/java/org/dynmap/web/handlers/FilesystemHandler.java deleted file mode 100644 index beb27125..00000000 --- a/src/main/java/org/dynmap/web/handlers/FilesystemHandler.java +++ /dev/null @@ -1,94 +0,0 @@ -package org.dynmap.web.handlers; - -import java.io.File; -import java.io.FileInputStream; -import java.io.FileNotFoundException; -import java.io.IOException; -import java.io.InputStream; - -import org.dynmap.Log; -import org.dynmap.utils.FileLockManager; -import org.dynmap.web.HttpField; -import org.dynmap.web.HttpRequest; -import org.dynmap.web.HttpResponse; - - -public class FilesystemHandler extends FileHandler { - private File root; - private boolean allow_symlinks = false; - private String root_path; - public FilesystemHandler(File root, boolean allow_symlinks) { - if (!root.isDirectory()) - throw new IllegalArgumentException(); - this.root = root; - this.allow_symlinks = allow_symlinks; - this.root_path = root.getAbsolutePath(); - } - @Override - protected InputStream getFileInput(String path, HttpRequest request, HttpResponse response) { - if(path == null) return null; - path = getNormalizedPath(path); /* Resolve out relative stuff - nothing allowed above webroot */ - File file = new File(root, path); - if(!file.isFile()) - return null; - if(!FileLockManager.getReadLock(file, 5000)) { /* Wait up to 5 seconds for lock */ - Log.severe("Timeout waiting for lock on file " + file.getPath()); - return null; - } - FileInputStream result = null; - try { - String fpath; - if(allow_symlinks) - fpath = file.getAbsolutePath(); - else - fpath = file.getCanonicalPath(); - if (fpath.startsWith(root_path)) { - try { - result = new FileInputStream(file); - } catch (FileNotFoundException e) { - return null; - } - response.fields.put(HttpField.ContentLength, Long.toString(file.length())); - return result; - } - } catch(IOException ex) { - Log.severe("Unable to get canoical path of requested file.", ex); - } finally { - if(result == null) FileLockManager.releaseReadLock(file); - } - return null; - } - protected void closeFileInput(String path, InputStream in) throws IOException { - path = getNormalizedPath(path); - try { - super.closeFileInput(path, in); - } finally { - File file = new File(root, path); - FileLockManager.releaseReadLock(file); - } - } - public static String getNormalizedPath(String p) { - p = p.replace('\\', '/'); - String[] tok = p.split("/"); - int i, j; - for(i = 0, j = 0; i < tok.length; i++) { - if((tok[i] == null) || (tok[i].length() == 0) || (tok[i].equals("."))) { - tok[i] = null; - } - else if(tok[i].equals("..")) { - if(j > 0) { j--; tok[j] = null; } - tok[i] = null; - } - else { - tok[j] = tok[i]; - j++; - } - } - String path = ""; - for(i = 0; i < j; i++) { - if(tok[i] != null) - path = path + "/" + tok[i]; - } - return path; - } -} diff --git a/src/main/java/org/dynmap/web/handlers/JarFileHandler.java b/src/main/java/org/dynmap/web/handlers/JarFileHandler.java deleted file mode 100644 index c822fa59..00000000 --- a/src/main/java/org/dynmap/web/handlers/JarFileHandler.java +++ /dev/null @@ -1,19 +0,0 @@ -package org.dynmap.web.handlers; - -import java.io.InputStream; - -import org.dynmap.web.HttpRequest; -import org.dynmap.web.HttpResponse; - - -public class JarFileHandler extends FileHandler { - private String root; - public JarFileHandler(String root) { - if (root.endsWith("/")) root = root.substring(0, root.length()-1); - this.root = root; - } - @Override - protected InputStream getFileInput(String path, HttpRequest request, HttpResponse response) { - return this.getClass().getResourceAsStream(root + "/" + path); - } -}