eu.xworlds.nukkit.web.tasks.WebserverTask.java Source code

Java tutorial

Introduction

Here is the source code for eu.xworlds.nukkit.web.tasks.WebserverTask.java

Source

/*
This file is part of "nukkit xWorlds plugin".
    
"nukkit xWorlds plugin" is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
    
"nukkit xWorlds plugin" 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 General Public License for more details.
    
You should have received a copy of the GNU General Public License
along with "nukkit xWorlds plugin". If not, see <http://www.gnu.org/licenses/>.
    
 */
package eu.xworlds.nukkit.web.tasks;

import java.io.PrintWriter;
import java.io.StringWriter;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

import cn.nukkit.scheduler.PluginTask;
import cn.nukkit.utils.Config;
import cn.nukkit.utils.TextFormat;
import eu.xworlds.nukkit.web.WebserverPlugin;
import io.netty.bootstrap.ServerBootstrap;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.channel.Channel;
import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelFutureListener;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelPipeline;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.handler.codec.http.DefaultFullHttpResponse;
import io.netty.handler.codec.http.FullHttpResponse;
import io.netty.handler.codec.http.HttpContent;
import io.netty.handler.codec.http.HttpContentCompressor;
import io.netty.handler.codec.http.HttpHeaders;
import io.netty.handler.codec.http.HttpHeaders.Names;
import io.netty.handler.codec.http.HttpMethod;
import io.netty.handler.codec.http.HttpObject;
import io.netty.handler.codec.http.HttpObjectAggregator;
import io.netty.handler.codec.http.HttpRequest;
import io.netty.handler.codec.http.HttpRequestDecoder;
import io.netty.handler.codec.http.HttpResponseEncoder;
import io.netty.handler.codec.http.HttpResponseStatus;
import io.netty.handler.codec.http.HttpVersion;
import io.netty.handler.codec.http.LastHttpContent;
import io.netty.handler.codec.http.QueryStringDecoder;
import io.netty.handler.ssl.SslContext;
import io.netty.util.concurrent.GenericFutureListener;

/**
 * A task to manage the built in web server.
 * 
 * @author mepeisen
 */
public class WebserverTask extends PluginTask<WebserverPlugin> {

    public static final String CONFIG_KEY_ENABLED = "webserver-enabled";
    public static final String CONFIG_KEY_PORT = "webserver-port";
    public static final String CONFIG_KEY_MAINTENANCE = "webserver-maintenance";

    private boolean startupIssued;

    private boolean stopIssued;

    private boolean maintenance;

    private int port;

    public enum ServerState {
        STOPPED, STOPPING, STARTING, RUNNING, MAINTENANCE
    }

    private ServerState currentState = ServerState.STOPPED;

    private int currentPort = -1;

    private Map<String, WebpageHandlerFactory> factories = new ConcurrentHashMap<>();

    public WebserverTask(WebserverPlugin owner, Config config) {
        super(owner);
        if (config.getBoolean(CONFIG_KEY_MAINTENANCE)) {
            this.maintenance = true;
        }
        if (config.getBoolean(CONFIG_KEY_ENABLED)) {
            this.startupIssued = true;
        }
        this.port = config.getInt(CONFIG_KEY_PORT);
    }

    /**
     * @param name
     * @param factory
     */
    public void registerFactory(String name, WebpageHandlerFactory factory) {
        this.factories.put(name, factory);
    }

    /**
     * @param name
     */
    public void unregisterFactory(String name) {
        this.factories.remove(name);
    }

    @Override
    public void onRun(int currentTick) {
        if (this.startupIssued) {
            if (this.stopIssued) {
                // issued both commands since last run
                this.startupIssued = false;
                this.stopIssued = false;
                this.owner.getLogger().warning(
                        TextFormat.RED + "Webserver got start and stop command. Ignoring start/stop commands.");
            } else {
                // start the server asynchronous
                switch (this.currentState) {
                case STOPPED:
                    this.startupIssued = false;
                    this.currentState = ServerState.STARTING;
                    this.owner.getLogger().info(TextFormat.DARK_GREEN + "Perform web server start.");
                    this.startAsync();
                    break;
                case MAINTENANCE:
                    this.startupIssued = false;
                    this.owner.getLogger().warning(TextFormat.RED
                            + "Webserver already running but in maintenance mode. Ignoring start command.");
                    break;
                case RUNNING:
                    this.startupIssued = false;
                    this.owner.getLogger()
                            .warning(TextFormat.RED + "Webserver already running. Ignoring start command.");
                    break;
                case STARTING:
                    this.startupIssued = false;
                    this.owner.getLogger()
                            .warning(TextFormat.RED + "Webserver already starting. Ignoring start command.");
                    break;
                case STOPPING:
                    this.owner.getLogger().warning(
                            TextFormat.RED + "Webserver is currently stopping. Will try to start again later.");
                    break;
                }
            }
        } else if (this.stopIssued) {
            // stop the server asynchronous
            switch (this.currentState) {
            case STOPPED:
                this.stopIssued = false;
                this.owner.getLogger()
                        .warning(TextFormat.RED + "Webserver already stopped. Ignoring stop command.");
                this.currentState = ServerState.STARTING;
                break;
            case MAINTENANCE:
            case RUNNING:
                this.stopIssued = false;
                this.currentState = ServerState.STOPPING;
                this.owner.getLogger().info(TextFormat.DARK_GREEN + "Perform web server stop.");
                this.stopAsync();
                break;
            case STOPPING:
                this.stopIssued = false;
                this.owner.getLogger()
                        .warning(TextFormat.RED + "Webserver already stopping. Ignoring stop command.");
                break;
            case STARTING:
                this.owner.getLogger().warning(
                        TextFormat.RED + "Webserver is currently starting. Will try to start again later.");
                break;
            }
        }
    }

