From 7e9289a754e1829ebb04efcce0a4a2d52a22b122 Mon Sep 17 00:00:00 2001 From: Kristóf Marussy Date: Mon, 11 Oct 2021 20:53:54 +0200 Subject: feat(web): add websocket server --- .../refinery/language/web/CacheControlFilter.java | 21 ++- .../refinery/language/web/ProblemServlet.java | 36 ---- .../language/web/ProblemWebSocketServlet.java | 29 +++ .../refinery/language/web/ServerLauncher.java | 69 +++++++- .../language/web/xtext/HttpServiceContext.java | 107 ----------- .../language/web/xtext/HttpSessionWrapper.java | 53 ------ .../language/web/xtext/SimpleServiceContext.java | 27 +++ .../refinery/language/web/xtext/SimpleSession.java | 35 ++++ .../refinery/language/web/xtext/XtextServlet.java | 196 --------------------- .../language/web/xtext/XtextStatusCode.java | 9 + .../language/web/xtext/XtextWebSocket.java | 137 ++++++++++++++ .../web/xtext/XtextWebSocketErrorResponse.java | 52 ++++++ .../web/xtext/XtextWebSocketOkResponse.java | 52 ++++++ .../language/web/xtext/XtextWebSocketRequest.java | 51 ++++++ .../language/web/xtext/XtextWebSocketResponse.java | 7 + .../language/web/xtext/XtextWebSocketServlet.java | 75 ++++++++ 16 files changed, 545 insertions(+), 411 deletions(-) delete mode 100644 language-web/src/main/java/tools/refinery/language/web/ProblemServlet.java create mode 100644 language-web/src/main/java/tools/refinery/language/web/ProblemWebSocketServlet.java delete mode 100644 language-web/src/main/java/tools/refinery/language/web/xtext/HttpServiceContext.java delete mode 100644 language-web/src/main/java/tools/refinery/language/web/xtext/HttpSessionWrapper.java create mode 100644 language-web/src/main/java/tools/refinery/language/web/xtext/SimpleServiceContext.java create mode 100644 language-web/src/main/java/tools/refinery/language/web/xtext/SimpleSession.java delete mode 100644 language-web/src/main/java/tools/refinery/language/web/xtext/XtextServlet.java create mode 100644 language-web/src/main/java/tools/refinery/language/web/xtext/XtextStatusCode.java create mode 100644 language-web/src/main/java/tools/refinery/language/web/xtext/XtextWebSocket.java create mode 100644 language-web/src/main/java/tools/refinery/language/web/xtext/XtextWebSocketErrorResponse.java create mode 100644 language-web/src/main/java/tools/refinery/language/web/xtext/XtextWebSocketOkResponse.java create mode 100644 language-web/src/main/java/tools/refinery/language/web/xtext/XtextWebSocketRequest.java create mode 100644 language-web/src/main/java/tools/refinery/language/web/xtext/XtextWebSocketResponse.java create mode 100644 language-web/src/main/java/tools/refinery/language/web/xtext/XtextWebSocketServlet.java (limited to 'language-web/src') diff --git a/language-web/src/main/java/tools/refinery/language/web/CacheControlFilter.java b/language-web/src/main/java/tools/refinery/language/web/CacheControlFilter.java index cf4c00fa..b13ae95d 100644 --- a/language-web/src/main/java/tools/refinery/language/web/CacheControlFilter.java +++ b/language-web/src/main/java/tools/refinery/language/web/CacheControlFilter.java @@ -1,8 +1,11 @@ package tools.refinery.language.web; import java.io.IOException; +import java.time.Duration; import java.util.regex.Pattern; +import org.eclipse.jetty.http.HttpHeader; + import jakarta.servlet.Filter; import jakarta.servlet.FilterChain; import jakarta.servlet.FilterConfig; @@ -13,16 +16,11 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; public class CacheControlFilter implements Filter { - - private static final String CACHE_CONTROL_HEADER = "Cache-Control"; - - private static final String EXPIRES_HEADER = "Expires"; - private static final Pattern CACHE_URI_PATTERN = Pattern.compile(".*\\.(css|gif|js|map|png|svg|woff2)"); - private static final long EXPIRY = 31536000; + private static final Duration EXPIRY = Duration.ofDays(365); - private static final String CACHE_CONTROL_CACHE_VALUE = "public, max-age: " + EXPIRY + ", immutable"; + private static final String CACHE_CONTROL_CACHE_VALUE = "public, max-age: " + EXPIRY.toSeconds() + ", immutable"; private static final String CACHE_CONTROL_NO_CACHE_VALUE = "no-cache, no-store, max-age: 0, must-revalidate"; @@ -36,11 +34,12 @@ public class CacheControlFilter implements Filter { throws IOException, ServletException { if (request instanceof HttpServletRequest httpRequest && response instanceof HttpServletResponse httpResponse) { if (CACHE_URI_PATTERN.matcher(httpRequest.getRequestURI()).matches()) { - httpResponse.setHeader(CACHE_CONTROL_HEADER, CACHE_CONTROL_CACHE_VALUE); - httpResponse.setDateHeader(EXPIRES_HEADER, System.currentTimeMillis() + EXPIRY * 1000L); + httpResponse.setHeader(HttpHeader.CACHE_CONTROL.asString(), CACHE_CONTROL_CACHE_VALUE); + httpResponse.setDateHeader(HttpHeader.EXPIRES.asString(), + System.currentTimeMillis() + EXPIRY.toMillis()); } else { - httpResponse.setHeader(CACHE_CONTROL_HEADER, CACHE_CONTROL_NO_CACHE_VALUE); - httpResponse.setDateHeader(EXPIRES_HEADER, 0); + httpResponse.setHeader(HttpHeader.CACHE_CONTROL.asString(), CACHE_CONTROL_NO_CACHE_VALUE); + httpResponse.setDateHeader(HttpHeader.EXPIRES.asString(), 0); } } chain.doFilter(request, response); diff --git a/language-web/src/main/java/tools/refinery/language/web/ProblemServlet.java b/language-web/src/main/java/tools/refinery/language/web/ProblemServlet.java deleted file mode 100644 index 49457002..00000000 --- a/language-web/src/main/java/tools/refinery/language/web/ProblemServlet.java +++ /dev/null @@ -1,36 +0,0 @@ -/* - * generated by Xtext 2.25.0 - */ -package tools.refinery.language.web; - -import org.eclipse.xtext.util.DisposableRegistry; - -import jakarta.servlet.ServletException; -import tools.refinery.language.web.xtext.XtextServlet; - -/** - * Deploy this class into a servlet container to enable DSL-specific services. - */ -public class ProblemServlet extends XtextServlet { - - private static final long serialVersionUID = -9204695886561362912L; - - private transient DisposableRegistry disposableRegistry; - - @Override - public void init() throws ServletException { - super.init(); - var injector = new ProblemWebSetup().createInjectorAndDoEMFRegistration(); - this.disposableRegistry = injector.getInstance(DisposableRegistry.class); - } - - @Override - public void destroy() { - if (disposableRegistry != null) { - disposableRegistry.dispose(); - disposableRegistry = null; - } - super.destroy(); - } - -} diff --git a/language-web/src/main/java/tools/refinery/language/web/ProblemWebSocketServlet.java b/language-web/src/main/java/tools/refinery/language/web/ProblemWebSocketServlet.java new file mode 100644 index 00000000..9ffd6557 --- /dev/null +++ b/language-web/src/main/java/tools/refinery/language/web/ProblemWebSocketServlet.java @@ -0,0 +1,29 @@ +package tools.refinery.language.web; + +import org.eclipse.xtext.util.DisposableRegistry; + +import jakarta.servlet.ServletException; +import tools.refinery.language.web.xtext.XtextWebSocketServlet; + +public class ProblemWebSocketServlet extends XtextWebSocketServlet { + + private static final long serialVersionUID = -7040955470384797008L; + + private transient DisposableRegistry disposableRegistry; + + @Override + public void init() throws ServletException { + super.init(); + var injector = new ProblemWebSetup().createInjectorAndDoEMFRegistration(); + this.disposableRegistry = injector.getInstance(DisposableRegistry.class); + } + + @Override + public void destroy() { + if (disposableRegistry != null) { + disposableRegistry.dispose(); + disposableRegistry = null; + } + super.destroy(); + } +} diff --git a/language-web/src/main/java/tools/refinery/language/web/ServerLauncher.java b/language-web/src/main/java/tools/refinery/language/web/ServerLauncher.java index c253422b..0942b680 100644 --- a/language-web/src/main/java/tools/refinery/language/web/ServerLauncher.java +++ b/language-web/src/main/java/tools/refinery/language/web/ServerLauncher.java @@ -9,6 +9,7 @@ import java.net.InetSocketAddress; import java.net.URI; import java.net.URISyntaxException; import java.util.EnumSet; +import java.util.Optional; import java.util.Set; import org.eclipse.jetty.server.Server; @@ -22,24 +23,28 @@ import org.slf4j.LoggerFactory; import jakarta.servlet.DispatcherType; import jakarta.servlet.SessionTrackingMode; +import tools.refinery.language.web.xtext.XtextWebSocketServlet; public class ServerLauncher { public static final String DEFAULT_LISTEN_ADDRESS = "localhost"; public static final int DEFAULT_LISTEN_PORT = 1312; - // Use this cookie name for load balancing. - public static final String SESSION_COOKIE_NAME = "JSESSIONID"; + public static final int DEFAULT_PUBLIC_PORT = 443; + + public static final int HTTP_DEFAULT_PORT = 80; + + public static final int HTTPS_DEFAULT_PORT = 443; private static final Logger LOG = LoggerFactory.getLogger(ServerLauncher.class); private final Server server; - public ServerLauncher(InetSocketAddress bindAddress, Resource baseResource) { + public ServerLauncher(InetSocketAddress bindAddress, Resource baseResource, Optional allowedOrigins) { server = new Server(bindAddress); var handler = new ServletContextHandler(); addSessionHandler(handler); - addProblemServlet(handler); + addProblemServlet(handler, allowedOrigins); if (baseResource != null) { handler.setBaseResource(baseResource); handler.setWelcomeFiles(new String[] { "index.html" }); @@ -52,12 +57,20 @@ public class ServerLauncher { private void addSessionHandler(ServletContextHandler handler) { var sessionHandler = new SessionHandler(); sessionHandler.setSessionTrackingModes(Set.of(SessionTrackingMode.COOKIE)); - sessionHandler.setSessionCookie(SESSION_COOKIE_NAME); handler.setSessionHandler(sessionHandler); } - private void addProblemServlet(ServletContextHandler handler) { - handler.addServlet(ProblemServlet.class, "/xtext-service/*"); + private void addProblemServlet(ServletContextHandler handler, Optional allowedOrigins) { + var problemServletHolder = new ServletHolder(ProblemWebSocketServlet.class); + if (allowedOrigins.isEmpty()) { + LOG.warn("All WebSocket origins are allowed! This setting should not be used in production!"); + } else { + var allowedOriginsString = String.join(XtextWebSocketServlet.ALLOWED_ORIGINS_SEPARATOR, + allowedOrigins.get()); + problemServletHolder.setInitParameter(XtextWebSocketServlet.ALLOWED_ORIGINS_INIT_PARAM, + allowedOriginsString); + } + handler.addServlet(problemServletHolder, "/xtext-service/*"); } private void addDefaultServlet(ServletContextHandler handler) { @@ -80,7 +93,8 @@ public class ServerLauncher { try { var bindAddress = getBindAddress(); var baseResource = getBaseResource(); - var serverLauncher = new ServerLauncher(bindAddress, baseResource); + var allowedOrigins = getAllowedOrigins(); + var serverLauncher = new ServerLauncher(bindAddress, baseResource, allowedOrigins); serverLauncher.start(); } catch (Exception exception) { LOG.error("Fatal server error", exception); @@ -132,4 +146,43 @@ public class ServerLauncher { // Fall back to just serving a 404. return null; } + + private static String getPublicHost() { + var publicHost = System.getenv("PUBLIC_HOST"); + if (publicHost != null) { + return publicHost.toLowerCase(); + } + return null; + } + + private static int getPublicPort() { + var portStr = System.getenv("PUBLIC_PORT"); + if (portStr != null) { + return Integer.parseInt(portStr); + } + return DEFAULT_LISTEN_PORT; + } + + private static Optional getAllowedOrigins() { + var allowedOrigins = System.getenv("ALLOWED_ORIGINS"); + if (allowedOrigins != null) { + return Optional.of(allowedOrigins.split(XtextWebSocketServlet.ALLOWED_ORIGINS_SEPARATOR)); + } + return getAllowedOriginsFromPublicHostAndPort(); + } + + private static Optional getAllowedOriginsFromPublicHostAndPort() { + var publicHost = getPublicHost(); + if (publicHost == null) { + return Optional.empty(); + } + int publicPort = getPublicPort(); + var scheme = publicPort == HTTPS_DEFAULT_PORT ? "https" : "http"; + var urlWithPort = String.format("%s://%s:%d", scheme, publicHost, publicPort); + if (publicPort == HTTPS_DEFAULT_PORT || publicPort == HTTP_DEFAULT_PORT) { + var urlWithoutPort = String.format("%s://%s", scheme, publicHost); + return Optional.of(new String[] { urlWithPort, urlWithoutPort }); + } + return Optional.of(new String[] { urlWithPort }); + } } diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/HttpServiceContext.java b/language-web/src/main/java/tools/refinery/language/web/xtext/HttpServiceContext.java deleted file mode 100644 index d0ba6a2d..00000000 --- a/language-web/src/main/java/tools/refinery/language/web/xtext/HttpServiceContext.java +++ /dev/null @@ -1,107 +0,0 @@ -/** - * Copyright (c) 2015, 2020 itemis AG (http://www.itemis.eu) and others. - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0. - * - * SPDX-License-Identifier: EPL-2.0 - */ -package tools.refinery.language.web.xtext; - -import java.io.IOException; -import java.net.URLDecoder; -import java.nio.charset.Charset; -import java.util.Collections; -import java.util.Enumeration; -import java.util.HashMap; -import java.util.Map; -import java.util.Set; - -import org.eclipse.xtext.web.server.IServiceContext; -import org.eclipse.xtext.web.server.ISession; - -import com.google.common.io.CharStreams; - -import jakarta.servlet.http.HttpServletRequest; - -/** - * Provides the parameters and metadata of an {@link HttpServletRequest}. - */ -class HttpServiceContext implements IServiceContext { - private final HttpServletRequest request; - - private final Map parameters = new HashMap<>(); - - private HttpSessionWrapper sessionWrapper; - - public HttpServiceContext(HttpServletRequest request) throws IOException { - this.request = request; - initializeParameters(); - } - - private void initializeParameters() throws IOException { - initializeUrlEncodedParameters(); - initializeRequestParameters(); - if (!parameters.containsKey(IServiceContext.SERVICE_TYPE)) { - String substring = null; - if (request.getPathInfo() != null) { - substring = request.getPathInfo().substring(1); - } - parameters.put(IServiceContext.SERVICE_TYPE, substring); - } - } - - private void initializeUrlEncodedParameters() throws IOException { - String[] contentType = null; - if (request.getContentType() != null) { - contentType = request.getContentType().split(";(\\s*)"); - } - if (contentType != null && "application/x-www-form-urlencoded".equals(contentType[0])) { - String charset = null; - if (contentType.length >= 2 && contentType[1].startsWith("charset=")) { - charset = (contentType[1]).substring("charset=".length()); - } else { - charset = Charset.defaultCharset().toString(); - } - String[] encodedParams = CharStreams.toString(request.getReader()).split("&"); - for (String param : encodedParams) { - int nameEnd = param.indexOf("="); - if (nameEnd > 0) { - String key = param.substring(0, nameEnd); - String value = URLDecoder.decode(param.substring(nameEnd + 1), charset); - parameters.put(key, value); - } - } - } - } - - private void initializeRequestParameters() { - Enumeration paramNames = request.getParameterNames(); - while (paramNames.hasMoreElements()) { - String name = paramNames.nextElement(); - parameters.put(name, request.getParameter(name)); - } - } - - @Override - public Set getParameterKeys() { - return Collections.unmodifiableSet(parameters.keySet()); - } - - @Override - public String getParameter(String key) { - return parameters.get(key); - } - - @Override - public ISession getSession() { - if (sessionWrapper == null) { - sessionWrapper = new HttpSessionWrapper(request.getSession(true)); - } - return sessionWrapper; - } - - public HttpServletRequest getRequest() { - return request; - } -} diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/HttpSessionWrapper.java b/language-web/src/main/java/tools/refinery/language/web/xtext/HttpSessionWrapper.java deleted file mode 100644 index 8a5e19ba..00000000 --- a/language-web/src/main/java/tools/refinery/language/web/xtext/HttpSessionWrapper.java +++ /dev/null @@ -1,53 +0,0 @@ -/** - * Copyright (c) 2015, 2020 itemis AG (http://www.itemis.eu) and others. - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0. - * - * SPDX-License-Identifier: EPL-2.0 - */ -package tools.refinery.language.web.xtext; - -import org.eclipse.xtext.web.server.ISession; -import org.eclipse.xtext.xbase.lib.Functions.Function0; - -import jakarta.servlet.http.HttpSession; - -/** - * Provides access to the information stored in a {@link HttpSession}. - */ -record HttpSessionWrapper(HttpSession session) implements ISession { - @SuppressWarnings("unchecked") - @Override - public T get(Object key) { - return (T) session.getAttribute(key.toString()); - } - - @Override - public T get(Object key, Function0 factory) { - synchronized (session) { - T sessionValue = get(key); - if (sessionValue != null) { - return sessionValue; - } else { - T factoryValue = factory.apply(); - put(key, factoryValue); - return factoryValue; - } - } - } - - @Override - public void put(Object key, Object value) { - session.setAttribute(key.toString(), value); - } - - @Override - public void remove(Object key) { - session.removeAttribute(key.toString()); - } - - public HttpSession getSession() { - return session; - } -} diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/SimpleServiceContext.java b/language-web/src/main/java/tools/refinery/language/web/xtext/SimpleServiceContext.java new file mode 100644 index 00000000..1ec5b235 --- /dev/null +++ b/language-web/src/main/java/tools/refinery/language/web/xtext/SimpleServiceContext.java @@ -0,0 +1,27 @@ +package tools.refinery.language.web.xtext; + +import java.util.Map; +import java.util.Set; + +import org.eclipse.xtext.web.server.IServiceContext; +import org.eclipse.xtext.web.server.ISession; + +import com.google.common.collect.ImmutableSet; + +record SimpleServiceContext(ISession session, Map parameters) implements IServiceContext { + + @Override + public Set getParameterKeys() { + return ImmutableSet.copyOf(parameters.keySet()); + } + + @Override + public String getParameter(String key) { + return parameters.get(key); + } + + @Override + public ISession getSession() { + return session; + } +} diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/SimpleSession.java b/language-web/src/main/java/tools/refinery/language/web/xtext/SimpleSession.java new file mode 100644 index 00000000..691077d0 --- /dev/null +++ b/language-web/src/main/java/tools/refinery/language/web/xtext/SimpleSession.java @@ -0,0 +1,35 @@ +package tools.refinery.language.web.xtext; + +import java.util.HashMap; +import java.util.Map; + +import org.eclipse.xtext.web.server.ISession; +import org.eclipse.xtext.xbase.lib.Functions.Function0; + +public class SimpleSession implements ISession { + private Map map = new HashMap<>(); + + @Override + public T get(Object key) { + @SuppressWarnings("unchecked") + var value = (T) map.get(key); + return value; + } + + @Override + public T get(Object key, Function0 factory) { + @SuppressWarnings("unchecked") + var value = (T) map.computeIfAbsent(key, absentKey -> factory.apply()); + return value; + } + + @Override + public void put(Object key, Object value) { + map.put(key, value); + } + + @Override + public void remove(Object key) { + map.remove(key); + } +} diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/XtextServlet.java b/language-web/src/main/java/tools/refinery/language/web/xtext/XtextServlet.java deleted file mode 100644 index f39bec12..00000000 --- a/language-web/src/main/java/tools/refinery/language/web/xtext/XtextServlet.java +++ /dev/null @@ -1,196 +0,0 @@ -/** - * Copyright (c) 2015, 2020 itemis AG (http://www.itemis.eu) and others. - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0. - * - * SPDX-License-Identifier: EPL-2.0 - */ -package tools.refinery.language.web.xtext; - -import java.io.IOException; -import java.util.Set; - -import org.eclipse.emf.common.util.URI; -import org.eclipse.xtext.resource.IResourceServiceProvider; -import org.eclipse.xtext.web.server.IServiceContext; -import org.eclipse.xtext.web.server.IServiceResult; -import org.eclipse.xtext.web.server.IUnwrappableServiceResult; -import org.eclipse.xtext.web.server.InvalidRequestException; -import org.eclipse.xtext.web.server.XtextServiceDispatcher; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import com.google.common.base.Objects; -import com.google.common.base.Strings; -import com.google.gson.Gson; -import com.google.inject.Injector; - -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServlet; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; - -/** - * An HTTP servlet for publishing the Xtext services. Include this into your web - * server by creating a subclass that executes the standalone setups of your - * languages in its {@link #init()} method: - * - *
- * @WebServlet(name = "Xtext Services", urlPatterns = "/xtext-service/*")
- * class MyXtextServlet extends XtextServlet {
- * 	override init() {
- * 		super.init();
- * 		MyDslWebSetup.doSetup();
- * 	}
- * }
- * 
- * - * Use the {@code WebServlet} annotation to register your servlet. The default - * URL pattern for Xtext services is {@code "/xtext-service/*"}. - */ -public class XtextServlet extends HttpServlet { - - private static final long serialVersionUID = 7784324070547781918L; - - private static final IResourceServiceProvider.Registry SERVICE_PROVIDER_REGISTRY = IResourceServiceProvider.Registry.INSTANCE; - - private static final String ENCODING = "UTF-8"; - - private static final String INVALID_REQUEST_MESSAGE = "Invalid request ({}): {}"; - - private final transient Logger log = LoggerFactory.getLogger(this.getClass()); - - private final transient Gson gson = new Gson(); - - @Override - protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { - try { - super.service(req, resp); - } catch (InvalidRequestException.ResourceNotFoundException exception) { - log.trace(INVALID_REQUEST_MESSAGE, req.getRequestURI(), exception.getMessage()); - resp.sendError(HttpServletResponse.SC_NOT_FOUND, exception.getMessage()); - } catch (InvalidRequestException.InvalidDocumentStateException exception) { - log.trace(INVALID_REQUEST_MESSAGE, req.getRequestURI(), exception.getMessage()); - resp.sendError(HttpServletResponse.SC_CONFLICT, exception.getMessage()); - } catch (InvalidRequestException.PermissionDeniedException exception) { - log.trace(INVALID_REQUEST_MESSAGE, req.getRequestURI(), exception.getMessage()); - resp.sendError(HttpServletResponse.SC_FORBIDDEN, exception.getMessage()); - } catch (InvalidRequestException exception) { - log.trace(INVALID_REQUEST_MESSAGE, req.getRequestURI(), exception.getMessage()); - resp.sendError(HttpServletResponse.SC_BAD_REQUEST, exception.getMessage()); - } - } - - @Override - protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { - XtextServiceDispatcher.ServiceDescriptor service = getService(req); - if (!service.isHasConflict() && (service.isHasSideEffects() || hasTextInput(service))) { - super.doGet(req, resp); - } else { - doService(service, resp); - } - } - - @Override - protected void doPut(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { - XtextServiceDispatcher.ServiceDescriptor service = getService(req); - String type = service.getContext().getParameter(IServiceContext.SERVICE_TYPE); - if (!service.isHasConflict() && !Objects.equal(type, "update")) { - super.doPut(req, resp); - } else { - doService(service, resp); - } - } - - @Override - protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { - XtextServiceDispatcher.ServiceDescriptor service = getService(req); - String type = service.getContext().getParameter(IServiceContext.SERVICE_TYPE); - if (!service.isHasConflict() - && (!service.isHasSideEffects() && !hasTextInput(service) || Objects.equal(type, "update"))) { - super.doPost(req, resp); - } else { - doService(service, resp); - } - } - - protected boolean hasTextInput(XtextServiceDispatcher.ServiceDescriptor service) { - Set parameterKeys = service.getContext().getParameterKeys(); - return parameterKeys.contains("fullText") || parameterKeys.contains("deltaText"); - } - - /** - * Retrieve the service metadata for the given request. This involves resolving - * the Guice injector for the respective language, querying the - * {@link XtextServiceDispatcher}, and checking the permission to invoke the - * service. - */ - protected XtextServiceDispatcher.ServiceDescriptor getService(HttpServletRequest request) throws IOException { - HttpServiceContext serviceContext = new HttpServiceContext(request); - Injector injector = getInjector(serviceContext); - XtextServiceDispatcher serviceDispatcher = injector.getInstance(XtextServiceDispatcher.class); - return serviceDispatcher.getService(serviceContext); - } - - /** - * Invoke the service function of the given service descriptor and write its - * result to the servlet response in Json format. An exception is made for - * {@link IUnwrappableServiceResult}: here the document itself is written into - * the response instead of wrapping it into a Json object. - */ - protected void doService(XtextServiceDispatcher.ServiceDescriptor service, HttpServletResponse response) - throws IOException { - IServiceResult result = service.getService().apply(); - response.setStatus(HttpServletResponse.SC_OK); - response.setCharacterEncoding(ENCODING); - response.setHeader("Cache-Control", "no-cache"); - if (result instanceof IUnwrappableServiceResult unwrapResult && unwrapResult.getContent() != null) { - String contentType = null; - if (unwrapResult.getContentType() != null) { - contentType = unwrapResult.getContentType(); - } else { - contentType = "text/plain"; - } - response.setContentType(contentType); - response.getWriter().write(unwrapResult.getContent()); - } else { - response.setContentType("text/x-json"); - gson.toJson(result, response.getWriter()); - } - } - - /** - * Resolve the Guice injector for the language associated with the given - * context. - */ - protected Injector getInjector(HttpServiceContext serviceContext) - throws InvalidRequestException.UnknownLanguageException { - IResourceServiceProvider resourceServiceProvider = null; - String parameter = serviceContext.getParameter("resource"); - if (parameter == null) { - parameter = ""; - } - URI emfURI = URI.createURI(parameter); - String contentType = serviceContext.getParameter("contentType"); - if (Strings.isNullOrEmpty(contentType)) { - resourceServiceProvider = SERVICE_PROVIDER_REGISTRY.getResourceServiceProvider(emfURI); - if (resourceServiceProvider == null) { - if (emfURI.toString().isEmpty()) { - throw new InvalidRequestException.UnknownLanguageException( - "Unable to identify the Xtext language: missing parameter 'resource' or 'contentType'."); - } else { - throw new InvalidRequestException.UnknownLanguageException( - "Unable to identify the Xtext language for resource " + emfURI + "."); - } - } - } else { - resourceServiceProvider = SERVICE_PROVIDER_REGISTRY.getResourceServiceProvider(emfURI, contentType); - if (resourceServiceProvider == null) { - throw new InvalidRequestException.UnknownLanguageException( - "Unable to identify the Xtext language for contentType " + contentType + "."); - } - } - return resourceServiceProvider.get(Injector.class); - } -} \ No newline at end of file diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/XtextStatusCode.java b/language-web/src/main/java/tools/refinery/language/web/xtext/XtextStatusCode.java new file mode 100644 index 00000000..8ef60108 --- /dev/null +++ b/language-web/src/main/java/tools/refinery/language/web/xtext/XtextStatusCode.java @@ -0,0 +1,9 @@ +package tools.refinery.language.web.xtext; + +public final class XtextStatusCode { + public static final int INVALID_JSON = 4007; + + private XtextStatusCode() { + throw new IllegalStateException("This is a static utility class and should not be instantiated directly"); + } +} diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/XtextWebSocket.java b/language-web/src/main/java/tools/refinery/language/web/xtext/XtextWebSocket.java new file mode 100644 index 00000000..c76ef12f --- /dev/null +++ b/language-web/src/main/java/tools/refinery/language/web/xtext/XtextWebSocket.java @@ -0,0 +1,137 @@ +package tools.refinery.language.web.xtext; + +import java.io.IOException; +import java.io.Reader; + +import org.eclipse.emf.common.util.URI; +import org.eclipse.jetty.websocket.api.Session; +import org.eclipse.jetty.websocket.api.StatusCode; +import org.eclipse.jetty.websocket.api.WriteCallback; +import org.eclipse.jetty.websocket.api.annotations.OnWebSocketMessage; +import org.eclipse.jetty.websocket.api.annotations.WebSocket; +import org.eclipse.xtext.resource.IResourceServiceProvider; +import org.eclipse.xtext.web.server.IServiceContext; +import org.eclipse.xtext.web.server.ISession; +import org.eclipse.xtext.web.server.IUnwrappableServiceResult; +import org.eclipse.xtext.web.server.InvalidRequestException; +import org.eclipse.xtext.web.server.InvalidRequestException.UnknownLanguageException; +import org.eclipse.xtext.web.server.XtextServiceDispatcher; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.common.base.Strings; +import com.google.gson.Gson; +import com.google.gson.JsonIOException; +import com.google.gson.JsonParseException; +import com.google.inject.Injector; + +@WebSocket +public class XtextWebSocket implements WriteCallback { + private static final Logger LOG = LoggerFactory.getLogger(XtextWebSocket.class); + + private final Gson gson = new Gson(); + + private final ISession session; + + private final IResourceServiceProvider.Registry resourceServiceProviderRegistry; + + public XtextWebSocket(ISession session, IResourceServiceProvider.Registry resourceServiceProviderRegistry) { + this.session = session; + this.resourceServiceProviderRegistry = resourceServiceProviderRegistry; + } + + @OnWebSocketMessage + public void onMessage(Session webSocketSession, Reader reader) { + XtextWebSocketRequest request; + try { + request = gson.fromJson(reader, XtextWebSocketRequest.class); + } catch (JsonIOException e) { + LOG.error("Cannot read from websocket " + webSocketSession.getRemoteAddress(), e); + if (webSocketSession.isOpen()) { + webSocketSession.close(StatusCode.SERVER_ERROR, "Cannot read payload"); + } + return; + } catch (JsonParseException e) { + LOG.warn("Malformed websocket request from " + webSocketSession.getRemoteAddress(), e); + webSocketSession.close(XtextStatusCode.INVALID_JSON, "Invalid JSON payload"); + return; + } + var serviceContext = new SimpleServiceContext(session, request.getRequestData()); + var response = handleMessage(serviceContext); + response.setId(request.getId()); + var responseString = gson.toJson(response); + try { + webSocketSession.getRemote().sendPartialString(responseString, true, this); + } catch (IOException e) { + LOG.warn("Cannot initiaite async write to websocket to " + webSocketSession.getRemoteAddress(), e); + if (webSocketSession.isOpen()) { + webSocketSession.close(StatusCode.SERVER_ERROR, "Cannot write payload"); + } + } + } + + @Override + public void writeFailed(Throwable x) { + LOG.warn("Cannot complete async write to websocket", x); + } + + protected XtextWebSocketResponse handleMessage(IServiceContext serviceContext) { + try { + var injector = getInjector(serviceContext); + var serviceDispatcher = injector.getInstance(XtextServiceDispatcher.class); + var service = serviceDispatcher.getService(serviceContext); + var serviceResult = service.getService().apply(); + var response = new XtextWebSocketOkResponse(); + if (serviceResult instanceof IUnwrappableServiceResult unwrappableServiceResult + && unwrappableServiceResult.getContent() != null) { + response.setResponseData(unwrappableServiceResult.getContent()); + } else { + response.setResponseData(serviceResult); + } + return response; + } catch (InvalidRequestException e) { + LOG.warn("Invalid request", e); + var error = new XtextWebSocketErrorResponse(); + error.setErrorMessage(e.getMessage()); + return error; + } + } + + /** + * Get the injector to satisfy the request in the {@code serviceContext}. + * + * Based on {@link org.eclipse.xtext.web.servlet.XtextServlet#getInjector}. + * + * @param serviceContext the Xtext service context of the request + * @return the injector for the Xtext language in the request + * @throws UnknownLanguageException if the Xtext language cannot be determined + */ + protected Injector getInjector(IServiceContext serviceContext) { + IResourceServiceProvider resourceServiceProvider = null; + var resourceName = serviceContext.getParameter("resource"); + if (resourceName == null) { + resourceName = ""; + } + var emfURI = URI.createURI(resourceName); + var contentType = serviceContext.getParameter("contentType"); + if (Strings.isNullOrEmpty(contentType)) { + resourceServiceProvider = resourceServiceProviderRegistry.getResourceServiceProvider(emfURI); + if (resourceServiceProvider == null) { + if (emfURI.toString().isEmpty()) { + throw new UnknownLanguageException( + "Unable to identify the Xtext language: missing parameter 'resource' or 'contentType'."); + } else { + throw new UnknownLanguageException( + "Unable to identify the Xtext language for resource " + emfURI + "."); + } + } + } else { + resourceServiceProvider = resourceServiceProviderRegistry.getResourceServiceProvider(emfURI, contentType); + if (resourceServiceProvider == null) { + throw new UnknownLanguageException( + "Unable to identify the Xtext language for contentType " + contentType + "."); + } + } + return resourceServiceProvider.get(Injector.class); + } +} diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/XtextWebSocketErrorResponse.java b/language-web/src/main/java/tools/refinery/language/web/xtext/XtextWebSocketErrorResponse.java new file mode 100644 index 00000000..87b300b4 --- /dev/null +++ b/language-web/src/main/java/tools/refinery/language/web/xtext/XtextWebSocketErrorResponse.java @@ -0,0 +1,52 @@ +package tools.refinery.language.web.xtext; + +import java.util.Objects; + +import com.google.gson.annotations.SerializedName; + +public final class XtextWebSocketErrorResponse implements XtextWebSocketResponse { + private String id; + + @SerializedName("error") + private String errorMessage; + + @Override + public String getId() { + return id; + } + + @Override + public void setId(String id) { + this.id = id; + } + + public String getErrorMessage() { + return errorMessage; + } + + public void setErrorMessage(String errorMessage) { + this.errorMessage = errorMessage; + } + + @Override + public int hashCode() { + return Objects.hash(errorMessage, id); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) + return true; + if (obj == null) + return false; + if (getClass() != obj.getClass()) + return false; + XtextWebSocketErrorResponse other = (XtextWebSocketErrorResponse) obj; + return Objects.equals(errorMessage, other.errorMessage) && Objects.equals(id, other.id); + } + + @Override + public String toString() { + return "XtextWebSocketError [id=" + id + ", errorMessage=" + errorMessage + "]"; + } +} diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/XtextWebSocketOkResponse.java b/language-web/src/main/java/tools/refinery/language/web/xtext/XtextWebSocketOkResponse.java new file mode 100644 index 00000000..4ef1768b --- /dev/null +++ b/language-web/src/main/java/tools/refinery/language/web/xtext/XtextWebSocketOkResponse.java @@ -0,0 +1,52 @@ +package tools.refinery.language.web.xtext; + +import java.util.Objects; + +import com.google.gson.annotations.SerializedName; + +public final class XtextWebSocketOkResponse implements XtextWebSocketResponse { + private String id; + + @SerializedName("response") + private Object responseData; + + @Override + public String getId() { + return id; + } + + @Override + public void setId(String id) { + this.id = id; + } + + public Object getResponseData() { + return responseData; + } + + public void setResponseData(Object responseData) { + this.responseData = responseData; + } + + @Override + public int hashCode() { + return Objects.hash(id, responseData); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) + return true; + if (obj == null) + return false; + if (getClass() != obj.getClass()) + return false; + XtextWebSocketOkResponse other = (XtextWebSocketOkResponse) obj; + return Objects.equals(id, other.id) && Objects.equals(responseData, other.responseData); + } + + @Override + public String toString() { + return "XtextWebSocketResponse [id=" + id + ", responseData=" + responseData + "]"; + } +} diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/XtextWebSocketRequest.java b/language-web/src/main/java/tools/refinery/language/web/xtext/XtextWebSocketRequest.java new file mode 100644 index 00000000..e34bf73a --- /dev/null +++ b/language-web/src/main/java/tools/refinery/language/web/xtext/XtextWebSocketRequest.java @@ -0,0 +1,51 @@ +package tools.refinery.language.web.xtext; + +import java.util.Map; +import java.util.Objects; + +import com.google.gson.annotations.SerializedName; + +public class XtextWebSocketRequest { + private String id; + + @SerializedName("request") + private Map requestData; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public Map getRequestData() { + return requestData; + } + + public void setRequestData(Map request) { + this.requestData = request; + } + + @Override + public int hashCode() { + return Objects.hash(id, requestData); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) + return true; + if (obj == null) + return false; + if (getClass() != obj.getClass()) + return false; + XtextWebSocketRequest other = (XtextWebSocketRequest) obj; + return Objects.equals(id, other.id) && Objects.equals(requestData, other.requestData); + } + + @Override + public String toString() { + return "XtextWebSocketRequest [id=" + id + ", requestData=" + requestData + "]"; + } +} diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/XtextWebSocketResponse.java b/language-web/src/main/java/tools/refinery/language/web/xtext/XtextWebSocketResponse.java new file mode 100644 index 00000000..df0c228e --- /dev/null +++ b/language-web/src/main/java/tools/refinery/language/web/xtext/XtextWebSocketResponse.java @@ -0,0 +1,7 @@ +package tools.refinery.language.web.xtext; + +public sealed interface XtextWebSocketResponse permits XtextWebSocketOkResponse, XtextWebSocketErrorResponse { + public String getId(); + + public void setId(String id); +} diff --git a/language-web/src/main/java/tools/refinery/language/web/xtext/XtextWebSocketServlet.java b/language-web/src/main/java/tools/refinery/language/web/xtext/XtextWebSocketServlet.java new file mode 100644 index 00000000..0de6c358 --- /dev/null +++ b/language-web/src/main/java/tools/refinery/language/web/xtext/XtextWebSocketServlet.java @@ -0,0 +1,75 @@ +package tools.refinery.language.web.xtext; + +import java.io.IOException; +import java.time.Duration; +import java.util.Set; + +import org.eclipse.jetty.websocket.server.JettyServerUpgradeRequest; +import org.eclipse.jetty.websocket.server.JettyServerUpgradeResponse; +import org.eclipse.jetty.websocket.server.JettyWebSocketCreator; +import org.eclipse.jetty.websocket.server.JettyWebSocketServlet; +import org.eclipse.jetty.websocket.server.JettyWebSocketServletFactory; +import org.eclipse.xtext.resource.IResourceServiceProvider; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import jakarta.servlet.ServletConfig; +import jakarta.servlet.ServletException; + +public abstract class XtextWebSocketServlet extends JettyWebSocketServlet implements JettyWebSocketCreator { + + private static final long serialVersionUID = -3772740838165122685L; + + public static final String ALLOWED_ORIGINS_SEPARATOR = ";"; + + public static final String ALLOWED_ORIGINS_INIT_PARAM = "tools.refinery.language.web.xtext.XtextWebSocketServlet.allowedOrigin"; + + /** + * Maximum message size should be large enough to upload a full model file. + */ + private static final long MAX_FRAME_SIZE = 4L * 1024L * 1024L; + + private static final Duration IDLE_TIMEOUT = Duration.ofMinutes(10); + + private transient Logger log = LoggerFactory.getLogger(getClass()); + + private transient Set allowedOrigins = null; + + @Override + public void init(ServletConfig config) throws ServletException { + var allowedOriginsStr = config.getInitParameter(ALLOWED_ORIGINS_INIT_PARAM); + if (allowedOriginsStr != null) { + allowedOrigins = Set.of(allowedOriginsStr.split(ALLOWED_ORIGINS_SEPARATOR)); + log.info("Allowed origins: {}", allowedOrigins); + } else { + log.warn("All WebSocket origins are allowed! This setting should not be used in production!"); + } + super.init(config); + } + + @Override + protected void configure(JettyWebSocketServletFactory factory) { + factory.setMaxFrameSize(MAX_FRAME_SIZE); + factory.setIdleTimeout(IDLE_TIMEOUT); + factory.addMapping("/", this); + } + + @Override + public Object createWebSocket(JettyServerUpgradeRequest req, JettyServerUpgradeResponse resp) { + if (allowedOrigins != null) { + var origin = req.getOrigin(); + if (origin != null && !allowedOrigins.contains(origin.toLowerCase())) { + log.error("Connection from {} from forbidden origin {}", req.getRemoteSocketAddress(), origin); + try { + resp.sendForbidden("Origin not allowed"); + } catch (IOException e) { + log.error("Cannot send forbidden origin error", e); + } + return null; + } + } + log.debug("New connection from {}", req.getRemoteSocketAddress()); + var session = new SimpleSession(); + return new XtextWebSocket(session, IResourceServiceProvider.Registry.INSTANCE); + } +} -- cgit v1.2.3-70-g09d2