001package jmri.web.server; 002 003import java.util.ArrayList; 004import java.util.HashMap; 005import java.util.List; 006import java.util.ServiceLoader; 007 008import javax.annotation.Nonnull; 009import javax.servlet.annotation.WebServlet; 010import javax.servlet.http.HttpServlet; 011 012import jmri.InstanceManager; 013import jmri.ShutDownManager; 014import jmri.server.json.JSON; 015import jmri.server.web.spi.WebServerConfiguration; 016import jmri.util.FileUtil; 017import jmri.util.zeroconf.ZeroConfService; 018import jmri.web.servlet.DenialServlet; 019import jmri.web.servlet.RedirectionServlet; 020import jmri.web.servlet.directory.DirectoryHandler; 021 022import org.eclipse.jetty.server.*; 023import org.eclipse.jetty.server.handler.*; 024import org.eclipse.jetty.servlet.ServletContextHandler; 025import org.eclipse.jetty.servlet.ServletHolder; 026import org.eclipse.jetty.util.component.LifeCycle; 027import org.eclipse.jetty.util.thread.QueuedThreadPool; 028import org.slf4j.Logger; 029import org.slf4j.LoggerFactory; 030 031/** 032 * An HTTP server that handles requests for HTTPServlets. 033 * <p> 034 * This server loads HttpServlets registered as 035 * {@link javax.servlet.http.HttpServlet} service providers and annotated with 036 * the {@link javax.servlet.annotation.WebServlet} annotation. It also loads the 037 * registered {@link jmri.server.web.spi.WebServerConfiguration} objects to get 038 * configuration for file handling, redirection, and denial of access to 039 * resources. 040 * <p> 041 * When there is a conflict over how a path should be handled, denials take 042 * precedence, followed by servlets, redirections, and lastly direct access to 043 * files. 044 * 045 * @author Bob Jacobsen Copyright 2005, 2006 046 * @author Randall Wood Copyright 2012, 2016 047 */ 048public final class WebServer implements LifeCycle, LifeCycle.Listener { 049 050 private enum Registration { 051 DENIAL, REDIRECTION, RESOURCE, SERVLET 052 } 053 private final Server server; 054 private ZeroConfService zeroConfService = null; 055 private WebServerPreferences preferences = null; 056 private Runnable shutDownTask = null; 057 private final HashMap<String, Registration> registeredUrls = new HashMap<>(); 058 private static final Logger log = LoggerFactory.getLogger(WebServer.class); 059 060 /** 061 * Create a WebServer instance with the default preferences. 062 */ 063 public WebServer() { 064 this(InstanceManager.getDefault(WebServerPreferences.class)); 065 } 066 067 /** 068 * Create a WebServer instance with the specified preferences. 069 * 070 * @param preferences the preferences 071 */ 072 public WebServer(WebServerPreferences preferences) { 073 QueuedThreadPool threadPool = new QueuedThreadPool(); 074 threadPool.setName("WebServer"); 075 threadPool.setMaxThreads(1000); 076 server = new Server(threadPool); 077 this.preferences = preferences; 078 } 079 080 /** 081 * Get the default web server instance. 082 * 083 * @return a WebServer instance, either the existing instance or a new 084 * instance created with the default constructor. 085 */ 086 @Nonnull 087 public static WebServer getDefault() { 088 return InstanceManager.getOptionalDefault(WebServer.class) 089 .orElseGet(() -> InstanceManager.setDefault(WebServer.class, new WebServer())); 090 } 091 092 /** 093 * Start the web server. 094 */ 095 @Override 096 public void start() { 097 if (!server.isRunning()) { 098 try (ServerConnector connector = new ServerConnector(server)) { 099 connector.setIdleTimeout(30000); // 5 minutes 100 connector.setPort(preferences.getPort()); 101 server.setConnectors(new Connector[]{connector}); 102 server.setHandler(new ContextHandlerCollection()); 103 104 // Load all path handlers 105 ServiceLoader.load(WebServerConfiguration.class).forEach(configuration -> { 106 configuration.getFilePaths().entrySet() 107 .forEach(resource -> this.registerResource(resource.getKey(), resource.getValue())); 108 configuration.getRedirectedPaths().entrySet() 109 .forEach(redirection -> this.registerRedirection(redirection.getKey(), redirection.getValue())); 110 configuration.getForbiddenPaths().forEach(this::registerDenial); 111 }); 112 // Load all classes that provide the HttpServlet service. 113 ServiceLoader.load(HttpServlet.class) 114 .forEach(servlet -> registerServlet(servlet.getClass(), servlet)); 115 server.addLifeCycleListener(this); 116 117 Thread serverThread = new ServerThread(server); 118 serverThread.setName("WebServer"); // NOI18N 119 serverThread.start(); 120 } 121 } 122 } 123 124 /** 125 * Stop the server. 126 * 127 * @throws Exception if there is an error stopping the server; defined by 128 * Jetty superclass 129 */ 130 @Override 131 public void stop() throws Exception { 132 server.stop(); 133 } 134 135 /** 136 * Get the public URI for a portable path. This method returns public URIs 137 * for only some portable paths, and does not check that the portable path 138 * is actually sane. Note that this refuses to return portable paths that 139 * are outside of {@link jmri.util.FileUtil#PREFERENCES}, 140 * {@link jmri.util.FileUtil#PROFILE}, 141 * {@link jmri.util.FileUtil#SETTINGS}, or 142 * {@link jmri.util.FileUtil#PROGRAM}. 143 * 144 * @param path the JMRI portable path 145 * @return The servable URI or null 146 * @see jmri.util.FileUtil#getPortableFilename(java.io.File) 147 */ 148 public static String portablePathToURI(String path) { 149 if (path.startsWith(FileUtil.PREFERENCES)) { 150 return path.replaceFirst(FileUtil.PREFERENCES, "/prefs/"); // NOI18N 151 } else if (path.startsWith(FileUtil.PROFILE)) { 152 return path.replaceFirst(FileUtil.PROFILE, "/project/"); // NOI18N 153 } else if (path.startsWith(FileUtil.SETTINGS)) { 154 return path.replaceFirst(FileUtil.SETTINGS, "/settings/"); // NOI18N 155 } else if (path.startsWith(FileUtil.PROGRAM)) { 156 return path.replaceFirst(FileUtil.PROGRAM, "/dist/"); // NOI18N 157 } else { 158 return null; 159 } 160 } 161 162 public int getPort() { 163 return preferences.getPort(); 164 } 165 166 public WebServerPreferences getPreferences() { 167 return preferences; 168 } 169 170 /** 171 * Register a URL pattern to be denied access. 172 * 173 * @param urlPattern the pattern to deny access to 174 */ 175 public void registerDenial(String urlPattern) { 176 this.registeredUrls.put(urlPattern, Registration.DENIAL); 177 ServletContextHandler servletContext = new ServletContextHandler(ServletContextHandler.NO_SECURITY); 178 servletContext.setContextPath(urlPattern); 179 DenialServlet servlet = new DenialServlet(); 180 servletContext.addServlet(new ServletHolder(servlet), "/*"); // NOI18N 181 ((HandlerCollection) this.server.getHandler()).addHandler(servletContext); 182 } 183 184 /** 185 * Register a URL pattern to return resources from the file system. The 186 * filePath may start with any of the following: 187 * <ol> 188 * <li>{@link jmri.util.FileUtil#PREFERENCES} 189 * <li>{@link jmri.util.FileUtil#PROFILE} 190 * <li>{@link jmri.util.FileUtil#SETTINGS} 191 * <li>{@link jmri.util.FileUtil#PROGRAM} 192 * </ol> 193 * Note that the filePath can be overridden by an otherwise identical 194 * filePath starting with any of the portable paths above it in the 195 * preceding list. 196 * 197 * @param urlPattern the pattern to get resources for 198 * @param filePath the portable path for the resources 199 * @throws IllegalArgumentException if urlPattern is already registered to 200 * deny access or for a servlet or if 201 * filePath is not allowed 202 */ 203 public void registerResource(String urlPattern, String filePath) { 204 String debugMsg = "Setting up handler chain for {}"; 205 if (this.registeredUrls.get(urlPattern) != null) { 206 throw new IllegalArgumentException("urlPattern \"" + urlPattern + "\" is already registered."); 207 } 208 this.registeredUrls.put(urlPattern, Registration.RESOURCE); 209 ServletContextHandler servletContext = new ServletContextHandler(ServletContextHandler.NO_SECURITY); 210 servletContext.setContextPath(urlPattern); 211 HandlerList handlers = new HandlerList(); 212 if (filePath.startsWith(FileUtil.PROGRAM) && !filePath.equals(FileUtil.PROGRAM)) { 213 // make it possible to override anything under program: with an identical path under preference:, profile:, or settings: 214 log.debug(debugMsg, urlPattern); 215 ResourceHandler preferenceHandler = new DirectoryHandler(FileUtil.getAbsoluteFilename(filePath.replace(FileUtil.PROGRAM, FileUtil.PREFERENCES))); 216 ResourceHandler projectHandler = new DirectoryHandler(FileUtil.getAbsoluteFilename(filePath.replace(FileUtil.PROGRAM, FileUtil.PROFILE))); 217 ResourceHandler settingsHandler = new DirectoryHandler(FileUtil.getAbsoluteFilename(filePath.replace(FileUtil.PROGRAM, FileUtil.SETTINGS))); 218 ResourceHandler programHandler = new DirectoryHandler(FileUtil.getAbsoluteFilename(filePath)); 219 handlers.setHandlers(new Handler[]{preferenceHandler, projectHandler, settingsHandler, programHandler, new DefaultHandler()}); 220 } else if (filePath.startsWith(FileUtil.SETTINGS) && !filePath.equals(FileUtil.SETTINGS)) { 221 // make it possible to override anything under settings: with an identical path under preference: or profile: 222 log.debug(debugMsg, urlPattern); 223 ResourceHandler preferenceHandler = new DirectoryHandler(FileUtil.getAbsoluteFilename(filePath.replace(FileUtil.SETTINGS, FileUtil.PREFERENCES))); 224 ResourceHandler projectHandler = new DirectoryHandler(FileUtil.getAbsoluteFilename(filePath.replace(FileUtil.PROGRAM, FileUtil.PROFILE))); 225 ResourceHandler settingsHandler = new DirectoryHandler(FileUtil.getAbsoluteFilename(filePath)); 226 handlers.setHandlers(new Handler[]{preferenceHandler, projectHandler, settingsHandler, new DefaultHandler()}); 227 } else if (filePath.startsWith(FileUtil.PROFILE) && !filePath.equals(FileUtil.PROFILE)) { 228 // make it possible to override anything under profile: with an identical path under preference: 229 log.debug(debugMsg, urlPattern); 230 ResourceHandler preferenceHandler = new DirectoryHandler(FileUtil.getAbsoluteFilename(filePath.replace(FileUtil.SETTINGS, FileUtil.PREFERENCES))); 231 ResourceHandler projectHandler = new DirectoryHandler(FileUtil.getAbsoluteFilename(filePath.replace(FileUtil.PROGRAM, FileUtil.PROFILE))); 232 handlers.setHandlers(new Handler[]{preferenceHandler, projectHandler, new DefaultHandler()}); 233 } else if (FileUtil.isPortableFilename(filePath)) { 234 log.debug(debugMsg, urlPattern); 235 ResourceHandler handler = new DirectoryHandler(FileUtil.getAbsoluteFilename(filePath)); 236 handlers.setHandlers(new Handler[]{handler, new DefaultHandler()}); 237 } else if (portablePathToURI(filePath) == null) { 238 throw new IllegalArgumentException("\"" + filePath + "\" is not allowed."); 239 } 240 ContextHandler handlerContext = new ContextHandler(); 241 handlerContext.setContextPath(urlPattern); 242 handlerContext.setHandler(handlers); 243 ((HandlerCollection) this.server.getHandler()).addHandler(handlerContext); 244 } 245 246 /** 247 * Register a URL pattern to be redirected to another resource. 248 * 249 * @param urlPattern the pattern to be redirected 250 * @param redirection the path to which the pattern is redirected 251 * @throws IllegalArgumentException if urlPattern is already registered for 252 * any other purpose 253 */ 254 public void registerRedirection(String urlPattern, String redirection) { 255 Registration registered = this.registeredUrls.get(urlPattern); 256 if (registered != null && registered != Registration.REDIRECTION) { 257 throw new IllegalArgumentException("\"" + urlPattern + "\" registered to " + registered); 258 } 259 this.registeredUrls.put(urlPattern, Registration.REDIRECTION); 260 ServletContextHandler servletContext = new ServletContextHandler(ServletContextHandler.NO_SECURITY); 261 servletContext.setContextPath(urlPattern); 262 RedirectionServlet servlet = new RedirectionServlet(urlPattern, redirection); 263 servletContext.addServlet(new ServletHolder(servlet), ""); // NOI18N 264 ((HandlerCollection) this.server.getHandler()).addHandler(servletContext); 265 } 266 267 /** 268 * Register a {@link javax.servlet.http.HttpServlet } that is annotated with 269 * the {@link javax.servlet.annotation.WebServlet } annotation. 270 * <p> 271 * This method calls 272 * {@link #registerServlet(java.lang.Class, javax.servlet.http.HttpServlet)} 273 * with a null HttpServlet. 274 * 275 * @param type The actual class of the servlet. 276 */ 277 public void registerServlet(Class<? extends HttpServlet> type) { 278 this.registerServlet(type, null); 279 } 280 281 /** 282 * Register a {@link javax.servlet.http.HttpServlet } that is annotated with 283 * the {@link javax.servlet.annotation.WebServlet } annotation. 284 * <p> 285 * Registration reads the WebServlet annotation to get the list of paths the 286 * servlet should handle and creates instances of the Servlet to handle each 287 * path. 288 * <p> 289 * Note that all HttpServlets registered using this mechanism must have a 290 * default constructor. 291 * 292 * @param type The actual class of the servlet. 293 * @param instance An un-initialized, un-registered instance of the servlet. 294 */ 295 public void registerServlet(Class<? extends HttpServlet> type, HttpServlet instance) { 296 this.registerServlet(ServletContextHandler.NO_SECURITY, type, instance) 297 .forEach(((HandlerCollection) this.server.getHandler())::addHandler); 298 } 299 300 private List<ServletContextHandler> registerServlet(int options, Class<? extends HttpServlet> type, HttpServlet instance) { 301 WebServlet info = type.getAnnotation(WebServlet.class); 302 List<ServletContextHandler> handlers = new ArrayList<>(info.urlPatterns().length); 303 for (String pattern : info.urlPatterns()) { 304 if (this.registeredUrls.get(pattern) != Registration.DENIAL) { 305 // DenialServlet gets special handling 306 if (info.name().equals("DenialServlet")) { // NOI18N 307 this.registeredUrls.put(pattern, Registration.DENIAL); 308 } else { 309 this.registeredUrls.put(pattern, Registration.SERVLET); 310 } 311 ServletContextHandler context = new ServletContextHandler(options); 312 context.setContextPath(pattern); 313 log.debug("Creating new {} for URL pattern {}", type.getName(), pattern); 314 context.addServlet(type, "/*"); // NOI18N 315 handlers.add(context); 316 } else { 317 log.error("Unable to register servlet \"{}\" to provide denied URL {}", info.name(), pattern); 318 } 319 } 320 return handlers; 321 } 322 323 @Override 324 public void lifeCycleStarting(LifeCycle lc) { 325 shutDownTask = () -> { 326 try { 327 server.stop(); 328 } catch (Exception ex) { 329 // Error without stack trace 330 log.warn("Error shutting down WebServer", ex); 331 // Full stack trace 332 log.debug("Details follow: ", ex); 333 } 334 }; 335 InstanceManager.getDefault(ShutDownManager.class).register(shutDownTask); 336 log.info("Starting Web Server on port {}", preferences.getPort()); 337 } 338 339 @Override 340 public void lifeCycleStarted(LifeCycle lc) { 341 if (this.preferences.isUseZeroConf()) { 342 HashMap<String, String> properties = new HashMap<>(); 343 properties.put("path", "/"); // NOI18N 344 properties.put(JSON.JSON, JSON.JSON_PROTOCOL_VERSION); 345 log.info("Starting ZeroConfService _http._tcp.local for Web Server with properties {}", properties); 346 zeroConfService = ZeroConfService.create("_http._tcp.local.", preferences.getPort(), properties); // NOI18N 347 zeroConfService.publish(); 348 } 349 log.debug("Web Server finished starting"); 350 } 351 352 @Override 353 public void lifeCycleFailure(LifeCycle lc, Throwable thrwbl) { 354 if (zeroConfService != null) { 355 zeroConfService.stop(); 356 } 357 log.error("Web Server failed", thrwbl); 358 } 359 360 @Override 361 public void lifeCycleStopping(LifeCycle lc) { 362 if (zeroConfService != null) { 363 zeroConfService.stop(); 364 } 365 log.info("Stopping Web Server"); 366 } 367 368 @Override 369 public void lifeCycleStopped(LifeCycle lc) { 370 if (zeroConfService != null) { 371 zeroConfService.stop(); 372 } 373 InstanceManager.getDefault(ShutDownManager.class).deregister(shutDownTask); 374 log.debug("Web Server stopped"); 375 } 376 377 @Override 378 public boolean isRunning() { 379 return this.server.isRunning(); 380 } 381 382 @Override 383 public boolean isStarted() { 384 return this.server.isStarted(); 385 } 386 387 @Override 388 public boolean isStarting() { 389 return this.server.isStarting(); 390 } 391 392 @Override 393 public boolean isStopping() { 394 return this.server.isStopping(); 395 } 396 397 @Override 398 public boolean isStopped() { 399 return this.server.isStopped(); 400 } 401 402 @Override 403 public boolean isFailed() { 404 return this.server.isFailed(); 405 } 406 407 @Override 408 public void addLifeCycleListener(Listener ll) { 409 this.server.addLifeCycleListener(ll); 410 } 411 412 @Override 413 public void removeLifeCycleListener(Listener ll) { 414 this.server.removeLifeCycleListener(ll); 415 } 416 417 private static class ServerThread extends Thread { 418 419 private final Server server; 420 421 public ServerThread(Server server) { 422 this.server = server; 423 } 424 425 @Override 426 public void run() { 427 try { 428 server.start(); 429 server.join(); 430 } catch (Exception ex) { 431 log.error("Exception starting Web Server", ex); 432 } 433 } 434 } 435}