/* * Copyright 2000-2016 Vaadin Ltd. * * Licensed under the Apache License, Version 2.0 (the "License"); you may not * use this file except in compliance with the License. You may obtain a copy of * the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the * License for the specific language governing permissions and limitations under * the License. */ package com.vaadin.server.communication; import java.io.IOException; import java.io.Reader; import java.util.Collection; import java.util.logging.Level; import java.util.logging.Logger; import org.atmosphere.cpr.AtmosphereRequest; import org.atmosphere.cpr.AtmosphereResource; import org.atmosphere.cpr.AtmosphereResource.TRANSPORT; import org.atmosphere.cpr.AtmosphereResourceEvent; import org.atmosphere.cpr.AtmosphereResourceImpl; import com.vaadin.server.ErrorEvent; import com.vaadin.server.ErrorHandler; import com.vaadin.server.LegacyCommunicationManager.InvalidUIDLSecurityKeyException; import com.vaadin.server.ServiceException; import com.vaadin.server.ServletPortletHelper; import com.vaadin.server.SessionExpiredException; import com.vaadin.server.SystemMessages; import com.vaadin.server.VaadinRequest; import com.vaadin.server.VaadinService; import com.vaadin.server.VaadinServletRequest; import com.vaadin.server.VaadinServletService; import com.vaadin.server.VaadinSession; import com.vaadin.shared.ApplicationConstants; import com.vaadin.shared.communication.PushMode; import com.vaadin.ui.UI; import elemental.json.JsonException; /** * Handles incoming push connections and messages and dispatches them to the * correct {@link UI}/ {@link AtmospherePushConnection} * * @author Vaadin Ltd * @since 7.1 */ public class PushHandler { private int longPollingSuspendTimeout = -1; /** * Callback interface used internally to process an event with the * corresponding UI properly locked. */ private interface PushEventCallback { public void run(AtmosphereResource resource, UI ui) throws IOException; } /** * Callback used when we receive a request to establish a push channel for a * UI. Associate the AtmosphereResource with the UI and leave the connection * open by calling resource.suspend(). If there is a pending push, send it * now. */ private final PushEventCallback establishCallback = ( AtmosphereResource resource, UI ui) -> { getLogger().log(Level.FINER, "New push connection for resource {0} with transport {1}", new Object[] { resource.uuid(), resource.transport() }); resource.getResponse().setContentType("text/plain; charset=UTF-8"); VaadinSession session = ui.getSession(); if (resource.transport() == TRANSPORT.STREAMING) { // Must ensure that the streaming response contains // "Connection: close", otherwise iOS 6 will wait for the // response to this request before sending another request to // the same server (as it will apparently try to reuse the same // connection) resource.getResponse().addHeader("Connection", "close"); } String requestToken = resource.getRequest() .getParameter(ApplicationConstants.PUSH_ID_PARAMETER); if (!isPushIdValid(session, requestToken)) { getLogger().log(Level.WARNING, "Invalid identifier in new connection received from {0}", resource.getRequest().getRemoteHost()); // Refresh on client side, create connection just for // sending a message sendRefreshAndDisconnect(resource); return; } suspend(resource); AtmospherePushConnection connection = getConnectionForUI(ui); assert (connection != null); connection.connect(resource); }; /** * Callback used when we receive a UIDL request through Atmosphere. If the * push channel is bidirectional (websockets), the request was sent via the * same channel. Otherwise, the client used a separate AJAX request. Handle * the request and send changed UI state via the push channel (we do not * respond to the request directly.) */ private final PushEventCallback receiveCallback = ( AtmosphereResource resource, UI ui) -> { getLogger().log(Level.FINER, "Received message from resource {0}", resource.uuid()); AtmosphereRequest req = resource.getRequest(); AtmospherePushConnection connection = getConnectionForUI(ui); assert connection != null : "Got push from the client " + "even though the connection does not seem to be " + "valid. This might happen if a HttpSession is " + "serialized and deserialized while the push " + "connection is kept open or if the UI has a " + "connection of unexpected type."; Reader reader = connection.receiveMessage(req.getReader()); if (reader == null) { // The whole message was not yet received return; } // Should be set up by caller VaadinRequest vaadinRequest = VaadinService.getCurrentRequest(); assert vaadinRequest != null; try { new ServerRpcHandler().handleRpc(ui, reader, vaadinRequest); connection.push(false); } catch (JsonException e) { getLogger().log(Level.SEVERE, "Error writing JSON to response", e); // Refresh on client side sendRefreshAndDisconnect(resource); } catch (InvalidUIDLSecurityKeyException e) { getLogger().log(Level.WARNING, "Invalid security key received from {0}", resource.getRequest().getRemoteHost()); // Refresh on client side sendRefreshAndDisconnect(resource); } }; private final VaadinServletService service; public PushHandler(VaadinServletService service) { this.service = service; } /** * Suspends the given resource * * @since 7.6 * @param resource * the resource to suspend */ protected void suspend(AtmosphereResource resource) { if (resource.transport() == TRANSPORT.LONG_POLLING) { resource.suspend(getLongPollingSuspendTimeout()); } else { resource.suspend(-1); } } /** * Find the UI for the atmosphere resource, lock it and invoke the callback. * * @param resource * the atmosphere resource for the current request * @param callback * the push callback to call when a UI is found and locked * @param websocket * true if this is a websocket message (as opposed to a HTTP * request) */ private void callWithUi(final AtmosphereResource resource, final PushEventCallback callback, boolean websocket) { AtmosphereRequest req = resource.getRequest(); VaadinServletRequest vaadinRequest = new VaadinServletRequest(req, service); VaadinSession session = null; if (websocket) { // For any HTTP request we have already started the request in the // servlet service.requestStart(vaadinRequest, null); } try { try { session = service.findVaadinSession(vaadinRequest); assert VaadinSession.getCurrent() == session; } catch (ServiceException e) { getLogger().log(Level.SEVERE, "Could not get session. This should never happen", e); return; } catch (SessionExpiredException e) { SystemMessages msg = service .getSystemMessages(ServletPortletHelper.findLocale(null, null, vaadinRequest), vaadinRequest); sendNotificationAndDisconnect(resource, VaadinService.createCriticalNotificationJSON( msg.getSessionExpiredCaption(), msg.getSessionExpiredMessage(), null, msg.getSessionExpiredURL())); return; } UI ui = null; session.lock(); try { ui = service.findUI(vaadinRequest); assert UI.getCurrent() == ui; if (ui == null) { sendNotificationAndDisconnect(resource, UidlRequestHandler .getUINotFoundErrorJSON(service, vaadinRequest)); } else { callback.run(resource, ui); } } catch (final IOException e) { callErrorHandler(session, e); } catch (final Exception e) { SystemMessages msg = service .getSystemMessages(ServletPortletHelper.findLocale(null, null, vaadinRequest), vaadinRequest); AtmosphereResource errorResource = resource; if (ui != null && ui.getPushConnection() != null) { // We MUST use the opened push connection if there is one. // Otherwise we will write the response to the wrong request // when using streaming (the client -> server request // instead of the opened push channel) errorResource = ((AtmospherePushConnection) ui .getPushConnection()).getResource(); } sendNotificationAndDisconnect(errorResource, VaadinService.createCriticalNotificationJSON( msg.getInternalErrorCaption(), msg.getInternalErrorMessage(), null, msg.getInternalErrorURL())); callErrorHandler(session, e); } finally { try { session.unlock(); } catch (Exception e) { getLogger().log(Level.WARNING, "Error while unlocking session", e); // can't call ErrorHandler, we (hopefully) don't have a lock } } } finally { try { if (websocket) { service.requestEnd(vaadinRequest, null, session); } } catch (Exception e) { getLogger().log(Level.WARNING, "Error while ending request", e); // can't call ErrorHandler, we don't have a lock } } } /** * Call the session's {@link ErrorHandler}, if it has one, with the given * exception wrapped in an {@link ErrorEvent}. */ private void callErrorHandler(VaadinSession session, Exception e) { try { ErrorHandler errorHandler = ErrorEvent.findErrorHandler(session); if (errorHandler != null) { errorHandler.error(new ErrorEvent(e)); } } catch (Exception ex) { // Let's not allow error handling to cause trouble; log fails getLogger().log(Level.WARNING, "ErrorHandler call failed", ex); } } private static AtmospherePushConnection getConnectionForUI(UI ui) { PushConnection pushConnection = ui.getPushConnection(); if (pushConnection instanceof AtmospherePushConnection) { return (AtmospherePushConnection) pushConnection; } else { return null; } } void connectionLost(AtmosphereResourceEvent event) { // We don't want to use callWithUi here, as it assumes there's a client // request active and does requestStart and requestEnd among other // things. if(event == null){ getLogger().log(Level.SEVERE, "Could not get event. This should never happen."); return; } AtmosphereResource resource = event.getResource(); if(resource == null){ getLogger().log(Level.SEVERE, "Could not get resource. This should never happen."); return; } VaadinServletRequest vaadinRequest = new VaadinServletRequest( resource.getRequest(), service); VaadinSession session = null; try { session = service.findVaadinSession(vaadinRequest); } catch (ServiceException e) { getLogger().log(Level.SEVERE, "Could not get session. This should never happen", e); return; } catch (SessionExpiredException e) { // This happens at least if the server is restarted without // preserving the session. After restart the client reconnects, gets // a session expired notification and then closes the connection and // ends up here getLogger().log(Level.FINER, "Session expired before push disconnect event was received", e); return; } UI ui = null; session.lock(); try { VaadinSession.setCurrent(session); // Sets UI.currentInstance ui = service.findUI(vaadinRequest); if (ui == null) { /* * UI not found, could be because FF has asynchronously closed * the websocket connection and Atmosphere has already done * cleanup of the request attributes. * * In that case, we still have a chance of finding the right UI * by iterating through the UIs in the session looking for one * using the same AtmosphereResource. */ ui = findUiUsingResource(resource, session.getUIs()); if (ui == null) { getLogger().log(Level.FINE, "Could not get UI. This should never happen," + " except when reloading in Firefox and Chrome -" + " see http://dev.vaadin.com/ticket/14251."); return; } else { getLogger().log(Level.INFO, "No UI was found based on data in the request," + " but a slower lookup based on the AtmosphereResource succeeded." + " See http://dev.vaadin.com/ticket/14251 for more details."); } } PushMode pushMode = ui.getPushConfiguration().getPushMode(); AtmospherePushConnection pushConnection = getConnectionForUI(ui); String id = resource.uuid(); if (pushConnection == null) { getLogger().log(Level.WARNING, "Could not find push connection to close: {0} with transport {1}", new Object[] { id, resource.transport() }); } else { if (!pushMode.isEnabled()) { /* * The client is expected to close the connection after push * mode has been set to disabled. */ getLogger().log(Level.FINER, "Connection closed for resource {0}", id); } else { /* * Unexpected cancel, e.g. if the user closes the browser * tab. */ getLogger().log(Level.FINER, "Connection unexpectedly closed for resource {0} with transport {1}", new Object[] { id, resource.transport() }); } pushConnection.connectionLost(); } } catch (final Exception e) { callErrorHandler(session, e); } finally { try { session.unlock(); } catch (Exception e) { getLogger().log(Level.WARNING, "Error while unlocking session", e); // can't call ErrorHandler, we (hopefully) don't have a lock } } } private static UI findUiUsingResource(AtmosphereResource resource, Collection<UI> uIs) { for (UI ui : uIs) { PushConnection pushConnection = ui.getPushConnection(); if (pushConnection instanceof AtmospherePushConnection) { if (((AtmospherePushConnection) pushConnection) .getResource() == resource) { return ui; } } } return null; } /** * Sends a refresh message to the given atmosphere resource. Uses an * AtmosphereResource instead of an AtmospherePushConnection even though it * might be possible to look up the AtmospherePushConnection from the UI to * ensure border cases work correctly, especially when there temporarily are * two push connections which try to use the same UI. Using the * AtmosphereResource directly guarantees the message goes to the correct * recipient. * * @param resource * The atmosphere resource to send refresh to * */ private static void sendRefreshAndDisconnect(AtmosphereResource resource) throws IOException { sendNotificationAndDisconnect(resource, VaadinService .createCriticalNotificationJSON(null, null, null, null)); } /** * Tries to send a critical notification to the client and close the * connection. Does nothing if the connection is already closed. */ private static void sendNotificationAndDisconnect( AtmosphereResource resource, String notificationJson) { // TODO Implemented differently from sendRefreshAndDisconnect try { if (resource instanceof AtmosphereResourceImpl && !((AtmosphereResourceImpl) resource).isInScope()) { // The resource is no longer valid so we should not write // anything to it getLogger().fine( "sendNotificationAndDisconnect called for resource no longer in scope"); return; } resource.getResponse().getWriter().write(notificationJson); resource.resume(); } catch (Exception e) { getLogger().log(Level.FINEST, "Failed to send critical notification to client", e); } } private static final Logger getLogger() { return Logger.getLogger(PushHandler.class.getName()); } /** * Checks whether a given push id matches the session's push id. * * @param session * the vaadin session for which the check should be done * @param requestPushId * the push id provided in the request * @return {@code true} if the id is valid, {@code false} otherwise */ private static boolean isPushIdValid(VaadinSession session, String requestPushId) { String sessionPushId = session.getPushId(); if (requestPushId == null || !requestPushId.equals(sessionPushId)) { return false; } return true; } /** * Called when a new push connection is requested to be opened by the client * * @since 7.5.0 * @param resource * The related atmosphere resources */ void onConnect(AtmosphereResource resource) { callWithUi(resource, establishCallback, false); } /** * Called when a message is received through the push connection * * @since 7.5.0 * @param resource * The related atmosphere resources */ void onMessage(AtmosphereResource resource) { callWithUi(resource, receiveCallback, resource.transport() == TRANSPORT.WEBSOCKET); } /** * Sets the timeout used for suspend calls when using long polling. * * If you are using a proxy with a defined idle timeout, set the suspend * timeout to a value smaller than the proxy timeout so that the server is * aware of a reconnect taking place. * * @since 7.6 * @param longPollingSuspendTimeout * the timeout to use for suspended AtmosphereResources */ public void setLongPollingSuspendTimeout(int longPollingSuspendTimeout) { this.longPollingSuspendTimeout = longPollingSuspendTimeout; } /** * Gets the timeout used for suspend calls when using long polling. * * @since 7.6 * @return the timeout to use for suspended AtmosphereResources */ public int getLongPollingSuspendTimeout() { return longPollingSuspendTimeout; } }