From: Mathieu Baudier Date: Wed, 25 May 2022 07:29:00 +0000 (+0200) Subject: Reintroduce web socket support. X-Git-Tag: v2.3.10~221 X-Git-Url: http://git.argeo.org/?a=commitdiff_plain;h=d0c5e5d0aa3a75e256333d47a3c711f1c1c73b14;p=lgpl%2Fargeo-commons.git Reintroduce web socket support. --- diff --git a/Makefile b/Makefile index d70823039..af16906cc 100644 --- a/Makefile +++ b/Makefile @@ -20,6 +20,7 @@ org.argeo.api.cms \ org.argeo.cms \ org.argeo.cms.pgsql \ org.argeo.cms.ux \ +eclipse/org.argeo.ext.equinox.jetty \ eclipse/org.argeo.cms.servlet \ eclipse/org.argeo.cms.swt \ eclipse/org.argeo.cms.e4 \ diff --git a/eclipse/org.argeo.cms.servlet/src/org/argeo/cms/integration/CmsExceptionsChain.java b/eclipse/org.argeo.cms.servlet/src/org/argeo/cms/integration/CmsExceptionsChain.java new file mode 100644 index 000000000..fb289c18e --- /dev/null +++ b/eclipse/org.argeo.cms.servlet/src/org/argeo/cms/integration/CmsExceptionsChain.java @@ -0,0 +1,154 @@ +package org.argeo.cms.integration; + +import java.io.IOException; +import java.io.Writer; +import java.util.ArrayList; +import java.util.List; + +import javax.servlet.http.HttpServletResponse; + +import org.argeo.api.cms.CmsLog; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +/** Serialisable wrapper of a {@link Throwable}. */ +public class CmsExceptionsChain { + public final static CmsLog log = CmsLog.getLog(CmsExceptionsChain.class); + + private List exceptions = new ArrayList<>(); + + public CmsExceptionsChain() { + super(); + } + + public CmsExceptionsChain(Throwable exception) { + writeException(exception); + if (log.isDebugEnabled()) + log.error("Exception chain", exception); + } + + public String toJsonString(ObjectMapper objectMapper) { + try { + return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(this); + } catch (JsonProcessingException e) { + throw new IllegalStateException("Cannot write system exceptions " + toString(), e); + } + } + + public void writeAsJson(ObjectMapper objectMapper, Writer writer) { + try { + JsonGenerator jg = objectMapper.writerWithDefaultPrettyPrinter().getFactory().createGenerator(writer); + jg.writeObject(this); + } catch (IOException e) { + throw new IllegalStateException("Cannot write system exceptions " + toString(), e); + } + } + + public void writeAsJson(ObjectMapper objectMapper, HttpServletResponse resp) { + try { + resp.setContentType("application/json"); + resp.setStatus(500); + writeAsJson(objectMapper, resp.getWriter()); + } catch (IOException e) { + throw new IllegalStateException("Cannot write system exceptions " + toString(), e); + } + } + + /** recursive */ + protected void writeException(Throwable exception) { + SystemException systemException = new SystemException(exception); + exceptions.add(systemException); + Throwable cause = exception.getCause(); + if (cause != null) + writeException(cause); + } + + public List getExceptions() { + return exceptions; + } + + public void setExceptions(List exceptions) { + this.exceptions = exceptions; + } + + /** An exception in the chain. */ + public static class SystemException { + private String type; + private String message; + private List stackTrace; + + public SystemException() { + } + + public SystemException(Throwable exception) { + this.type = exception.getClass().getName(); + this.message = exception.getMessage(); + this.stackTrace = new ArrayList<>(); + StackTraceElement[] elems = exception.getStackTrace(); + for (int i = 0; i < elems.length; i++) + stackTrace.add("at " + elems[i].toString()); + } + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + public List getStackTrace() { + return stackTrace; + } + + public void setStackTrace(List stackTrace) { + this.stackTrace = stackTrace; + } + + @Override + public String toString() { + return "System exception: " + type + ", " + message + ", " + stackTrace; + } + + } + + @Override + public String toString() { + return exceptions.toString(); + } + +// public static void main(String[] args) throws Exception { +// try { +// try { +// try { +// testDeeper(); +// } catch (Exception e) { +// throw new Exception("Less deep exception", e); +// } +// } catch (Exception e) { +// throw new RuntimeException("Top exception", e); +// } +// } catch (Exception e) { +// CmsExceptionsChain vjeSystemErrors = new CmsExceptionsChain(e); +// ObjectMapper objectMapper = new ObjectMapper(); +// System.out.println(objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(vjeSystemErrors)); +// System.out.println(objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(e)); +// e.printStackTrace(); +// } +// } +// +// static void testDeeper() throws Exception { +// throw new IllegalStateException("Deep exception"); +// } + +} diff --git a/eclipse/org.argeo.cms.servlet/src/org/argeo/cms/integration/CmsLoginServlet.java b/eclipse/org.argeo.cms.servlet/src/org/argeo/cms/integration/CmsLoginServlet.java new file mode 100644 index 000000000..29a3137bb --- /dev/null +++ b/eclipse/org.argeo.cms.servlet/src/org/argeo/cms/integration/CmsLoginServlet.java @@ -0,0 +1,112 @@ +package org.argeo.cms.integration; + +import java.io.IOException; +import java.util.Locale; +import java.util.Set; + +import javax.security.auth.Subject; +import javax.security.auth.callback.Callback; +import javax.security.auth.callback.NameCallback; +import javax.security.auth.callback.PasswordCallback; +import javax.security.auth.callback.UnsupportedCallbackException; +import javax.security.auth.login.LoginContext; +import javax.security.auth.login.LoginException; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.argeo.api.cms.CmsAuth; +import org.argeo.api.cms.CmsSessionId; +import org.argeo.cms.auth.RemoteAuthCallback; +import org.argeo.cms.auth.RemoteAuthCallbackHandler; +import org.argeo.cms.servlet.ServletHttpRequest; +import org.argeo.cms.servlet.ServletHttpResponse; +import org.osgi.service.useradmin.Authorization; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.ObjectMapper; + +/** Externally authenticate an http session. */ +public class CmsLoginServlet extends HttpServlet { + public final static String PARAM_USERNAME = "username"; + public final static String PARAM_PASSWORD = "password"; + + private static final long serialVersionUID = 2478080654328751539L; + private ObjectMapper objectMapper = new ObjectMapper(); + + @Override + protected void doGet(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + doPost(request, response); + } + + @Override + protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { + LoginContext lc = null; + String username = req.getParameter(PARAM_USERNAME); + String password = req.getParameter(PARAM_PASSWORD); + ServletHttpRequest request = new ServletHttpRequest(req); + ServletHttpResponse response = new ServletHttpResponse(resp); + try { + lc = new LoginContext(CmsAuth.LOGIN_CONTEXT_USER, new RemoteAuthCallbackHandler(request, response) { + public void handle(Callback[] callbacks) throws IOException, UnsupportedCallbackException { + for (Callback callback : callbacks) { + if (callback instanceof NameCallback && username != null) + ((NameCallback) callback).setName(username); + else if (callback instanceof PasswordCallback && password != null) + ((PasswordCallback) callback).setPassword(password.toCharArray()); + else if (callback instanceof RemoteAuthCallback) { + ((RemoteAuthCallback) callback).setRequest(request); + ((RemoteAuthCallback) callback).setResponse(response); + } + } + } + }); + lc.login(); + + Subject subject = lc.getSubject(); + CmsSessionId cmsSessionId = extractFrom(subject.getPrivateCredentials(CmsSessionId.class)); + if (cmsSessionId == null) { + resp.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + return; + } + Authorization authorization = extractFrom(subject.getPrivateCredentials(Authorization.class)); + Locale locale = extractFrom(subject.getPublicCredentials(Locale.class)); + + CmsSessionDescriptor cmsSessionDescriptor = new CmsSessionDescriptor(authorization.getName(), + cmsSessionId.getUuid().toString(), authorization.getRoles(), authorization.toString(), + locale != null ? locale.toString() : null); + + resp.setContentType("application/json"); + JsonGenerator jg = objectMapper.getFactory().createGenerator(resp.getWriter()); + jg.writeObject(cmsSessionDescriptor); + + String redirectTo = redirectTo(req); + if (redirectTo != null) + resp.sendRedirect(redirectTo); + } catch (LoginException e) { + resp.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + return; + } + } + + protected T extractFrom(Set creds) { + if (creds.size() > 0) + return creds.iterator().next(); + else + return null; + } + + /** + * To be overridden in order to return a richer {@link CmsSessionDescriptor} to + * be serialized. + */ + protected CmsSessionDescriptor enrichJson(CmsSessionDescriptor cmsSessionDescriptor) { + return cmsSessionDescriptor; + } + + protected String redirectTo(HttpServletRequest request) { + return null; + } +} diff --git a/eclipse/org.argeo.cms.servlet/src/org/argeo/cms/integration/CmsLogoutServlet.java b/eclipse/org.argeo.cms.servlet/src/org/argeo/cms/integration/CmsLogoutServlet.java new file mode 100644 index 000000000..0628eae36 --- /dev/null +++ b/eclipse/org.argeo.cms.servlet/src/org/argeo/cms/integration/CmsLogoutServlet.java @@ -0,0 +1,79 @@ +package org.argeo.cms.integration; + +import java.io.IOException; +import java.util.Set; + +import javax.security.auth.Subject; +import javax.security.auth.callback.Callback; +import javax.security.auth.callback.UnsupportedCallbackException; +import javax.security.auth.login.LoginContext; +import javax.security.auth.login.LoginException; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.argeo.api.cms.CmsAuth; +import org.argeo.api.cms.CmsSessionId; +import org.argeo.cms.auth.CurrentUser; +import org.argeo.cms.auth.RemoteAuthCallback; +import org.argeo.cms.auth.RemoteAuthCallbackHandler; +import org.argeo.cms.servlet.ServletHttpRequest; +import org.argeo.cms.servlet.ServletHttpResponse; + +/** Externally authenticate an http session. */ +public class CmsLogoutServlet extends HttpServlet { + private static final long serialVersionUID = 2478080654328751539L; + + @Override + protected void doGet(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + doPost(request, response); + } + + @Override + protected void doPost(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + ServletHttpRequest httpRequest = new ServletHttpRequest(request); + ServletHttpResponse httpResponse = new ServletHttpResponse(response); + LoginContext lc = null; + try { + lc = new LoginContext(CmsAuth.LOGIN_CONTEXT_USER, + new RemoteAuthCallbackHandler(httpRequest, httpResponse) { + public void handle(Callback[] callbacks) throws IOException, UnsupportedCallbackException { + for (Callback callback : callbacks) { + if (callback instanceof RemoteAuthCallback) { + ((RemoteAuthCallback) callback).setRequest(httpRequest); + ((RemoteAuthCallback) callback).setResponse(httpResponse); + } + } + } + }); + lc.login(); + + Subject subject = lc.getSubject(); + CmsSessionId cmsSessionId = extractFrom(subject.getPrivateCredentials(CmsSessionId.class)); + if (cmsSessionId != null) {// logged in + CurrentUser.logoutCmsSession(subject); + } + + } catch (LoginException e) { + // ignore + } + + String redirectTo = redirectTo(request); + if (redirectTo != null) + response.sendRedirect(redirectTo); + } + + protected T extractFrom(Set creds) { + if (creds.size() > 0) + return creds.iterator().next(); + else + return null; + } + + protected String redirectTo(HttpServletRequest request) { + return null; + } +} diff --git a/eclipse/org.argeo.cms.servlet/src/org/argeo/cms/integration/CmsPrivateServletContext.java b/eclipse/org.argeo.cms.servlet/src/org/argeo/cms/integration/CmsPrivateServletContext.java new file mode 100644 index 000000000..cec04d230 --- /dev/null +++ b/eclipse/org.argeo.cms.servlet/src/org/argeo/cms/integration/CmsPrivateServletContext.java @@ -0,0 +1,82 @@ +package org.argeo.cms.integration; + +import java.io.IOException; +import java.security.AccessControlContext; +import java.security.PrivilegedAction; +import java.util.Map; + +import javax.security.auth.Subject; +import javax.security.auth.login.LoginContext; +import javax.security.auth.login.LoginException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.argeo.api.cms.CmsAuth; +import org.argeo.cms.auth.RemoteAuthCallbackHandler; +import org.argeo.cms.auth.RemoteAuthUtils; +import org.argeo.cms.servlet.ServletHttpRequest; +import org.argeo.cms.servlet.ServletHttpResponse; +import org.osgi.service.http.context.ServletContextHelper; + +/** Manages security access to servlets. */ +public class CmsPrivateServletContext extends ServletContextHelper { + public final static String LOGIN_PAGE = "argeo.cms.integration.loginPage"; + public final static String LOGIN_SERVLET = "argeo.cms.integration.loginServlet"; + private String loginPage; + private String loginServlet; + + public void init(Map properties) { + loginPage = properties.get(LOGIN_PAGE); + loginServlet = properties.get(LOGIN_SERVLET); + } + + /** + * Add the {@link AccessControlContext} as a request attribute, or redirect to + * the login page. + */ + @Override + public boolean handleSecurity(final HttpServletRequest req, HttpServletResponse resp) throws IOException { + LoginContext lc = null; + ServletHttpRequest request = new ServletHttpRequest(req); + ServletHttpResponse response = new ServletHttpResponse(resp); + + String pathInfo = req.getPathInfo(); + String servletPath = req.getServletPath(); + if ((pathInfo != null && (servletPath + pathInfo).equals(loginPage)) || servletPath.contentEquals(loginServlet)) + return true; + try { + lc = new LoginContext(CmsAuth.LOGIN_CONTEXT_USER, new RemoteAuthCallbackHandler(request, response)); + lc.login(); + } catch (LoginException e) { + lc = processUnauthorized(req, resp); + if (lc == null) + return false; + } + Subject.doAs(lc.getSubject(), new PrivilegedAction() { + + @Override + public Void run() { + // TODO also set login context in order to log out ? + RemoteAuthUtils.configureRequestSecurity(request); + return null; + } + + }); + + return true; + } + + @Override + public void finishSecurity(HttpServletRequest req, HttpServletResponse resp) { + RemoteAuthUtils.clearRequestSecurity(new ServletHttpRequest(req)); + } + + protected LoginContext processUnauthorized(HttpServletRequest request, HttpServletResponse response) { + try { + response.sendRedirect(loginPage); + } catch (IOException e) { + throw new RuntimeException("Cannot redirect to login page", e); + } + return null; + } +} diff --git a/eclipse/org.argeo.cms.servlet/src/org/argeo/cms/integration/CmsSessionDescriptor.java b/eclipse/org.argeo.cms.servlet/src/org/argeo/cms/integration/CmsSessionDescriptor.java new file mode 100644 index 000000000..30de616a2 --- /dev/null +++ b/eclipse/org.argeo.cms.servlet/src/org/argeo/cms/integration/CmsSessionDescriptor.java @@ -0,0 +1,96 @@ +package org.argeo.cms.integration; + +import java.io.Serializable; +import java.util.Arrays; +import java.util.Collections; +import java.util.Set; +import java.util.TreeSet; + +import org.argeo.api.cms.CmsSession; +import org.osgi.service.useradmin.Authorization; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +/** A serializable descriptor of an internal {@link CmsSession}. */ +@JsonIgnoreProperties(ignoreUnknown = true) +public class CmsSessionDescriptor implements Serializable, Authorization { + private static final long serialVersionUID = 8592162323372641462L; + + private String name; + private String cmsSessionId; + private String displayName; + private String locale; + private Set roles; + + public CmsSessionDescriptor() { + } + + public CmsSessionDescriptor(String name, String cmsSessionId, String[] roles, String displayName, String locale) { + this.name = name; + this.displayName = displayName; + this.cmsSessionId = cmsSessionId; + this.locale = locale; + this.roles = Collections.unmodifiableSortedSet(new TreeSet<>(Arrays.asList(roles))); + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDisplayName() { + return displayName; + } + + public void setDisplayName(String displayName) { + this.displayName = displayName; + } + + public String getCmsSessionId() { + return cmsSessionId; + } + + public void setCmsSessionId(String cmsSessionId) { + this.cmsSessionId = cmsSessionId; + } + + public Boolean isAnonymous() { + return name == null; + } + + public String getLocale() { + return locale; + } + + public void setLocale(String locale) { + this.locale = locale; + } + + @Override + public boolean hasRole(String name) { + return roles.contains(name); + } + + @Override + public String[] getRoles() { + return roles.toArray(new String[roles.size()]); + } + + public void setRoles(String[] roles) { + this.roles = Collections.unmodifiableSortedSet(new TreeSet<>(Arrays.asList(roles))); + } + + @Override + public int hashCode() { + return cmsSessionId != null ? cmsSessionId.hashCode() : super.hashCode(); + } + + @Override + public String toString() { + return displayName != null ? displayName : name != null ? name : super.toString(); + } + +} diff --git a/eclipse/org.argeo.cms.servlet/src/org/argeo/cms/integration/CmsTokenServlet.java b/eclipse/org.argeo.cms.servlet/src/org/argeo/cms/integration/CmsTokenServlet.java new file mode 100644 index 000000000..983202ad2 --- /dev/null +++ b/eclipse/org.argeo.cms.servlet/src/org/argeo/cms/integration/CmsTokenServlet.java @@ -0,0 +1,117 @@ +package org.argeo.cms.integration; + +import java.io.IOException; +import java.time.ZonedDateTime; +import java.util.Set; +import java.util.UUID; + +import javax.security.auth.Subject; +import javax.security.auth.callback.Callback; +import javax.security.auth.callback.UnsupportedCallbackException; +import javax.security.auth.login.LoginContext; +import javax.security.auth.login.LoginException; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.argeo.api.cms.CmsAuth; +import org.argeo.cms.CmsUserManager; +import org.argeo.cms.auth.RemoteAuthCallback; +import org.argeo.cms.auth.RemoteAuthCallbackHandler; +import org.argeo.cms.servlet.ServletHttpRequest; +import org.argeo.cms.servlet.ServletHttpResponse; +import org.argeo.util.naming.NamingUtils; +import org.osgi.service.useradmin.Authorization; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.ObjectMapper; + +/** Provides access to tokens. */ +public class CmsTokenServlet extends HttpServlet { + private static final long serialVersionUID = 302918711430864140L; + + public final static String PARAM_EXPIRY_DATE = "expiryDate"; + public final static String PARAM_TOKEN = "token"; + + private final static int DEFAULT_HOURS = 24; + + private CmsUserManager userManager; + private ObjectMapper objectMapper = new ObjectMapper(); + + @Override + protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { + ServletHttpRequest request = new ServletHttpRequest(req); + ServletHttpResponse response = new ServletHttpResponse(resp); + LoginContext lc = null; + try { + lc = new LoginContext(CmsAuth.LOGIN_CONTEXT_USER, new RemoteAuthCallbackHandler(request, response) { + public void handle(Callback[] callbacks) throws IOException, UnsupportedCallbackException { + for (Callback callback : callbacks) { + if (callback instanceof RemoteAuthCallback) { + ((RemoteAuthCallback) callback).setRequest(request); + ((RemoteAuthCallback) callback).setResponse(response); + } + } + } + }); + lc.login(); + } catch (LoginException e) { + // ignore + } + + try { + Subject subject = lc.getSubject(); + Authorization authorization = extractFrom(subject.getPrivateCredentials(Authorization.class)); + String token = UUID.randomUUID().toString(); + String expiryDateStr = req.getParameter(PARAM_EXPIRY_DATE); + ZonedDateTime expiryDate; + if (expiryDateStr != null) { + expiryDate = NamingUtils.ldapDateToZonedDateTime(expiryDateStr); + } else { + expiryDate = ZonedDateTime.now().plusHours(DEFAULT_HOURS); + expiryDateStr = NamingUtils.instantToLdapDate(expiryDate); + } + userManager.addAuthToken(authorization.getName(), token, expiryDate); + + TokenDescriptor tokenDescriptor = new TokenDescriptor(); + tokenDescriptor.setUsername(authorization.getName()); + tokenDescriptor.setToken(token); + tokenDescriptor.setExpiryDate(expiryDateStr); +// tokenDescriptor.setRoles(Collections.unmodifiableSortedSet(new TreeSet<>(Arrays.asList(roles)))); + + resp.setContentType("application/json"); + JsonGenerator jg = objectMapper.getFactory().createGenerator(resp.getWriter()); + jg.writeObject(tokenDescriptor); + } catch (Exception e) { + new CmsExceptionsChain(e).writeAsJson(objectMapper, resp); + } + } + + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { + // temporarily wrap POST for ease of testing + doPost(req, resp); + } + + @Override + protected void doDelete(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { + try { + String token = req.getParameter(PARAM_TOKEN); + userManager.expireAuthToken(token); + } catch (Exception e) { + new CmsExceptionsChain(e).writeAsJson(objectMapper, resp); + } + } + + protected T extractFrom(Set creds) { + if (creds.size() > 0) + return creds.iterator().next(); + else + return null; + } + + public void setUserManager(CmsUserManager userManager) { + this.userManager = userManager; + } +} diff --git a/eclipse/org.argeo.cms.servlet/src/org/argeo/cms/integration/TokenDescriptor.java b/eclipse/org.argeo.cms.servlet/src/org/argeo/cms/integration/TokenDescriptor.java new file mode 100644 index 000000000..1541b4f29 --- /dev/null +++ b/eclipse/org.argeo.cms.servlet/src/org/argeo/cms/integration/TokenDescriptor.java @@ -0,0 +1,49 @@ +package org.argeo.cms.integration; + +import java.io.Serializable; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +/** A serializable descriptor of a token. */ +@JsonIgnoreProperties(ignoreUnknown = true) +public class TokenDescriptor implements Serializable { + private static final long serialVersionUID = -6607393871416803324L; + + private String token; + private String username; + private String expiryDate; +// private Set roles; + + public String getToken() { + return token; + } + + public void setToken(String token) { + this.token = token; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + +// public Set getRoles() { +// return roles; +// } +// +// public void setRoles(Set roles) { +// this.roles = roles; +// } + + public String getExpiryDate() { + return expiryDate; + } + + public void setExpiryDate(String expiryDate) { + this.expiryDate = expiryDate; + } + +} diff --git a/eclipse/org.argeo.cms.servlet/src/org/argeo/cms/integration/package-info.java b/eclipse/org.argeo.cms.servlet/src/org/argeo/cms/integration/package-info.java new file mode 100644 index 000000000..1405737ee --- /dev/null +++ b/eclipse/org.argeo.cms.servlet/src/org/argeo/cms/integration/package-info.java @@ -0,0 +1,2 @@ +/** Argeo CMS integration (JSON, web services). */ +package org.argeo.cms.integration; \ No newline at end of file diff --git a/eclipse/org.argeo.cms.servlet/src/org/argeo/cms/servlet/internal/jetty/JettyServiceFactory.java b/eclipse/org.argeo.cms.servlet/src/org/argeo/cms/servlet/internal/jetty/JettyServiceFactory.java index 05de32c48..f1d9216c2 100644 --- a/eclipse/org.argeo.cms.servlet/src/org/argeo/cms/servlet/internal/jetty/JettyServiceFactory.java +++ b/eclipse/org.argeo.cms.servlet/src/org/argeo/cms/servlet/internal/jetty/JettyServiceFactory.java @@ -1,19 +1,59 @@ package org.argeo.cms.servlet.internal.jetty; import java.util.Dictionary; +import java.util.Hashtable; +import java.util.Map; + +import javax.websocket.DeploymentException; +import javax.websocket.server.ServerContainer; +import javax.websocket.server.ServerEndpointConfig; import org.argeo.api.cms.CmsConstants; import org.argeo.api.cms.CmsLog; +import org.argeo.cms.websocket.javax.server.CmsWebSocketConfigurator; +import org.argeo.cms.websocket.javax.server.TestEndpoint; +import org.argeo.util.LangUtils; import org.eclipse.equinox.http.jetty.JettyConfigurator; +import org.osgi.framework.BundleContext; import org.osgi.framework.FrameworkUtil; +import org.osgi.framework.ServiceReference; import org.osgi.service.cm.ConfigurationException; import org.osgi.service.cm.ManagedServiceFactory; +import org.osgi.util.tracker.ServiceTracker; public class JettyServiceFactory implements ManagedServiceFactory { - private final CmsLog log = CmsLog.getLog(JettyServiceFactory.class); + private final static CmsLog log = CmsLog.getLog(JettyServiceFactory.class); + + final static String CMS_JETTY_CUSTOMIZER_CLASS = "org.argeo.equinox.jetty.CmsJettyCustomizer"; + // Argeo specific + final static String WEBSOCKET_ENABLED = "websocket.enabled"; + + private final BundleContext bc = FrameworkUtil.getBundle(JettyServiceFactory.class).getBundleContext(); public void start() { + ServiceTracker serverSt = new ServiceTracker( + bc, ServerContainer.class, null) { + + @Override + public ServerContainer addingService(ServiceReference reference) { + ServerContainer serverContainer = super.addingService(reference); + + BundleContext bc = reference.getBundle().getBundleContext(); + ServiceReference srConfigurator = bc + .getServiceReference(ServerEndpointConfig.Configurator.class); + ServerEndpointConfig.Configurator endpointConfigurator = bc.getService(srConfigurator); + ServerEndpointConfig config = ServerEndpointConfig.Builder + .create(TestEndpoint.class, "/ws/test/events/").configurator(endpointConfigurator).build(); + try { + serverContainer.addEndpoint(config); + } catch (DeploymentException e) { + throw new IllegalStateException("Cannot initalise the WebSocket server runtime.", e); + } + return serverContainer; + } + }; + serverSt.open(); } @Override @@ -25,24 +65,24 @@ public class JettyServiceFactory implements ManagedServiceFactory { public void updated(String pid, Dictionary properties) throws ConfigurationException { // Explicitly configures Jetty so that the default server is not started by the // activator of the Equinox Jetty bundle. + Map config = LangUtils.dictToStringMap(properties); + if (!config.isEmpty()) { + config.put("customizer.class", CMS_JETTY_CUSTOMIZER_CLASS); -// if (!webServerConfig.isEmpty()) { -// webServerConfig.put("customizer.class", KernelConstants.CMS_JETTY_CUSTOMIZER_CLASS); -// -// // TODO centralise with Jetty extender -// Object webSocketEnabled = webServerConfig.get(InternalHttpConstants.WEBSOCKET_ENABLED); -// if (webSocketEnabled != null && webSocketEnabled.toString().equals("true")) { -// bc.registerService(ServerEndpointConfig.Configurator.class, new CmsWebSocketConfigurator(), null); -// webServerConfig.put(InternalHttpConstants.WEBSOCKET_ENABLED, "true"); -// } -// } + // TODO centralise with Jetty extender + Object webSocketEnabled = config.get(WEBSOCKET_ENABLED); + if (webSocketEnabled != null && webSocketEnabled.toString().equals("true")) { + bc.registerService(ServerEndpointConfig.Configurator.class, new CmsWebSocketConfigurator(), null); + config.put(WEBSOCKET_ENABLED, "true"); + } + } int tryCount = 60; try { tryGettyJetty: while (tryCount > 0) { try { // FIXME deal with multiple ids - JettyConfigurator.startServer(CmsConstants.DEFAULT, properties); + JettyConfigurator.startServer(CmsConstants.DEFAULT, new Hashtable<>(config)); // Explicitly starts Jetty OSGi HTTP bundle, so that it gets triggered if OSGi // configuration is not cleaned FrameworkUtil.getBundle(JettyConfigurator.class).start(); diff --git a/eclipse/org.argeo.cms.servlet/src/org/argeo/cms/websocket/javax/server/CmsWebSocketConfigurator.java b/eclipse/org.argeo.cms.servlet/src/org/argeo/cms/websocket/javax/server/CmsWebSocketConfigurator.java new file mode 100644 index 000000000..8cc165591 --- /dev/null +++ b/eclipse/org.argeo.cms.servlet/src/org/argeo/cms/websocket/javax/server/CmsWebSocketConfigurator.java @@ -0,0 +1,109 @@ +package org.argeo.cms.websocket.javax.server; + +import java.security.AccessController; +import java.security.PrivilegedAction; +import java.util.List; + +import javax.security.auth.Subject; +import javax.security.auth.login.LoginContext; +import javax.websocket.Extension; +import javax.websocket.HandshakeResponse; +import javax.websocket.server.HandshakeRequest; +import javax.websocket.server.ServerEndpointConfig; +import javax.websocket.server.ServerEndpointConfig.Configurator; + +import org.argeo.api.cms.CmsAuth; +import org.argeo.api.cms.CmsLog; +import org.argeo.cms.auth.RemoteAuthCallbackHandler; +import org.argeo.cms.auth.RemoteAuthSession; +import org.argeo.cms.servlet.ServletHttpSession; +import org.osgi.service.http.context.ServletContextHelper; + +/** + * Disabled until third party issues are solved.. Customises + * the initialisation of a new web socket. + */ +public class CmsWebSocketConfigurator extends Configurator { + public final static String WEBSOCKET_SUBJECT = "org.argeo.cms.websocket.subject"; + + private final static CmsLog log = CmsLog.getLog(CmsWebSocketConfigurator.class); + final static String HEADER_WWW_AUTHENTICATE = "WWW-Authenticate"; + + @Override + public boolean checkOrigin(String originHeaderValue) { + return true; + } + + @Override + public T getEndpointInstance(Class endpointClass) throws InstantiationException { + try { + return endpointClass.getDeclaredConstructor().newInstance(); + } catch (Exception e) { + throw new IllegalArgumentException("Cannot get endpoint instance", e); + } + } + + @Override + public List getNegotiatedExtensions(List installed, List requested) { + return requested; + } + + @Override + public String getNegotiatedSubprotocol(List supported, List requested) { + if ((requested == null) || (requested.size() == 0)) + return ""; + if ((supported == null) || (supported.isEmpty())) + return ""; + for (String possible : requested) { + if (possible == null) + continue; + if (supported.contains(possible)) + return possible; + } + return ""; + } + + @Override + public void modifyHandshake(ServerEndpointConfig sec, HandshakeRequest request, HandshakeResponse response) { + if(true) + return; + + RemoteAuthSession httpSession = new ServletHttpSession( + (javax.servlet.http.HttpSession) request.getHttpSession()); + if (log.isDebugEnabled() && httpSession != null) + log.debug("Web socket HTTP session id: " + httpSession.getId()); + + if (httpSession == null) { + rejectResponse(response, null); + } + try { + LoginContext lc = new LoginContext(CmsAuth.LOGIN_CONTEXT_USER, new RemoteAuthCallbackHandler(httpSession)); + lc.login(); + if (log.isDebugEnabled()) + log.debug("Web socket logged-in as " + lc.getSubject()); + Subject.doAs(lc.getSubject(), new PrivilegedAction() { + + @Override + public Void run() { + sec.getUserProperties().put(ServletContextHelper.REMOTE_USER, AccessController.getContext()); + return null; + } + + }); + } catch (Exception e) { + rejectResponse(response, e); + } + } + + /** + * Behaviour when the web socket could not be authenticated. Throws an + * {@link IllegalStateException} by default. + * + * @param e can be null + */ + protected void rejectResponse(HandshakeResponse response, Exception e) { + // violent implementation, as suggested in + // https://stackoverflow.com/questions/21763829/jsr-356-how-to-abort-a-websocket-connection-during-the-handshake +// throw new IllegalStateException("Web socket cannot be authenticated"); + } +} diff --git a/eclipse/org.argeo.cms.servlet/src/org/argeo/cms/websocket/javax/server/TestEndpoint.java b/eclipse/org.argeo.cms.servlet/src/org/argeo/cms/websocket/javax/server/TestEndpoint.java new file mode 100644 index 000000000..e01f6f721 --- /dev/null +++ b/eclipse/org.argeo.cms.servlet/src/org/argeo/cms/websocket/javax/server/TestEndpoint.java @@ -0,0 +1,178 @@ +package org.argeo.cms.websocket.javax.server; + +import java.io.IOException; +import java.security.AccessControlContext; +import java.util.Hashtable; +import java.util.Map; + +import javax.security.auth.Subject; +import javax.websocket.CloseReason; +import javax.websocket.OnClose; +import javax.websocket.OnError; +import javax.websocket.OnMessage; +import javax.websocket.OnOpen; +import javax.websocket.RemoteEndpoint; +import javax.websocket.Session; +import javax.websocket.server.ServerEndpoint; + +import org.argeo.api.cms.CmsLog; +import org.argeo.cms.integration.CmsExceptionsChain; +import org.osgi.framework.BundleContext; +import org.osgi.framework.FrameworkUtil; +import org.osgi.framework.ServiceRegistration; +import org.osgi.service.event.Event; +import org.osgi.service.event.EventConstants; +import org.osgi.service.event.EventHandler; +import org.osgi.service.http.context.ServletContextHelper; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +/** Provides WebSocket access. */ +@ServerEndpoint(value = "/ws/test/events/") +public class TestEndpoint implements EventHandler { + private final static CmsLog log = CmsLog.getLog(TestEndpoint.class); + + final static String TOPICS_BASE = "/test"; + final static String INPUT = "input"; + final static String TOPIC = "topic"; + final static String VIEW_UID = "viewUid"; + final static String COMPUTATION_UID = "computationUid"; + final static String MESSAGES = "messages"; + final static String ERRORS = "errors"; + + final static String EXCEPTION = "exception"; + final static String MESSAGE = "message"; + + private BundleContext bc = FrameworkUtil.getBundle(TestEndpoint.class).getBundleContext(); + + private String wsSessionId; + private RemoteEndpoint.Basic remote; + private ServiceRegistration eventHandlerSr; + + // json + private ObjectMapper objectMapper = new ObjectMapper(); + + private WebSocketView view; + + @OnOpen + public void onWebSocketConnect(Session session) { + wsSessionId = session.getId(); + + // 24h timeout + session.setMaxIdleTimeout(1000 * 60 * 60 * 24); + + Map userProperties = session.getUserProperties(); + Subject subject = null; +// AccessControlContext accessControlContext = (AccessControlContext) userProperties +// .get(ServletContextHelper.REMOTE_USER); +// Subject subject = Subject.getSubject(accessControlContext); +// // Deal with authentication failure +// if (subject == null) { +// try { +// CloseReason.CloseCode closeCode = new CloseReason.CloseCode() { +// +// @Override +// public int getCode() { +// return 4001; +// } +// }; +// session.close(new CloseReason(closeCode, "Unauthorized")); +// if (log.isTraceEnabled()) +// log.trace("Unauthorized web socket " + wsSessionId + ". Closing with code " + closeCode.getCode() +// + "."); +// return; +// } catch (IOException e) { +// // silent +// } +// return;// ignore +// } + + if (log.isDebugEnabled()) + log.debug("WS#" + wsSessionId + " open for: " + subject); + remote = session.getBasicRemote(); + view = new WebSocketView(subject); + + // OSGi events + String[] topics = new String[] { TOPICS_BASE + "/*" }; + Hashtable ht = new Hashtable<>(); + ht.put(EventConstants.EVENT_TOPIC, topics); + ht.put(EventConstants.EVENT_FILTER, "(" + VIEW_UID + "=" + view.getUid() + ")"); + eventHandlerSr = bc.registerService(EventHandler.class, this, ht); + + if (log.isDebugEnabled()) + log.debug("New view " + view.getUid() + " opened, via web socket."); + } + + @OnMessage + public void onWebSocketText(Session session, String message) throws JsonMappingException, JsonProcessingException { + try { + if (log.isTraceEnabled()) + log.trace("WS#" + view.getUid() + " received:\n" + message + "\n"); +// JsonNode jsonNode = objectMapper.readTree(message); +// String topic = jsonNode.get(TOPIC).textValue(); + + final String computationUid = null; +// if (MY_TOPIC.equals(topic)) { +// view.checkRole(SPECIFIC_ROLE); +// computationUid= process(); +// } + remote.sendText("ACK"); + } catch (Exception e) { + log.error("Error when receiving web socket message", e); + sendSystemErrorMessage(e); + } + } + + @OnClose + public void onWebSocketClose(CloseReason reason) { + if (eventHandlerSr != null) + eventHandlerSr.unregister(); + if (view != null && log.isDebugEnabled()) + log.debug("WS#" + view.getUid() + " closed: " + reason); + } + + @OnError + public void onWebSocketError(Throwable cause) { + if (view != null) { + log.error("WS#" + view.getUid() + " ERROR", cause); + } else { + if (log.isTraceEnabled()) + log.error("Error in web socket session " + wsSessionId, cause); + } + } + + @Override + public void handleEvent(Event event) { + try { + Object uid = event.getProperty(COMPUTATION_UID); + Exception exception = (Exception) event.getProperty(EXCEPTION); + if (exception != null) { + CmsExceptionsChain systemErrors = new CmsExceptionsChain(exception); + String sent = systemErrors.toJsonString(objectMapper); + remote.sendText(sent); + return; + } + String topic = event.getTopic(); + if (log.isTraceEnabled()) + log.trace("WS#" + view.getUid() + " " + topic + ": notify event " + topic + "#" + uid + ", " + event); + } catch (Exception e) { + log.error("Error when handling event for WebSocket", e); + sendSystemErrorMessage(e); + } + + } + + /** Sends an error message in JSON format. */ + protected void sendSystemErrorMessage(Exception e) { + CmsExceptionsChain systemErrors = new CmsExceptionsChain(e); + try { + if (remote != null) + remote.sendText(systemErrors.toJsonString(objectMapper)); + } catch (Exception e1) { + log.error("Cannot send WebSocket system error messages " + systemErrors, e1); + } + } +} diff --git a/eclipse/org.argeo.cms.servlet/src/org/argeo/cms/websocket/javax/server/WebSocketTest.java b/eclipse/org.argeo.cms.servlet/src/org/argeo/cms/websocket/javax/server/WebSocketTest.java new file mode 100644 index 000000000..819837b49 --- /dev/null +++ b/eclipse/org.argeo.cms.servlet/src/org/argeo/cms/websocket/javax/server/WebSocketTest.java @@ -0,0 +1,35 @@ +package org.argeo.cms.websocket.javax.server; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.WebSocket; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.TimeUnit; + +/** Tests connectivity to the web socket server. */ +public class WebSocketTest { + + public static void main(String[] args) throws Exception { + CompletableFuture received = new CompletableFuture<>(); + WebSocket.Listener listener = new WebSocket.Listener() { + + public CompletionStage onText(WebSocket webSocket, CharSequence message, boolean last) { + System.out.println(message); + CompletionStage res = CompletableFuture.completedStage(message.toString()); + received.complete(true); + return res; + } + }; + + HttpClient client = HttpClient.newHttpClient(); + CompletableFuture ws = client.newWebSocketBuilder() + .buildAsync(URI.create("ws://localhost:7070/ws/test/events/"), listener); + WebSocket webSocket = ws.get(); + webSocket.sendText("TEST", true); + + received.get(10, TimeUnit.SECONDS); + webSocket.sendClose(WebSocket.NORMAL_CLOSURE, ""); + } + +} diff --git a/eclipse/org.argeo.cms.servlet/src/org/argeo/cms/websocket/javax/server/WebSocketView.java b/eclipse/org.argeo.cms.servlet/src/org/argeo/cms/websocket/javax/server/WebSocketView.java new file mode 100644 index 000000000..a5da88be9 --- /dev/null +++ b/eclipse/org.argeo.cms.servlet/src/org/argeo/cms/websocket/javax/server/WebSocketView.java @@ -0,0 +1,60 @@ +package org.argeo.cms.websocket.javax.server; + +import java.security.Principal; +import java.util.HashSet; +import java.util.Set; +import java.util.UUID; + +import javax.security.auth.Subject; +import javax.security.auth.x500.X500Principal; + +import org.osgi.service.useradmin.Role; + +/** + * Abstraction of a single Frontend view, that is a web browser page. There can + * be multiple views within one single authenticated HTTP session. + */ +public class WebSocketView { + private final String uid; + private Subject subject; + + public WebSocketView(Subject subject) { + this.uid = UUID.randomUUID().toString(); + this.subject = subject; + } + + public String getUid() { + return uid; + } + + public Set getRoles() { + return roles(subject); + } + + public boolean isInRole(String role) { + return getRoles().contains(role); + } + + public void checkRole(String role) { + checkRole(subject, role); + } + + public final static Set roles(Subject subject) { + Set roles = new HashSet(); + X500Principal principal = subject.getPrincipals(X500Principal.class).iterator().next(); + String username = principal.getName(); + roles.add(username); + for (Principal group : subject.getPrincipals()) { + if (group instanceof Role) + roles.add(group.getName()); + } + return roles; + } + + public static void checkRole(Subject subject, String role) { + Set roles = roles(subject); + if (!roles.contains(role)) + throw new IllegalStateException("User is not in role " + role); + } + +} diff --git a/eclipse/org.argeo.cms.servlet/src/org/argeo/cms/websocket/javax/server/package-info.java b/eclipse/org.argeo.cms.servlet/src/org/argeo/cms/websocket/javax/server/package-info.java new file mode 100644 index 000000000..564c881bc --- /dev/null +++ b/eclipse/org.argeo.cms.servlet/src/org/argeo/cms/websocket/javax/server/package-info.java @@ -0,0 +1,2 @@ +/** Argeo CMS websocket integration. */ +package org.argeo.cms.websocket.javax.server; \ No newline at end of file diff --git a/eclipse/org.argeo.ext.equinox.jetty/.classpath b/eclipse/org.argeo.ext.equinox.jetty/.classpath new file mode 100644 index 000000000..eca7bdba8 --- /dev/null +++ b/eclipse/org.argeo.ext.equinox.jetty/.classpath @@ -0,0 +1,7 @@ + + + + + + + diff --git a/eclipse/org.argeo.ext.equinox.jetty/.gitignore b/eclipse/org.argeo.ext.equinox.jetty/.gitignore new file mode 100644 index 000000000..09e3bc9b2 --- /dev/null +++ b/eclipse/org.argeo.ext.equinox.jetty/.gitignore @@ -0,0 +1,2 @@ +/bin/ +/target/ diff --git a/eclipse/org.argeo.ext.equinox.jetty/.project b/eclipse/org.argeo.ext.equinox.jetty/.project new file mode 100644 index 000000000..0b9700dd6 --- /dev/null +++ b/eclipse/org.argeo.ext.equinox.jetty/.project @@ -0,0 +1,28 @@ + + + org.argeo.ext.equinox.jetty + + + + + + org.eclipse.jdt.core.javabuilder + + + + + org.eclipse.pde.ManifestBuilder + + + + + org.eclipse.pde.SchemaBuilder + + + + + + org.eclipse.pde.PluginNature + org.eclipse.jdt.core.javanature + + diff --git a/eclipse/org.argeo.ext.equinox.jetty/META-INF/.gitignore b/eclipse/org.argeo.ext.equinox.jetty/META-INF/.gitignore new file mode 100644 index 000000000..4854a41b9 --- /dev/null +++ b/eclipse/org.argeo.ext.equinox.jetty/META-INF/.gitignore @@ -0,0 +1 @@ +/MANIFEST.MF diff --git a/eclipse/org.argeo.ext.equinox.jetty/bnd.bnd b/eclipse/org.argeo.ext.equinox.jetty/bnd.bnd new file mode 100644 index 000000000..7fca53671 --- /dev/null +++ b/eclipse/org.argeo.ext.equinox.jetty/bnd.bnd @@ -0,0 +1 @@ +Fragment-Host: org.eclipse.equinox.http.jetty diff --git a/eclipse/org.argeo.ext.equinox.jetty/build.properties b/eclipse/org.argeo.ext.equinox.jetty/build.properties new file mode 100644 index 000000000..34d2e4d2d --- /dev/null +++ b/eclipse/org.argeo.ext.equinox.jetty/build.properties @@ -0,0 +1,4 @@ +source.. = src/ +output.. = bin/ +bin.includes = META-INF/,\ + . diff --git a/eclipse/org.argeo.ext.equinox.jetty/src/org/argeo/equinox/jetty/CmsJettyCustomizer.java b/eclipse/org.argeo.ext.equinox.jetty/src/org/argeo/equinox/jetty/CmsJettyCustomizer.java new file mode 100644 index 000000000..8ad95c9e3 --- /dev/null +++ b/eclipse/org.argeo.ext.equinox.jetty/src/org/argeo/equinox/jetty/CmsJettyCustomizer.java @@ -0,0 +1,40 @@ +package org.argeo.equinox.jetty; + +import java.util.Dictionary; + +import javax.servlet.ServletContext; +import javax.websocket.DeploymentException; +import javax.websocket.server.ServerContainer; + +import org.eclipse.equinox.http.jetty.JettyCustomizer; +import org.eclipse.jetty.servlet.ServletContextHandler; +import org.eclipse.jetty.websocket.javax.server.config.JavaxWebSocketServletContainerInitializer; +import org.eclipse.jetty.websocket.javax.server.config.JavaxWebSocketServletContainerInitializer.Configurator; +import org.osgi.framework.BundleContext; +import org.osgi.framework.FrameworkUtil; + +/** Customises the Jetty HTTP server. */ +public class CmsJettyCustomizer extends JettyCustomizer { + private BundleContext bc = FrameworkUtil.getBundle(CmsJettyCustomizer.class).getBundleContext(); + + public final static String WEBSOCKET_ENABLED = "websocket.enabled"; + + @Override + public Object customizeContext(Object context, Dictionary settings) { + // WebSocket + Object webSocketEnabled = settings.get(WEBSOCKET_ENABLED); + if (webSocketEnabled != null && webSocketEnabled.toString().equals("true")) { + ServletContextHandler servletContextHandler = (ServletContextHandler) context; + JavaxWebSocketServletContainerInitializer.configure(servletContextHandler, new Configurator() { + + @Override + public void accept(ServletContext servletContext, ServerContainer serverContainer) + throws DeploymentException { + bc.registerService(javax.websocket.server.ServerContainer.class, serverContainer, null); + } + }); + } + return super.customizeContext(context, settings); + + } +} diff --git a/eclipse/org.argeo.ext.equinox.jetty/src/org/argeo/equinox/jetty/package-info.java b/eclipse/org.argeo.ext.equinox.jetty/src/org/argeo/equinox/jetty/package-info.java new file mode 100644 index 000000000..41c8ce9b0 --- /dev/null +++ b/eclipse/org.argeo.ext.equinox.jetty/src/org/argeo/equinox/jetty/package-info.java @@ -0,0 +1,2 @@ +/** Equinox Jetty extensions. */ +package org.argeo.equinox.jetty; \ No newline at end of file diff --git a/jcr/org.argeo.cms.jcr/src/org/argeo/cms/jcr/internal/servlet/JcrReadServlet.java b/jcr/org.argeo.cms.jcr/src/org/argeo/cms/jcr/internal/servlet/JcrReadServlet.java new file mode 100644 index 000000000..b0cd7897c --- /dev/null +++ b/jcr/org.argeo.cms.jcr/src/org/argeo/cms/jcr/internal/servlet/JcrReadServlet.java @@ -0,0 +1,319 @@ +package org.argeo.cms.jcr.internal.servlet; + +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.security.AccessControlContext; +import java.security.PrivilegedActionException; +import java.security.PrivilegedExceptionAction; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; + +import javax.jcr.Node; +import javax.jcr.NodeIterator; +import javax.jcr.Property; +import javax.jcr.PropertyIterator; +import javax.jcr.PropertyType; +import javax.jcr.Repository; +import javax.jcr.RepositoryException; +import javax.jcr.Session; +import javax.jcr.Value; +import javax.jcr.nodetype.NodeType; +import javax.security.auth.Subject; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.apache.commons.io.IOUtils; +import org.apache.jackrabbit.api.JackrabbitNode; +import org.apache.jackrabbit.api.JackrabbitValue; +import org.argeo.api.cms.CmsLog; +import org.argeo.cms.integration.CmsExceptionsChain; +import org.argeo.jcr.JcrUtils; +import org.osgi.service.http.context.ServletContextHelper; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.ObjectMapper; + +/** Access a JCR repository via web services. */ +public class JcrReadServlet extends HttpServlet { + private static final long serialVersionUID = 6536175260540484539L; + private final static CmsLog log = CmsLog.getLog(JcrReadServlet.class); + + protected final static String ACCEPT_HTTP_HEADER = "Accept"; + protected final static String CONTENT_DISPOSITION_HTTP_HEADER = "Content-Disposition"; + + protected final static String OCTET_STREAM_CONTENT_TYPE = "application/octet-stream"; + protected final static String XML_CONTENT_TYPE = "application/xml"; + protected final static String JSON_CONTENT_TYPE = "application/json"; + + private final static String PARAM_VERBOSE = "verbose"; + private final static String PARAM_DEPTH = "depth"; + + protected final static String JCR_NODES = "jcr:nodes"; + // cf. javax.jcr.Property + protected final static String JCR_PATH = "path"; + protected final static String JCR_NAME = "name"; + + protected final static String _JCR = "_jcr"; + protected final static String JCR_PREFIX = "jcr:"; + protected final static String REP_PREFIX = "rep:"; + + private Repository repository; + private Integer maxDepth = 8; + + private ObjectMapper objectMapper = new ObjectMapper(); + + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { + if (log.isTraceEnabled()) + log.trace("Data service: " + req.getPathInfo()); + + String dataWorkspace = getWorkspace(req); + String jcrPath = getJcrPath(req); + + boolean verbose = req.getParameter(PARAM_VERBOSE) != null && !req.getParameter(PARAM_VERBOSE).equals("false"); + int depth = 1; + if (req.getParameter(PARAM_DEPTH) != null) { + depth = Integer.parseInt(req.getParameter(PARAM_DEPTH)); + if (depth > maxDepth) + throw new RuntimeException("Depth " + depth + " is higher than maximum " + maxDepth); + } + + Session session = null; + try { + // authentication + session = openJcrSession(req, resp, getRepository(), dataWorkspace); + if (!session.itemExists(jcrPath)) + throw new RuntimeException("JCR node " + jcrPath + " does not exist"); + Node node = session.getNode(jcrPath); + + List acceptHeader = readAcceptHeader(req); + if (!acceptHeader.isEmpty() && node.isNodeType(NodeType.NT_FILE)) { + resp.setContentType(OCTET_STREAM_CONTENT_TYPE); + resp.addHeader(CONTENT_DISPOSITION_HTTP_HEADER, "attachment; filename='" + node.getName() + "'"); + IOUtils.copy(JcrUtils.getFileAsStream(node), resp.getOutputStream()); + resp.flushBuffer(); + } else { + if (!acceptHeader.isEmpty() && acceptHeader.get(0).equals(XML_CONTENT_TYPE)) { + // TODO Use req.startAsync(); ? + resp.setContentType(XML_CONTENT_TYPE); + session.exportSystemView(node.getPath(), resp.getOutputStream(), false, depth <= 1); + return; + } + if (!acceptHeader.isEmpty() && !acceptHeader.contains(JSON_CONTENT_TYPE)) { + if (log.isTraceEnabled()) + log.warn("Content type " + acceptHeader + " in Accept header is not supported. Supported: " + + JSON_CONTENT_TYPE + " (default), " + XML_CONTENT_TYPE); + } + resp.setContentType(JSON_CONTENT_TYPE); + JsonGenerator jsonGenerator = getObjectMapper().getFactory().createGenerator(resp.getWriter()); + jsonGenerator.writeStartObject(); + writeNodeChildren(node, jsonGenerator, depth, verbose); + writeNodeProperties(node, jsonGenerator, verbose); + jsonGenerator.writeEndObject(); + jsonGenerator.flush(); + } + } catch (Exception e) { + new CmsExceptionsChain(e).writeAsJson(getObjectMapper(), resp); + } finally { + JcrUtils.logoutQuietly(session); + } + } + + protected Session openJcrSession(HttpServletRequest req, HttpServletResponse resp, Repository repository, + String workspace) throws RepositoryException { + AccessControlContext acc = (AccessControlContext) req.getAttribute(ServletContextHelper.REMOTE_USER); + Subject subject = Subject.getSubject(acc); + try { + return Subject.doAs(subject, new PrivilegedExceptionAction() { + + @Override + public Session run() throws RepositoryException { + return repository.login(workspace); + } + + }); + } catch (PrivilegedActionException e) { + if (e.getException() instanceof RepositoryException) + throw (RepositoryException) e.getException(); + else + throw new RuntimeException(e.getException()); + } +// return workspace != null ? repository.login(workspace) : repository.login(); + } + + protected String getWorkspace(HttpServletRequest req) { + String path = req.getPathInfo(); + try { + path = URLDecoder.decode(path, StandardCharsets.UTF_8.name()); + } catch (UnsupportedEncodingException e) { + throw new IllegalArgumentException(e); + } + String[] pathTokens = path.split("/"); + return pathTokens[1]; + } + + protected String getJcrPath(HttpServletRequest req) { + String path = req.getPathInfo(); + try { + path = URLDecoder.decode(path, StandardCharsets.UTF_8.name()); + } catch (UnsupportedEncodingException e) { + throw new IllegalArgumentException(e); + } + String[] pathTokens = path.split("/"); + String domain = pathTokens[1]; + String jcrPath = path.substring(domain.length() + 1); + return jcrPath; + } + + protected List readAcceptHeader(HttpServletRequest req) { + List lst = new ArrayList<>(); + String acceptHeader = req.getHeader(ACCEPT_HTTP_HEADER); + if (acceptHeader == null) + return lst; +// Enumeration acceptHeader = req.getHeaders(ACCEPT_HTTP_HEADER); +// while (acceptHeader.hasMoreElements()) { + String[] arr = acceptHeader.split("\\."); + for (int i = 0; i < arr.length; i++) { + String str = arr[i].trim(); + if (!"".equals(str)) + lst.add(str); + } +// } + return lst; + } + + protected void writeNodeProperties(Node node, JsonGenerator jsonGenerator, boolean verbose) + throws RepositoryException, IOException { + String jcrPath = node.getPath(); + Map> namespaces = new TreeMap<>(); + + PropertyIterator pit = node.getProperties(); + properties: while (pit.hasNext()) { + Property property = pit.nextProperty(); + + final String propertyName = property.getName(); + int columnIndex = propertyName.indexOf(':'); + if (columnIndex > 0) { + // mark prefix with a '_' before the name of the object, according to JSON + // conventions to indicate a special value + String prefix = "_" + propertyName.substring(0, columnIndex); + String unqualifiedName = propertyName.substring(columnIndex + 1); + if (!namespaces.containsKey(prefix)) + namespaces.put(prefix, new LinkedHashMap()); + Map map = namespaces.get(prefix); + assert !map.containsKey(unqualifiedName); + map.put(unqualifiedName, property); + continue properties; + } + + if (property.getType() == PropertyType.BINARY) { + if (!(node instanceof JackrabbitNode)) { + continue properties;// skip + } + } + + writeProperty(propertyName, property, jsonGenerator); + } + + for (String prefix : namespaces.keySet()) { + Map map = namespaces.get(prefix); + jsonGenerator.writeFieldName(prefix); + jsonGenerator.writeStartObject(); + if (_JCR.equals(prefix)) { + jsonGenerator.writeStringField(JCR_NAME, node.getName()); + jsonGenerator.writeStringField(JCR_PATH, jcrPath); + } + properties: for (String unqualifiedName : map.keySet()) { + Property property = map.get(unqualifiedName); + if (property.getType() == PropertyType.BINARY) { + if (!(node instanceof JackrabbitNode)) { + continue properties;// skip + } + } + writeProperty(unqualifiedName, property, jsonGenerator); + } + jsonGenerator.writeEndObject(); + } + } + + protected void writeProperty(String fieldName, Property property, JsonGenerator jsonGenerator) + throws RepositoryException, IOException { + if (!property.isMultiple()) { + jsonGenerator.writeFieldName(fieldName); + writePropertyValue(property.getType(), property.getValue(), jsonGenerator); + } else { + jsonGenerator.writeFieldName(fieldName); + jsonGenerator.writeStartArray(); + Value[] values = property.getValues(); + for (Value value : values) { + writePropertyValue(property.getType(), value, jsonGenerator); + } + jsonGenerator.writeEndArray(); + } + } + + protected void writePropertyValue(int type, Value value, JsonGenerator jsonGenerator) + throws RepositoryException, IOException { + if (type == PropertyType.DOUBLE) + jsonGenerator.writeNumber(value.getDouble()); + else if (type == PropertyType.LONG) + jsonGenerator.writeNumber(value.getLong()); + else if (type == PropertyType.BINARY) { + if (value instanceof JackrabbitValue) { + String contentIdentity = ((JackrabbitValue) value).getContentIdentity(); + jsonGenerator.writeString("SHA256:" + contentIdentity); + } else { + // TODO write Base64 ? + jsonGenerator.writeNull(); + } + } else + jsonGenerator.writeString(value.getString()); + } + + protected void writeNodeChildren(Node node, JsonGenerator jsonGenerator, int depth, boolean verbose) + throws RepositoryException, IOException { + if (!node.hasNodes()) + return; + if (depth <= 0) + return; + NodeIterator nit; + + nit = node.getNodes(); + children: while (nit.hasNext()) { + Node child = nit.nextNode(); + if (!verbose && child.getName().startsWith(REP_PREFIX)) { + continue children;// skip Jackrabbit auth metadata + } + + jsonGenerator.writeFieldName(child.getName()); + jsonGenerator.writeStartObject(); + writeNodeChildren(child, jsonGenerator, depth - 1, verbose); + writeNodeProperties(child, jsonGenerator, verbose); + jsonGenerator.writeEndObject(); + } + } + + public void setRepository(Repository repository) { + this.repository = repository; + } + + public void setMaxDepth(Integer maxDepth) { + this.maxDepth = maxDepth; + } + + protected Repository getRepository() { + return repository; + } + + protected ObjectMapper getObjectMapper() { + return objectMapper; + } + +} diff --git a/jcr/org.argeo.cms.jcr/src/org/argeo/cms/jcr/internal/servlet/JcrWriteServlet.java b/jcr/org.argeo.cms.jcr/src/org/argeo/cms/jcr/internal/servlet/JcrWriteServlet.java new file mode 100644 index 000000000..459a1e493 --- /dev/null +++ b/jcr/org.argeo.cms.jcr/src/org/argeo/cms/jcr/internal/servlet/JcrWriteServlet.java @@ -0,0 +1,92 @@ +package org.argeo.cms.jcr.internal.servlet; + +import java.io.IOException; + +import javax.jcr.ImportUUIDBehavior; +import javax.jcr.Node; +import javax.jcr.Session; +import javax.jcr.nodetype.NodeType; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.apache.commons.io.FilenameUtils; +import org.apache.commons.io.IOUtils; +import org.argeo.api.cms.CmsLog; +import org.argeo.cms.integration.CmsExceptionsChain; +import org.argeo.jcr.JcrUtils; + +/** Access a JCR repository via web services. */ +public class JcrWriteServlet extends JcrReadServlet { + private static final long serialVersionUID = 17272653843085492L; + private final static CmsLog log = CmsLog.getLog(JcrWriteServlet.class); + + @Override + protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { + if (log.isDebugEnabled()) + log.debug("Data service POST: " + req.getPathInfo()); + + String dataWorkspace = getWorkspace(req); + String jcrPath = getJcrPath(req); + + Session session = null; + try { + // authentication + session = openJcrSession(req, resp, getRepository(), dataWorkspace); + + if (req.getContentType() != null && req.getContentType().equals(XML_CONTENT_TYPE)) { +// resp.setContentType(XML_CONTENT_TYPE); + session.getWorkspace().importXML(jcrPath, req.getInputStream(), + ImportUUIDBehavior.IMPORT_UUID_COLLISION_REMOVE_EXISTING); + return; + } + + if (!session.itemExists(jcrPath)) { + String parentPath = FilenameUtils.getFullPathNoEndSeparator(jcrPath); + String fileName = FilenameUtils.getName(jcrPath); + Node folderNode = JcrUtils.mkfolders(session, parentPath); + byte[] bytes = IOUtils.toByteArray(req.getInputStream()); + JcrUtils.copyBytesAsFile(folderNode, fileName, bytes); + } else { + Node node = session.getNode(jcrPath); + if (!node.isNodeType(NodeType.NT_FILE)) + throw new IllegalArgumentException("Node " + jcrPath + " exists but is not a file"); + byte[] bytes = IOUtils.toByteArray(req.getInputStream()); + JcrUtils.copyBytesAsFile(node.getParent(), node.getName(), bytes); + } + session.save(); + } catch (Exception e) { + new CmsExceptionsChain(e).writeAsJson(getObjectMapper(), resp); + } finally { + JcrUtils.logoutQuietly(session); + } + } + + @Override + protected void doDelete(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { + if (log.isDebugEnabled()) + log.debug("Data service DELETE: " + req.getPathInfo()); + + String dataWorkspace = getWorkspace(req); + String jcrPath = getJcrPath(req); + + Session session = null; + try { + // authentication + session = openJcrSession(req, resp, getRepository(), dataWorkspace); + if (!session.itemExists(jcrPath)) { + // ignore + return; + } else { + Node node = session.getNode(jcrPath); + node.remove(); + } + session.save(); + } catch (Exception e) { + new CmsExceptionsChain(e).writeAsJson(getObjectMapper(), resp); + } finally { + JcrUtils.logoutQuietly(session); + } + } + +}