    /**
     * @return the currentState
     */
    public ServerState getCurrentState() {
        return this.currentState;
    }

    /**
     * @param startupIssued the startupIssued to set
     */
    public void setStartupIssued(boolean startupIssued) {
        this.startupIssued = startupIssued;
    }

    /**
     * @param stopIssued the stopIssued to set
     */
    public void setStopIssued(boolean stopIssued) {
        this.stopIssued = stopIssued;
    }

    /**
     * @param maintenance the maintenance to set
     */
    public void setMaintenance(boolean maintenance) {
        this.maintenance = maintenance;
    }

    /**
     * @return the port
     */
    public int getPort() {
        return this.port;
    }

    /**
     * @param port the port to set
     */
    public void setPort(int port) {
        this.port = port;
    }

    /**
     * @return the maintenance
     */
    public boolean isMaintenance() {
        return this.maintenance;
    }

    /**
     * Forces web server shutdown due to shutting down the whole server
     */
    public void shutdown() {
        final Channel ch = serverChannel;
        if (ch != null) {
            boolean success = false;
            try {
                success = ch.close().await(5000);
            } catch (InterruptedException e) {
                // silently ignore
            }
            if (!success) {
                getOwner().getLogger().error("Failed to stop webserver within time limit.");
            }
        }
    }

    /**
     * @return
     */
    public int getCurrentPort() {
        return this.currentPort;
    }

    /**
     * stops the web server async
     */
    private void stopAsync() {
        serverChannel.close();
    }

    private Channel serverChannel;
    private EventLoopGroup bossGroup;
    private EventLoopGroup workerGroup;

    /**
     * starts the web server async
     */
    private void startAsync() {
        bossGroup = new NioEventLoopGroup(1);
        workerGroup = new NioEventLoopGroup();
        final ServerBootstrap b = new ServerBootstrap();
        b.group(bossGroup, workerGroup).channel(NioServerSocketChannel.class)
                .childHandler(new NukkitNettyInitializer(null));
        this.currentPort = this.port;
        b.bind(this.port).addListener(new StartupListener());
    }

    private final class StartupListener implements GenericFutureListener<ChannelFuture> {

        @Override
        public void operationComplete(ChannelFuture future) throws Exception {
            if (future.isSuccess()) {
                serverChannel = future.channel();
                serverChannel.closeFuture().addListener(new ShutdownListener());
                currentState = maintenance ? ServerState.MAINTENANCE : ServerState.RUNNING;
            } else {
                WebserverTask.this.getOwner().getLogger().error("Unable to bind webserver. Starting failed.");
                currentState = ServerState.STOPPED;
            }
        }

    }

    private final class ShutdownListener implements GenericFutureListener<ChannelFuture> {

        @Override
        public void operationComplete(ChannelFuture future) throws Exception {
            bossGroup.shutdownGracefully();
            workerGroup.shutdownGracefully();
            bossGroup = null;
            workerGroup = null;
            serverChannel = null;
            currentState = ServerState.STOPPED;
        }

    }

    private final class RequestContext implements WebRequestContext {

        protected HttpHeaders headers;
        protected HttpHeaders trailingHeaders;
        protected HttpVersion protocolVersion;
        protected String uri;
        protected QueryStringDecoder queryStringDecoder;
        protected ByteBuf content;
        protected HttpMethod method;

        @Override
        public HttpHeaders getRequestHeaders() {
            return this.headers;
        }

        @Override
        public HttpHeaders getRequestTrailingHeaders() {
            return this.trailingHeaders;
        }

        @Override
        public HttpVersion getProtocolVersion() {
            return this.protocolVersion;
        }

        @Override
        public String getRequestUri() {
            return this.uri;
        }

        @Override
        public QueryStringDecoder getQueryString() {
            return this.queryStringDecoder;
        }

        @Override
        public ByteBuf getRequestContent() {
            return this.content;
        }

        @Override
        public HttpMethod getMethod() {
            return this.method;
        }

    }

    private final class NukkitNettyHandler extends SimpleChannelInboundHandler<Object> {

        private HttpRequest request;

        private final RequestContext rContext = new RequestContext();

