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}