// ---------------------------------------------------------------------------
// jWebSocket - ChannelPlugIn
// Copyright (c) 2010 Innotrade GmbH, jWebSocket.org
// ---------------------------------------------------------------------------
// This program is free software; you can redistribute it and/or modify it
// under the terms of the GNU Lesser General Public License as published by the
// Free Software Foundation; either version 3 of the License, or (at your
// option) any later version.
// This program is distributed in the hope that it will be useful, but WITHOUT
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
// FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for
// more details.
// You should have received a copy of the GNU Lesser General Public License along
// with this program; if not, see <http://www.gnu.org/licenses/lgpl.html>.
// ---------------------------------------------------------------------------
package org.jwebsocket.plugins.channels;
import java.util.Date;
import java.util.List;
import java.util.Random;
import javolution.util.FastList;
import org.apache.log4j.Logger;
import org.jwebsocket.api.PluginConfiguration;
import org.jwebsocket.api.WebSocketConnector;
import org.jwebsocket.api.WebSocketEngine;
import org.jwebsocket.config.JWebSocketCommonConstants;
import org.jwebsocket.config.JWebSocketServerConstants;
import org.jwebsocket.kit.CloseReason;
import org.jwebsocket.kit.PlugInResponse;
import org.jwebsocket.logging.Logging;
import org.jwebsocket.plugins.TokenPlugIn;
import org.jwebsocket.security.SecurityFactory;
import org.jwebsocket.security.User;
import org.jwebsocket.token.Token;
import org.jwebsocket.token.TokenFactory;
import org.jwebsocket.util.Tools;
/**
* Token based implementation of the channel plugin. It's based on a
* publisher/subscriber model where channels can be either used to publish the
* data by one or more registered publishers and subscribed by multiple
* subscribers. The operation of channel is best handled by channel sub-protocol
* that has to be followed by clients to publish data to the channel or
* subscribe for receiving the data from the channels.
*
************************ PUBLISHER OPERATION***********************************
*
* Token Type : <tt>publisher</tt>
* Namespace : <tt>org.jwebsocket.plugins.channel</tt>
*
* Token Key : <tt>event</tt>
* Token Value : <tt>[authorize][publish][stop]</tt>
*
* <tt>authorize</tt> event command is used for authorization of client before
* publishing a data to the channel, publisher client has to authorize itself
* using <tt>secret_key</tt>, <tt>access_key</tt> and <tt>login</tt> which is
* registered in the jWebSocket server system via configuration file or from
* other jWebSocket components.
*
* <tt>Token Request Includes:</tt>
*
* Token Key : <tt>channel<tt>
* Token Value : <tt>channel id to authorize for</tt>
*
* Token Key : <tt>secret_key<tt>
* Token Value : <tt>value of the secret key</tt>
*
* Token Key : <tt>access_key<tt>
* Token Value : <tt>value of the access key</tt>
*
* Token Key : <tt>login<tt>
* Token Value : <tt>login name or id of the jWebSocket registered user</tt>
*
* <tt>publish</tt>: publish event means publisher client has been authorized
* and ready to publish the data. Data is received from the token string of key
* <tt>data</tt>. If the channel registered is not started then it is started
* when publish command is received for the first time.
*
* <tt>Token Request Includes:</tt>
*
* Token Key : <tt>channel<tt>
* Token Value : <tt>channel id to publish the data</tt>
*
* Token Key : <tt>data<tt>
* Token Value : <tt>data to publish to the channel</tt>
*
* <tt>stop</tt>: stop event means proper shutdown of channel and no more data
* will be received from the publisher.
*
************************ SUBSCRIBER OPERATION *****************************************
*
* Token Type : <tt>subscriber</tt> Namespace :
* <tt>org.jwebsocket.plugins.channel</tt>
*
* Token Key : <tt>operation</tt> Token Value : <tt>[subscribe][unsubscribe]</tt>
*
* <tt>subscribe</tt> subscribe event is to register the client as a subscriber
* for the passed in channel and access_key if the channel is private and needs
* access_key for subscription
*
* <tt>Token Request Includes:</tt> Token Key : <tt>channel<tt>
* Token Value : <tt>channel id to publish the data</tt>
*
* Token Key : <tt>access_key<tt>
* Token Value : <tt>access_key value required for subscription</tt>
*
* <tt>unsubscribe</tt> removes the client from the channel so no data will be
* broadcasted to the unsuscribed clients.
*
* <tt>Token Request Includes:</tt> Token Key : <tt>channel<tt>
* Token Value : <tt>channel id to unsubscribe</tt>
*
* @author puran
* @version $Id$
*/
public class ChannelPlugIn extends TokenPlugIn {
/** logger */
private static Logger mLog = Logging.getLogger(ChannelPlugIn.class);
/** channel manager */
private ChannelManager mChannelManager = null;
/** namespace for channels */
private static final String NS_CHANNELS_DEFAULT = JWebSocketServerConstants.NS_BASE + ".plugins.channels";
/** empty string */
private static final String EMPTY_STRING = "";
/** publisher request string */
private static final String PUBLISHER = "publisher";
/** subscriber request string */
private static final String SUBSCRIBER = "subscriber";
/** channel plugin handshake protocol operation values */
private static final String AUTHORIZE = "authorize";
private static final String PUBLISH = "publish";
private static final String STOP = "stop";
private static final String SUBSCRIBE = "subscribe";
private static final String UNSUBSCRIBE = "unsubscribe";
private static final String GET_CHANNELS = "getChannels";
/** channel plugin handshake protocol parameters */
private static final String DATA = "data";
private static final String EVENT = "event";
private static final String ACCESS_KEY = "access_key";
private static final String SECRET_KEY = "secret_key";
private static final String CHANNEL = "channel";
private static final String CONNECTED = "connected";
/**
* Constructor with plugin config
*
* @param aConfiguration
* the plugin configuration for this PlugIn
*/
public ChannelPlugIn(PluginConfiguration aConfiguration) {
super(aConfiguration);
if (mLog.isDebugEnabled()) {
mLog.debug("Instantiating channel plug-in...");
}
// specify default name space
this.setNamespace(NS_CHANNELS_DEFAULT);
mChannelManager = ChannelManager.getChannelManager(aConfiguration.getSettings());
}
/**
* {@inheritDoc} When the engine starts perform the initialization of
* default and system channels and start it for accepting subscriptions.
*/
@Override
public void engineStarted(WebSocketEngine aEngine) {
if (mLog.isDebugEnabled()) {
mLog.debug("Engine started, starting system channels...");
}
try {
mChannelManager.startSystemChannels();
if (mLog.isInfoEnabled()) {
mLog.info("System channels started.");
}
} catch (ChannelLifeCycleException lEx) {
mLog.error("Failed to start system channels", lEx);
}
}
/**
* {@inheritDoc} Stops the system channels and clean up all the taken
* resources by those channels.
*/
@Override
public void engineStopped(WebSocketEngine aEngine) {
if (mLog.isDebugEnabled()) {
mLog.debug("Engine stopped, stopping system channels...");
}
try {
mChannelManager.stopSystemChannels();
if (mLog.isInfoEnabled()) {
mLog.info("System channels stopped.");
}
} catch (ChannelLifeCycleException lEx) {
mLog.error("Error stopping system channels", lEx);
}
}
/**
* {@inheritDoc}
*/
@Override
public void connectorStarted(WebSocketConnector aConnector) {
// set session id first, so that it can be processed in the
// connectorStarted method set session id first, so that it can be
// processed in the connectorStarted method
Random lRand = new Random(System.nanoTime());
aConnector.getSession().setSessionId(Tools.getMD5(aConnector.generateUID() + "." + lRand.nextInt()));
// call super connectorStarted
super.connectorStarted(aConnector);
// and send the welcome message incl. the session id
sendWelcome(aConnector);
}
/**
* {@inheritDoc}
*/
@Override
public void processToken(PlugInResponse aResponse, WebSocketConnector aConnector, Token aToken) {
String lType = aToken.getType();
String lNS = aToken.getNS();
if (lType != null && getNamespace().equals(lNS)) {
if (lType.equals(PUBLISHER)) {
handlePublisher(aConnector, aToken);
} else if (lType.equals(SUBSCRIBER)) {
handleSubscriber(aConnector, aToken);
} else {
// ignore
}
}
}
/**
* Handles all the operation related to subscriber based on the subscriber
* commands
*
* @param aConnector
* the subscriber connector object
* @param aToken
* the the publisher connector object
*/
private void handleSubscriber(WebSocketConnector aConnector, Token aToken) {
String lEvent = aToken.getString(EVENT);
if (SUBSCRIBE.equals(lEvent)) {
subscribe(aConnector, aToken);
} else if (UNSUBSCRIBE.equals(lEvent)) {
unsubscribe(aConnector, aToken);
} else if (GET_CHANNELS.equals(lEvent)) {
getChannels(aConnector, aToken);
} else {
// no command, close the connector
// TODO: Don't close connection here! We'll introduce a "not processed" token.
aConnector.stopConnector(CloseReason.CLIENT);
}
}
/**
* Handles the operations related to the publisher.
*
* @param aConnector
* the connector for this publisher
* @param aToken
* the token data
*/
private void handlePublisher(WebSocketConnector aConnector, Token aToken) {
String lChannelId = aToken.getString(CHANNEL);
if (lChannelId == null || EMPTY_STRING.equals(lChannelId)) {
sendError(aConnector, lChannelId, "Channel value not specified.");
return;
}
if (!aToken.getNS().equals(getNamespace())) {
sendError(aConnector, lChannelId, "Namespace '" + aToken.getNS() + "' not correct.");
return;
}
String lEvent = aToken.getString(EVENT);
if (AUTHORIZE.equals(lEvent)) {
// perform the authorization
authorize(aConnector, aToken, lChannelId);
} else if (PUBLISH.equals(lEvent)) {
Channel lChannel = mChannelManager.getChannel(lChannelId);
Publisher lPublisher = mChannelManager.getPublisher(
aConnector.getSession().getSessionId());
if (lPublisher == null || !lPublisher.isAuthorized()) {
sendError(aConnector, lChannelId, "Connector '" + aConnector.getId()
+ "': access denied, publisher not authorized for channelId '"
+ lChannelId + "'");
return;
}
String lData = aToken.getString(DATA);
Token lToken = mChannelManager.getChannelSuccessToken(
aConnector, lChannelId, ChannelEventEnum.PUBLISH);
lToken.setString("data", lData);
mChannelManager.publishToLoggerChannel(lToken);
lChannel.broadcastToken(lToken);
} else if (STOP.equals(lEvent)) {
Publisher lPublisher = mChannelManager.getPublisher(
aConnector.getSession().getSessionId());
Channel lChannel = mChannelManager.getChannel(lChannelId);
if (lChannel == null) {
sendError(aConnector, lChannelId, "'" + aConnector.getId()
+ "' channel not found for given channelId '"
+ lChannelId + "'");
return;
}
if (lPublisher == null || !lPublisher.isAuthorized()) {
sendError(aConnector, lChannelId, "Connector: " + aConnector.getId()
+ ": access denied, publisher not authorized for channelId '"
+ lChannelId + "'");
return;
}
try {
lChannel.stop(lPublisher.getLogin());
Token lSuccessToken = mChannelManager.getChannelSuccessToken(
aConnector, lChannelId, ChannelEventEnum.STOP);
sendTokenAsync(aConnector, aConnector, lSuccessToken);
} catch (ChannelLifeCycleException lEx) {
mLog.error("Error stopping channel '" + lChannelId
+ "' from publisher "
+ lPublisher.getId() + "'", lEx);
//publish to logger channel
Token lErrorToken = mChannelManager.getErrorToken(aConnector,
lChannelId, "'" + aConnector.getId()
+ "' Error stopping channel '" + lChannelId
+ "' from publisher '" + lPublisher.getId() + "'");
mChannelManager.publishToLoggerChannel(lErrorToken);
sendTokenAsync(aConnector, aConnector, lErrorToken);
}
}
}
/**
* Authorize the publisher before publishing the data to the channel
* @param aConnector the connector associated with the publisher
* @param aToken the token received from the publisher client
* @param aChannelId the channel id
*/
private void authorize(WebSocketConnector aConnector, Token aToken,
String aChannelId) {
String lAccessKey = aToken.getString(ACCESS_KEY);
String lSecretKey = aToken.getString(SECRET_KEY);
String lLogin = aToken.getString("login");
User lUser = SecurityFactory.getUser(lLogin);
if (lUser == null) {
sendError(aConnector, aChannelId,
"'" + aConnector.getId()
+ "' Authorization failed for channel '"
+ aChannelId
+ "', channel owner is not registered in the jWebSocket server system");
return;
}
if (lSecretKey == null || lAccessKey == null) {
sendError(aConnector, aChannelId, "'" + aConnector.getId()
+ "' Authorization failed, secret_key/access_key pair value is not correct");
return;
} else {
Channel lChannel = mChannelManager.getChannel(aChannelId);
if (lChannel == null) {
sendError(aConnector, aChannelId, "'" + aConnector.getId()
+ "' channel not found for given channelId '" + aChannelId + "'");
return;
}
Publisher lPublisher = authorizePublisher(aConnector, lChannel, lUser, lSecretKey, lAccessKey);
if (!lPublisher.isAuthorized()) {
// couldn't authorize the publisher
sendError(aConnector, aChannelId,
"'" + aConnector.getId()
+ "' Authorization failed for channel '"
+ aChannelId + "'");
} else {
lChannel.addPublisher(lPublisher);
Token lResponseToken = mChannelManager.getChannelSuccessToken(aConnector, aChannelId,
ChannelEventEnum.AUTHORIZE);
mChannelManager.publishToLoggerChannel(lResponseToken);
// send the success response
sendToken(aConnector, aConnector, lResponseToken);
}
}
}
/**
* Validates the publisher based on the accessKey and secretKey. If the
* authorization fails the publisher object will have flag authorized set to
* false.
*
* @param aConnector
* the connector for the publisher
* @param aChannel
* the channel to publish
* @param aUser
* the user object that represents the publisher
* @param aSecretKey
* the secretKey value from the publisher
* @param aAccessKey
* the accessKey value from the publisher
* @return the publisher object
*/
private Publisher authorizePublisher(WebSocketConnector aConnector,
Channel aChannel, User aUser, String aSecretKey,
String aAccessKey) {
Publisher lPublisher = null;
Date lNow = new Date();
// TODO: Commented our by Alex: Why may only the owner publish something ?
if (aChannel.getAccessKey().equals(aAccessKey)
&& aChannel.getSecretKey().equals(aSecretKey) /* && user.getLoginname().equals(channel.getOwner())*/) {
lPublisher = new Publisher(aConnector, aUser.getLoginname(), aChannel.getId(), lNow, lNow, true);
mChannelManager.storePublisher(lPublisher);
} else {
lPublisher = new Publisher(aConnector, aUser.getLoginname(), aChannel.getId(), lNow, lNow, false);
}
return lPublisher;
}
/**
* Subscribes the connector to the channel given by the subscriber
*
* @param aConnector
* the connector for this client
* @param aToken
* the request token object
*/
private void subscribe(WebSocketConnector aConnector, Token aToken) {
if (mLog.isDebugEnabled()) {
mLog.debug("Processing 'subscribe'...");
}
String lChannelId = aToken.getString(CHANNEL);
if (lChannelId == null || EMPTY_STRING.equals(lChannelId)) {
sendError(aConnector, null, "Channel value is null");
return;
}
String lAccessKey = aToken.getString(ACCESS_KEY);
Channel lChannel = mChannelManager.getChannel(lChannelId);
if (lChannel == null
|| lChannel.getState() == Channel.ChannelState.STOPPED) {
sendError(aConnector, lChannelId,
"Channel doesn't exists for the channelId: '"
+ lChannelId + "'");
return;
}
if (lChannel.isPrivateChannel() && EMPTY_STRING.equals(lAccessKey)) {
sendError(aConnector, lChannelId,
"Access_key required for subscribing to a private channel: '"
+ lChannelId + "'");
return;
}
if (lChannel.isPrivateChannel() && !EMPTY_STRING.equals(lAccessKey)) {
if (lChannel.getAccessKey().equals(lAccessKey)) {
sendError(aConnector, lChannelId,
"Access_key not valid for the given channel id: '"
+ lChannelId + "'");
return;
}
}
Subscriber lSubscriber = mChannelManager.getSubscriber(aConnector.getId());
Date lDate = new Date();
if (lSubscriber == null) {
lSubscriber = new Subscriber(aConnector, getServer(), lDate);
}
// Added by Alex: If already subscribed, return error message
Token lResponseToken = createResponse(aToken);
if (lSubscriber.getChannels().contains(lChannelId)) {
lResponseToken.setInteger("code", -1);
lResponseToken.setString("msg",
"Client already subscribed to channel '"
+ lChannelId + "'.");
} else {
lChannel.subscribe(lSubscriber, mChannelManager);
lResponseToken.setString("subscribe", "ok");
}
// send the success response
sendToken(aConnector, aConnector, lResponseToken);
}
/**
* Method for subscribers to unsubscribe from the channel. If the unsubscribe
* operation is successful it sends the unsubscriber - ok response to the
* client.
*
* @param aConnector
* the connector associated with the subscriber
* @param aToken
* the token object
*/
private void unsubscribe(WebSocketConnector aConnector, Token aToken) {
if (mLog.isDebugEnabled()) {
mLog.debug("Processing 'unsubscribe'...");
}
String lChannelId = aToken.getString(CHANNEL);
if (lChannelId == null
|| EMPTY_STRING.equals(lChannelId)) {
sendError(aConnector, null, "Channel value is null");
return;
}
Token lResponseToken = createResponse(aToken);
Subscriber lSubscriber =
mChannelManager.getSubscriber(aConnector.getId());
if (lSubscriber != null) {
Channel lChannel = mChannelManager.getChannel(lChannelId);
if (lChannel != null) {
lChannel.unsubscribe(lSubscriber, mChannelManager);
lResponseToken.setString("unsubscribe", "ok");
}
} else {
lResponseToken.setInteger("code", -1);
lResponseToken.setString("msg",
"Client not subscribed to channel '"
+ lChannelId + "'.");
}
// send the success response
sendToken(aConnector, aConnector, lResponseToken);
}
/**
* Returns all channels available to the client
*
* @param aConnector
* the connector for this client
* @param aToken
* the request token object
*/
private void getChannels(WebSocketConnector aConnector, Token aToken) {
if (mLog.isDebugEnabled()) {
mLog.debug("Processing 'getChannels'...");
}
// TODO: Here we probably have to introduce restrictions
// not all clients should be allowed to retreive system or private channels
Token lResponseToken = createResponse(aToken);
List lPublic = new FastList();
lPublic.addAll(mChannelManager.getPublicChannels().keySet());
lResponseToken.setList("publicChannels", lPublic);
List lSystem = new FastList();
lSystem.addAll(mChannelManager.getSystemChannels().keySet());
lResponseToken.setList("systemChannels", lSystem);
List lPrivate = new FastList();
lPrivate.addAll(mChannelManager.getPrivateChannels().keySet());
lResponseToken.setList("privateChannels", lPrivate);
// send the response
sendToken(aConnector, aConnector, lResponseToken);
}
/**
* {@inheritDoc}
*/
@Override
public void connectorStopped(WebSocketConnector aConnector, CloseReason aCloseReason) {
// unsubscribe from the channel
Subscriber lSubscriber = mChannelManager.getSubscriber(aConnector.getId());
for (String lChannelId : lSubscriber.getChannels()) {
Channel lChannel = mChannelManager.getChannel(lChannelId);
if (lChannel != null) {
lChannel.unsubscribe(lSubscriber, mChannelManager);
}
}
mChannelManager.removeSubscriber(lSubscriber);
}
/**
* Send the error response to the client as well as publish the error log to
* the logger channel for monitoring
*
* @param aConnector
* the target connector object
* @param aChannel
* the target channel id, can be null if channel is not
* initialized
* @param error
* the error message
*/
private void sendError(WebSocketConnector aConnector, String aChannel, String aError) {
Token lErrorToken = mChannelManager.getErrorToken(
aConnector, aChannel, aError);
// publish to logger channel
mChannelManager.publishToLoggerChannel(lErrorToken);
// send the error to the client
sendTokenAsync(aConnector, aConnector, lErrorToken);
}
/**
* Send connected message to the publisher/subscriber after successful
* session id creation remember that this doesn't mean the client the
* publisher or subscriber is authorized
*
* @param aConnector
* the connector object
*/
private void sendWelcome(WebSocketConnector aConnector) {
if (mLog.isDebugEnabled()) {
mLog.debug("Sending connected message to the channels");
}
// send "welcome" token to client
Token lWelcome = TokenFactory.createToken(CONNECTED);
lWelcome.setString("vendor", JWebSocketCommonConstants.VENDOR);
lWelcome.setString("version", JWebSocketServerConstants.VERSION_STR);
// here the session id is MANDATORY! to pass to the client!
lWelcome.setString("usid", aConnector.getSession().getSessionId());
lWelcome.setString("sourceId", aConnector.getId());
// if a unique node id is specified for the client include that
String lNodeId = aConnector.getNodeId();
if (lNodeId != null) {
lWelcome.setString("unid", lNodeId);
}
lWelcome.setInteger("timeout", aConnector.getEngine().getConfiguration().getTimeout());
ChannelManager.getLoggerChannel().broadcastToken(lWelcome);
sendTokenAsync(aConnector, aConnector, lWelcome);
}
}