        /**
         * @see io.netty.channel.SimpleChannelInboundHandler#channelRead0(io.netty.channel.ChannelHandlerContext, java.lang.Object)
         */
        @Override
        protected void channelRead0(ChannelHandlerContext ctx, Object msg) throws Exception {
            if (msg instanceof HttpRequest) {
                final HttpRequest request = this.request = (HttpRequest) msg;
                if (HttpHeaders.is100ContinueExpected(request)) {
                    send100Continue(ctx);
                }

                this.rContext.protocolVersion = request.getProtocolVersion();
                this.rContext.headers = request.headers();
                this.rContext.uri = request.getUri();
                this.rContext.headers = request.headers();
                this.rContext.queryStringDecoder = new QueryStringDecoder(request.getUri());
                this.rContext.method = request.getMethod();
            }

            if (msg instanceof HttpContent) {
                HttpContent httpContent = (HttpContent) msg;

                this.rContext.content = httpContent.content();

                if (msg instanceof LastHttpContent) {
                    LastHttpContent trailer = (LastHttpContent) msg;
                    this.rContext.trailingHeaders = trailer.trailingHeaders();

                    if (!writeResponse(trailer, ctx)) {
                        // If keep-alive is off, close the connection once the content is fully written.
                        ctx.writeAndFlush(Unpooled.EMPTY_BUFFER).addListener(ChannelFutureListener.CLOSE);
                    }
                }
            }

        }

        private WebpageHandler getHandler() {
            if (currentState != ServerState.RUNNING) {
                return WebserverPlugin.MAINTENANCE;
            }
            final String path = rContext.getQueryString().path();
            final String[] splitted = path.split("/");
            if (splitted == null || splitted.length == 0) {
                return WebserverPlugin.INDEX;
            }
            final String plugin = splitted[1];
            final WebpageHandlerFactory factory = factories.get(plugin);
            if (factory != null) {
                final WebpageHandler result = factory.requestHandler(rContext, getOwner().getServer(), splitted);
                if (result != null) {
                    return result;
                }
            }
            return WebserverPlugin._404;
        }

        private boolean writeResponse(HttpObject currentObj, ChannelHandlerContext ctx) {
            // Decide whether to close the connection or not.
            boolean keepAlive = HttpHeaders.isKeepAlive(request);

            // TODO set keepalive to false because browsers seem not to like it ?!?!?
            //      there maybe some bug elsewhere
            keepAlive = false;

            // Build the response object.
            // TODO For better load management perform handleRequest in a separate worker group
            //      Dont do that in http encoding/decoding worker group
            FullHttpResponse response = null;
            try {
                response = getHandler().handleRequest(this.rContext, getOwner().getServer());
            } catch (RuntimeException ex) {
                response = WebserverPlugin._500.handleRequest(this.rContext, getOwner().getServer());
            }

            if (keepAlive) {
                // Add 'Content-Length' header only for a keep-alive connection.
                response.headers().set(Names.CONTENT_LENGTH, response.content().readableBytes());
                // Add keep alive header as per:
                // - http://www.w3.org/Protocols/HTTP/1.1/draft-ietf-http-v11-spec-01.html#Connection
                response.headers().set(Names.CONNECTION, HttpHeaders.Values.KEEP_ALIVE);
            } else {
                response.headers().set(Names.CONNECTION, HttpHeaders.Values.CLOSE);
            }

            ctx.write(response);
            return keepAlive;
        }

        private void send100Continue(ChannelHandlerContext ctx) {
            FullHttpResponse response = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1,
                    HttpResponseStatus.CONTINUE);
            ctx.write(response);
        }

        /**
         * @see io.netty.channel.ChannelInboundHandlerAdapter#channelReadComplete(io.netty.channel.ChannelHandlerContext)
         */
        @Override
        public void channelReadComplete(ChannelHandlerContext ctx) throws Exception {
            ctx.flush();
        }

        /**
         * @see io.netty.channel.ChannelInboundHandlerAdapter#exceptionCaught(io.netty.channel.ChannelHandlerContext, java.lang.Throwable)
         */
        @Override
        public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
            final StringWriter writer = new StringWriter();
            cause.printStackTrace(new PrintWriter(writer));
            WebserverTask.this.owner.getLogger()
                    .error("Exception within webserver: " + cause.getMessage() + "\n" + writer.toString());
            ctx.close();
        }

    }

    private final class NukkitNettyInitializer extends ChannelInitializer<SocketChannel> {

        private final SslContext sslCtx;

        public NukkitNettyInitializer(SslContext sslCtx) {
            this.sslCtx = sslCtx;
        }

        /**
         * @see io.netty.channel.ChannelInitializer#initChannel(io.netty.channel.Channel)
         */
        @Override
        protected void initChannel(SocketChannel ch) throws Exception {
            final ChannelPipeline p = ch.pipeline();

            if (this.sslCtx != null)
                p.addLast(sslCtx.newHandler(ch.alloc()));

            p.addLast(new HttpRequestDecoder());
            p.addLast(new HttpObjectAggregator(1048576)); // http chunks
            p.addLast(new HttpResponseEncoder());
            p.addLast(new HttpContentCompressor()); // automatic compression
            p.addLast(new NukkitNettyHandler());
        }

    }

}