package org.sakaiproject.portal.chat.entity; import java.io.File; import java.io.InputStream; import java.io.OutputStream; import java.lang.reflect.Method; import java.util.ArrayList; import java.util.Arrays; import java.util.Date; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import org.apache.commons.lang3.StringEscapeUtils; import org.apache.log4j.Logger; import org.jgroups.Address; import org.jgroups.Channel; import org.jgroups.JChannel; import org.jgroups.Message; import org.jgroups.Receiver; import org.jgroups.View; import org.sakaiproject.component.api.ComponentManager; import org.sakaiproject.component.api.ServerConfigurationService; import org.sakaiproject.email.api.EmailService; import org.sakaiproject.entitybroker.DeveloperHelperService; import org.sakaiproject.entitybroker.EntityReference; import org.sakaiproject.entitybroker.EntityView; import org.sakaiproject.entitybroker.entityprovider.EntityProvider; import org.sakaiproject.entitybroker.entityprovider.annotations.EntityCustomAction; import org.sakaiproject.entitybroker.entityprovider.capabilities.ActionsExecutable; import org.sakaiproject.entitybroker.entityprovider.capabilities.AutoRegisterEntityProvider; import org.sakaiproject.entitybroker.entityprovider.capabilities.Createable; import org.sakaiproject.entitybroker.entityprovider.capabilities.Describeable; import org.sakaiproject.entitybroker.entityprovider.capabilities.Inputable; import org.sakaiproject.entitybroker.entityprovider.capabilities.Outputable; import org.sakaiproject.entitybroker.entityprovider.extension.Formats; import org.sakaiproject.entitybroker.exception.EntityException; import org.sakaiproject.entitybroker.util.AbstractEntityProvider; import org.sakaiproject.portal.api.PortalChatPermittedHelper; import org.sakaiproject.presence.api.PresenceService; import org.sakaiproject.user.api.User; import org.sakaiproject.user.api.UserDirectoryService; import org.sakaiproject.util.ResourceLoader; /** * Provides all the RESTful targets for the portal chat code in chat.js. Clustering * is catered for using a JGroups channel. * * @author Adrian Fish (a.fish@lancaster.ac.uk) */ public class PCServiceEntityProvider extends AbstractEntityProvider implements Receiver, EntityProvider, Createable, Inputable, Outputable, ActionsExecutable, AutoRegisterEntityProvider, Describeable { protected final Logger logger = Logger.getLogger(getClass()); /** messages. */ private static ResourceLoader rb = new ResourceLoader("portal-chat"); public final static String ENTITY_PREFIX = "portal-chat"; /* JGROUPS MESSAGE PREFIXES */ /* Heartbeat messages start with this */ private final String HEARTBEAT_PREAMBLE = "heartbeat:"; /* Message messages start with this */ private final String MESSAGE_PREAMBLE = "message:"; /* Clear messages start with this */ private final String CLEAR_PREAMBLE = "clear:"; /* SAK-20565. Gets set to false if Profile2 isn't available */ private boolean connectionsAvailable = true; /* Setting used to configure if site users should be available in the chat. */ private boolean showSiteUsers = true; private int pollInterval = 5000; /* SAK-20565. We now use reflection to call the profile connection methods */ private Object profileServiceObject = null; private Method getConnectionsForUserMethod = null; private Method getUuidMethod = null; private Method setProfileMethod = null; private Method setPrivacyMethod = null; private Method setPreferencesMethod = null; private PortalChatPermittedHelper portalChatPermittedHelper; public void setPortalChatPermittedHelper(PortalChatPermittedHelper portalChatPermittedHelper) { this.portalChatPermittedHelper = portalChatPermittedHelper; } private UserDirectoryService userDirectoryService; public void setUserDirectoryService(UserDirectoryService userDirectoryService) { this.userDirectoryService = userDirectoryService; } private EmailService emailService; public void setEmailService(EmailService emailService) { this.emailService = emailService; } private PresenceService presenceService; public void setPresenceService(PresenceService presenceService) { this.presenceService = presenceService; } private ServerConfigurationService serverConfigurationService; public void setServerConfigurationService(ServerConfigurationService serverConfigurationService) { this.serverConfigurationService = serverConfigurationService; } private DeveloperHelperService developerService = null; public void setDeveloperService(DeveloperHelperService developerService) { this.developerService = developerService; } /* A mapping of a list of messages onto the user id they are intended for */ private Map<String, List<UserMessage>> messageMap = new HashMap<String,List<UserMessage>>(); /* * A mapping of timestamps onto the user id that sent the heartbeat. The initial capacity should be set * to the number of app servers in your cluster times the max number of threads per app server. This is * configurable in sakai.properties as portalchat.heartbeatmap.size. */ private Map<String,Date> heartbeatMap; /* JGroups channel for keeping the above maps in sync across nodes in a Sakai cluster */ private Channel clusterChannel = null; private boolean clustered = false; private String portalUrl; private String service; private String serverName; public void init() { service = serverConfigurationService.getString("ui.service","Sakai"); portalUrl = serverConfigurationService.getServerUrl() + "/portal"; serverName = serverConfigurationService.getServerName(); pollInterval = serverConfigurationService.getInt("portal.chat.pollInterval", 5000); showSiteUsers = serverConfigurationService.getBoolean("portal.chat.showSiteUsers", true); try { String channelId = serverConfigurationService.getString("portalchat.cluster.channel"); if(channelId != null && !channelId.equals("")) { // Pick up the config file from sakai home if it exists File jgroupsConfig = new File(serverConfigurationService.getSakaiHomePath() + File.separator + "jgroups-config.xml"); if(jgroupsConfig.exists()) { if(logger.isDebugEnabled()) { logger.debug("Using jgroups config file: " + jgroupsConfig.getAbsolutePath()); } clusterChannel = new JChannel(jgroupsConfig); } else { if(logger.isDebugEnabled()) { logger.debug("No jgroups config file. Using jgroup defaults."); } clusterChannel = new JChannel(); } if(logger.isDebugEnabled()) { logger.debug("JGROUPS PROTOCOL: " + clusterChannel.getProtocolStack().printProtocolSpecAsXML()); } clusterChannel.setReceiver(this); clusterChannel.connect(channelId); // We don't want a copy of our JGroups messages sent back to us clusterChannel.setDiscardOwnMessages(true); clustered = true; logger.info("Portal chat is connected on JGroups channel '" + channelId + "'"); } else { logger.info("No 'portalchat.cluster.channel' specified in sakai.properties. JGroups will not be used and chat messages will not be replicated."); } } catch (Exception e) { logger.error("Error creating JGroups channel. Chat messages will now NOT BE KEPT IN SYNC", e); } int heartbeatMapSize = serverConfigurationService.getInt("portalchat.heartbeatmap.size",1000); heartbeatMap = new ConcurrentHashMap<String,Date>(heartbeatMapSize,0.75F,64); // SAK-20565. Get handles on the profile2 connections methods if available. If not, unset the connectionsAvailable flag. ComponentManager componentManager = org.sakaiproject.component.cover.ComponentManager.getInstance(); profileServiceObject = componentManager.get("org.sakaiproject.profile2.service.ProfileService"); if(profileServiceObject != null) { try { getConnectionsForUserMethod = profileServiceObject.getClass().getMethod("getConnectionsForUser",new Class[] {String.class}); try { Class personClass = Class.forName("org.sakaiproject.profile2.model.Person"); try { getUuidMethod = personClass.getMethod("getUuid",null); } catch(Exception e) { logger.warn("Failed to set getUuidMethod"); } try { Class clazz = Class.forName("org.sakaiproject.profile2.model.UserProfile"); setProfileMethod = personClass.getMethod("setProfile",new Class[] {clazz}); } catch(Exception e) { logger.warn("Failed to set setProfileMethod"); } try { Class clazz = Class.forName("org.sakaiproject.profile2.model.ProfilePrivacy"); setPrivacyMethod = personClass.getMethod("setPrivacy",new Class[] {clazz}); } catch(Exception e) { logger.warn("Failed to set setPrivacyMethod"); } try { Class clazz = Class.forName("org.sakaiproject.profile2.model.ProfilePreferences"); setPreferencesMethod = personClass.getMethod("setPreferences",new Class[] {clazz}); } catch(Exception e) { logger.warn("Failed to set setPreferencesMethod"); } } catch(Exception e) { logger.error("Failed to find Person class. Connections will NOT be available in portal chat.",e); connectionsAvailable = false; } } catch(Exception e) { logger.warn("Failed to set getConnectionsForUserMethod. Connections will NOT be available in portal chat."); connectionsAvailable = false; } } else { logger.warn("Failed to find ProfileService interface. Connections will NOT be available in portal chat."); connectionsAvailable = false; } } public void destroy() { if(logger.isDebugEnabled()) logger.debug("DESTROY!!!!!"); if(clusterChannel != null && clusterChannel.isConnected()) { // This calls disconnect() first clusterChannel.close(); } } /** * Uses reflection to call Profile2's connections method. * * @returns A list of Person instances cunningly disguised as lowly Objects */ private List<Object> getConnectionsForUser(String uuid) { List<Object> connections = new ArrayList<Object>(); if(connectionsAvailable == false) { return connections; } try { connections = (List<Object>) getConnectionsForUserMethod.invoke(profileServiceObject,new Object[] {uuid}); } catch(Exception e) { logger.error("Failed to invoke the getConnectionsForUser method. Returning an empty connections list ...", e); } List<Object> connectionsWithPermissions = new ArrayList<Object>(); for(Object personObject : connections) { String connectionUuid = null; try { connectionUuid = (String) getUuidMethod.invoke(personObject,null); // Null all the person stuff to reduce the download size if(setProfileMethod != null) { setProfileMethod.invoke(personObject,new Object[] {null}); } if(setPrivacyMethod != null) { setPrivacyMethod.invoke(personObject,new Object[] {null}); } if(setPreferencesMethod != null) { setPreferencesMethod.invoke(personObject,new Object[] {null}); } } catch(Exception e) { logger.error("Failed to invoke getUuid on a Person instance. Skipping this person ...",e); continue; } // Only add the connection if that person is allowed to use portal chat. if(portalChatPermittedHelper.checkChatPermitted(connectionUuid)) { connectionsWithPermissions.add(personObject); } } return connectionsWithPermissions; } public String getEntityPrefix() { return ENTITY_PREFIX; } public String[] getHandledOutputFormats() { return new String[] { Formats.TXT ,Formats.JSON}; } public Object getSampleEntity() { return new UserMessage(); } /** * New messages come in here. The recipient is indicated by the parameter 'to'. */ public String createEntity(EntityReference ref, Object entity, Map<String, Object> params) { User currentUser = userDirectoryService.getCurrentUser(); User anon = userDirectoryService.getAnonymousUser(); if(anon.equals(currentUser)) { throw new SecurityException("You must be logged in to use this service"); } String to = (String) params.get("to"); if(to == null) throw new IllegalArgumentException("You must supply a recipient"); if(to.equals(currentUser.getId())) { throw new IllegalArgumentException("You can't chat with yourself"); } Date now = new Date(); Date lastHeartbeat = null; lastHeartbeat = heartbeatMap.get(to); if(lastHeartbeat == null) return "OFFLINE"; if((now.getTime() - lastHeartbeat.getTime()) >= pollInterval) return "OFFLINE"; String message = (String) params.get("message"); if(message == null) throw new IllegalArgumentException("You must supply a message"); // Sanitise the message. XSS attacks. Unescape single quotes. They are valid. message = StringEscapeUtils.escapeHtml4( StringEscapeUtils.escapeEcmaScript(message)).replaceAll("\\\\'","'"); //message = message.replaceAll("\\\\'","'"); addMessageToMap(new UserMessage(currentUser.getId(), to, message)); if(clustered) { try { Message msg = new Message(null, null, MESSAGE_PREAMBLE + currentUser.getId() + ":" + to + ":" + message); clusterChannel.send(msg); } catch (Exception e) { logger.error("Error sending JGroups message", e); } } return "success"; } public String[] getHandledInputFormats() { return new String[] { Formats.HTML }; } public class UserMessage { public String from; public String to; public String content; public long timestamp; private UserMessage() { } private UserMessage(String from, String to, String content) { this.to = to; this.from = from; this.content = content; this.timestamp = (new Date()).getTime(); } } public class PortalChatUser { public String id; public String displayName; public boolean offline = false; public PortalChatUser(String id, String displayName, boolean offline) { this.id = id; this.displayName = displayName; this.offline = offline; } } /** * The JS client calls this to grab the latest data in one call. Connections, latest messages, online users * and present users (in a site) are all returned in one lump of JSON. If the online parameter is supplied and * true, a heartbeat is stamped for the sender as well. */ @EntityCustomAction(action = "latestData", viewKey = EntityView.VIEW_SHOW) public Map<String,Object> handleLatestData(EntityReference ref, Map<String,Object> params) { if(logger.isDebugEnabled()) logger.debug("handleLatestData"); User currentUser = userDirectoryService.getCurrentUser(); User anon = userDirectoryService.getAnonymousUser(); if(anon.equals(currentUser)) { return new HashMap<String,Object>(0); } String online = (String) params.get("online"); if(logger.isDebugEnabled()) logger.debug("online: " + online); if(online != null && "true".equals(online)) { if(logger.isDebugEnabled()) logger.debug(currentUser.getEid() + " is online. Stamping their heartbeat ..."); heartbeatMap.put(currentUser.getId(),new Date()); if(clustered) { if(logger.isDebugEnabled()) logger.debug("We are clustered. Propagating heartbeat ..."); Message msg = new Message(null, null, HEARTBEAT_PREAMBLE + currentUser.getId()); try { clusterChannel.send(msg); if(logger.isDebugEnabled()) logger.debug("Heartbeat message sent."); } catch (Exception e) { logger.error("Error sending JGroups heartbeat message", e); } } } else { if(logger.isDebugEnabled()) logger.debug(currentUser.getEid() + " is offline. Removing them from the message map ..."); synchronized(messageMap) { messageMap.remove(currentUser.getId()); } sendClearMessage(currentUser.getId()); if(logger.isDebugEnabled()) logger.debug(currentUser.getEid() + " is offline. Returning an empty data map ..."); return new HashMap<String,Object>(0); } List<PortalChatUser> presentUsers = new ArrayList<PortalChatUser>(); String siteId = (String) params.get("siteId"); if(logger.isDebugEnabled()) logger.debug("Site ID: " + siteId); if(siteId != null && siteId.length() > 0 && showSiteUsers) { // A site id has been specified, so we refresh our presence at the // location and retrieve the present users String location = siteId + "-presence"; presenceService.setPresence(location); List<User> presentSakaiUsers = presenceService.getPresentUsers(siteId + "-presence"); presentSakaiUsers.remove(currentUser); for(User user : presentSakaiUsers) { // Flag this user as offline if they can't access portal chat boolean offline = !portalChatPermittedHelper.checkChatPermitted(user.getId()); presentUsers.add(new PortalChatUser(user.getId(), user.getDisplayName(), offline)); } } List<Object> connections = getConnectionsForUser(currentUser.getId()); List<String> onlineConnections = new ArrayList<String>(connections.size()); Date now = new Date(); for(Object personObject : connections) { String uuid = null; try { uuid = (String) getUuidMethod.invoke(personObject,null); // Null all the person stuff to reduce the download size if(setProfileMethod != null) { setProfileMethod.invoke(personObject,new Object[] {null}); } if(setPrivacyMethod != null) { setPrivacyMethod.invoke(personObject,new Object[] {null}); } if(setPreferencesMethod != null) { setPreferencesMethod.invoke(personObject,new Object[] {null}); } } catch(Exception e) { logger.error("Failed to invoke getUuid on a Person instance. Skipping this person ...",e); continue; } Date lastHeartbeat = null; lastHeartbeat = heartbeatMap.get(uuid); if(lastHeartbeat == null) continue; if((now.getTime() - lastHeartbeat.getTime()) < pollInterval) { onlineConnections.add(uuid); } } List<UserMessage> messages = new ArrayList<UserMessage>(); String currentUserId = currentUser.getId(); synchronized(messageMap) { if(messageMap.containsKey(currentUserId)) { // Grab the user's messages messages = messageMap.get(currentUserId); // Now we can reset the replicated map. messageMap.remove(currentUserId); } sendClearMessage(currentUserId); } Map<String,Object> data = new HashMap<String,Object>(4); data.put("connections", connections); data.put("messages", messages); data.put("online", onlineConnections); data.put("showSiteUsers", showSiteUsers); data.put("presentUsers", presentUsers); data.put("connectionsAvailable", connectionsAvailable); return data; } private void sendClearMessage(String userId) { if(clustered) { try { if(logger.isDebugEnabled()) logger.debug("Sending messagMap clear message for " + userId + " ..."); Message msg = new Message(null, null, CLEAR_PREAMBLE + userId); clusterChannel.send(msg); } catch (Exception e) { logger.error("Error sending JGroups clear message", e); } } } @EntityCustomAction(action = "ping", viewKey = EntityView.VIEW_SHOW) public String handlePing(EntityReference ref) { User currentUser = userDirectoryService.getCurrentUser(); User anon = userDirectoryService.getAnonymousUser(); if(anon.equals(currentUser)) { throw new SecurityException("You must be logged in to use this service"); } String userId = ref.getId(); try { String email = userDirectoryService.getUser(userId).getEmail(); new EmailSender(email, rb.getFormattedMessage("email.subject", new String[]{service}), rb.getFormattedMessage("email.body", new String[]{currentUser.getDisplayName(), service, portalUrl})); } catch(Exception e) { throw new EntityException("Failed to send email",userId); } return "success"; } /** * Implements a threadsafe addition to the message map */ private void addMessageToMap(UserMessage m) { synchronized (messageMap) { List<UserMessage> current = messageMap.get(m.to); if (current != null) { List<UserMessage> copy = new ArrayList<UserMessage>(current.size()); copy.addAll(current); copy.add(m); messageMap.put(m.to, copy); } else { messageMap.put(m.to, Arrays.asList(m)); } } } private class EmailSender implements Runnable { private Thread runner; private String email; private String subject; private String message; public EmailSender(String email, String subject, String message) { this.email = email; this.subject = subject; this.message = message; runner = new Thread(this, "PC EmailSender thread"); runner.start(); } public synchronized void run() { try { final List<String> additionalHeaders = new ArrayList<String>(); additionalHeaders.add("Content-Type: text/plain; charset=ISO-8859-1"); final String emailFromAddress = "\"" + service + "\" <no-reply@" + serverName + ">"; emailService.send(emailFromAddress, email, subject, message, email, null, additionalHeaders); } catch (Exception e) { logger.error("sendEmail() failed for email: " + email,e); } } } /** * JGroups message listener. */ public void receive(Message msg) { Object o = msg.getObject(); if (o instanceof String) { String message = (String) o; if (message.startsWith(HEARTBEAT_PREAMBLE)) { String onlineUserId = message.substring(HEARTBEAT_PREAMBLE.length()); heartbeatMap.put(onlineUserId, new Date()); } else if (message.startsWith(MESSAGE_PREAMBLE)) { Address address = clusterChannel.getAddress(); String[] parts = message.split(":"); String from = parts[1]; String to = parts[2]; String m = parts[3]; addMessageToMap(new UserMessage(from, to, m)); } else if (message.startsWith(CLEAR_PREAMBLE)) { String userId = message.substring(CLEAR_PREAMBLE.length()); synchronized (messageMap) { messageMap.remove(userId); } } } } public void getState(OutputStream arg0) throws Exception { } public void setState(InputStream arg0) throws Exception { } public void block() { } public void suspect(Address arg0) { } public void unblock() { } public void viewAccepted(View arg0) { } }