Commit 20581e3b authored by Gaston Dombiak's avatar Gaston Dombiak Committed by gato

Initial version. JM-450

git-svn-id: http://svn.igniterealtime.org/svn/repos/messenger/trunk@3024 b35dd754-fafc-0310-a699-88a17e54d16e
parent 4b0009a9
/**
* $Revision: 3023 $
* $Date: 2005-11-02 18:00:15 -0300 (Wed, 02 Nov 2005) $
*
* Copyright (C) 2005 Jive Software. All rights reserved.
*
* This software is published under the terms of the GNU Public License (GPL),
* a copy of which is included in this distribution.
*/
package org.jivesoftware.messenger.commands;
import org.dom4j.Element;
import java.util.List;
/**
* An ad-hoc command is a stateless object responsbile for executing the provided service. Each
* subclass will only have one instance that will be shared across all users sessions. Therefore,
* it is important to not keep any information related to executions as permanent data
* (i.e. as instance or static variables). Each command has a <tt>code</tt> that should be
* unique within a given JID.<p>
*
* Commands may have zero or more stages. Each stage is usually used for gathering information
* required for the command execution. Users are able to move forward or backward across the
* different stages. Commands may not be cancelled while they are beig executed. However, users
* may request the "cancel" action when submiting a stage response indicating that the command
* execution should be aborted. Thus, releasing any collected information. Commands that require
* user interaction (i.e. have more than one stage) will have to provide the data forms the user
* must complete in each stage and the allowed actions the user might perform during each stage
* (e.g. go to the previous stage or go to the next stage).
*
* @author Gaston Dombiak
*/
abstract class AdHocCommand {
/**
* Label of the command. This information may be used to display the command as a button
* or menu item.
*/
private String label = getDefaultLabel();
/**
* Flag that indicates if the default permission schema defined in AdHocCommandHandler should
* be overridden while the permission schema defined for this command.
*/
private boolean overridePermissions;
public AdHocCommand() {
}
public String getLabel() {
return label;
}
public void setLabel(String label) {
this.label = label;
}
public boolean isOverridePermissions() {
return overridePermissions;
}
public void setOverridePermissions(boolean overridePermissions) {
this.overridePermissions = overridePermissions;
}
/**
* Returns the unique identifier for this command for the containing JID. The code will
* be used as the node in the disco#items or the node when executing the command.
*
* @return the unique identifier for this command for the containing JID.
*/
public abstract String getCode();
/**
* Returns the default label used for describing this commmand. Admins can later use
* {@link #setLabel(String)} to set a new label and reset to the default value at any time.
*
* @return the default label used for describing this commmand.
*/
public abstract String getDefaultLabel();
/**
* Returns the max number of stages for this command. The number of stages may vary according
* to the collected data in previous stages. Therefore, a SessionData object is passed as a
* parameter. When the max number of stages has been reached then the command is ready to
* be executed.
*
* @param data the gathered data through the command stages or <tt>null</tt> if the
* command does not have stages or the requester is requesting the execution for the
* first time.
* @return the max number of stages for this command.
*/
public abstract int getMaxStages(SessionData data);
/**
* Executes the command with the specified session data.
*
* @param data the gathered data through the command stages or <tt>null</tt> if the
* command does not have stages.
* @return a reported data or note element with the answer of the execution.
*/
public abstract Element execute(SessionData data);
/**
* Adds to the command element the data form or notes required by the current stage. The
* current stage is specified in the SessionData. This method will never be invoked for
* commands that have no stages.
*
* @param data the gathered data through the command stages or <tt>null</tt> if the
* command does not have stages or the requester is requesting the execution for the
* first time.
* @param command the command element to be sent to the command requester.
*/
protected abstract void addStageInformation(SessionData data, Element command);
/**
* Returns a collection with the allowed actions based on the current stage as defined
* in the SessionData. Possible actions are: <tt>prev</tt>, <tt>next</tt> and <tt>complete</tt>.
* This method will never be invoked for commands that have no stages.
*
* @param data the gathered data through the command stages or <tt>null</tt> if the
* command does not have stages or the requester is requesting the execution for the
* first time.
* @return a collection with the allowed actions based on the current stage as defined
* in the SessionData.
*/
protected abstract List<Action> getActions(SessionData data);
/**
* Returns which of the actions available for the current stage is considered the equivalent
* to "execute". When the requester sends his reply, if no action was defined in the command
* then the action will be assumed "execute" thus assuming the action returned by this
* method. This method will never be invoked for commands that have no stages.
*
* @param data the gathered data through the command stages or <tt>null</tt> if the
* command does not have stages or the requester is requesting the execution for the
* first time.
* @return which of the actions available for the current stage is considered the equivalent
* to "execute".
*/
protected abstract Action getExecuteAction(SessionData data);
/**
* Increments the stage number by one and adds to the command element the new data form and
* new allowed actions that the user might perform.
*
* @param data the gathered data through the command stages or <tt>null</tt> if the
* command does not have stages or the requester is requesting the execution for the
* first time.
* @param command the command element to be sent to the command requester.
*/
public void addNextStageInformation(SessionData data, Element command) {
// Increment the stage number to the next stage
data.setStage(data.getStage() + 1);
// Return the data form of the current stage to the command requester. The
// requester will need to specify the action to follow (e.g. execute, prev,
// cancel, etc.) and complete the form is going "forward"
addStageInformation(data, command);
// Include the available actions at this stage
addStageActions(data, command);
}
/**
* Decrements the stage number by one and adds to the command the data form and allowed
* actions that the user might perform of the previous stage.
*
* @param data the gathered data through the command stages or <tt>null</tt> if the
* command does not have stages or the requester is requesting the execution for the
* first time.
* @param command the command element to be sent to the command requester.
*/
public void addPreviousStageInformation(SessionData data, Element command) {
// Decrement the stage number to the previous stage
data.setStage(data.getStage() - 1);
// Return the data form of the current stage to the command requester. The
// requester will need to specify the action to follow (e.g. execute, prev,
// cancel, etc.) and complete the form is going "forward"
addStageInformation(data, command);
// Include the available actions at this stage
addStageActions(data, command);
}
/**
* Adds the allowed actions to follow from the current stage. Possible actions are:
* <tt>prev</tt>, <tt>next</tt> and <tt>complete</tt>.
*
* @param data the gathered data through the command stages or <tt>null</tt> if the
* command does not have stages or the requester is requesting the execution for the
* first time.
* @param command the command element to be sent to the command requester.
*/
protected void addStageActions(SessionData data, Element command) {
// Add allowed actions to the response
Element actions = command.addElement("actions");
List<Action> validActions = getActions(data);
for (AdHocCommand.Action action : validActions) {
actions.addElement(action.name());
}
Action executeAction = getExecuteAction(data);
// Add default execute action to the response
actions.addAttribute("execute", executeAction.name());
// Store the allowed actions that the user can follow from this stage
data.setAllowedActions(validActions);
// Store the default execute action to follow if the user does not specify an
// action in his command
data.setExecuteAction(executeAction);
}
public enum Status {
/**
* The command is being executed.
*/
executing,
/**
* The command has completed. The command session has ended.
*/
completed,
/**
* The command has been canceled. The command session has ended.
*/
canceled;
}
public enum Action {
/**
* The command should be executed or continue to be executed. This is the default value.
*/
execute,
/**
* The command should be canceled.
*/
cancel,
/**
* The command should be digress to the previous stage of execution.
*/
prev,
/**
* The command should progress to the next stage of execution.
*/
next,
/**
* The command should be completed (if possible).
*/
complete;
}
}
/**
* $Revision: 3023 $
* $Date: 2005-11-02 18:00:15 -0300 (Wed, 02 Nov 2005) $
*
* Copyright (C) 2005 Jive Software. All rights reserved.
*
* This software is published under the terms of the GNU Public License (GPL),
* a copy of which is included in this distribution.
*/
package org.jivesoftware.messenger.commands;
import org.dom4j.DocumentHelper;
import org.dom4j.Element;
import org.dom4j.QName;
import org.jivesoftware.messenger.IQHandlerInfo;
import org.jivesoftware.messenger.XMPPServer;
import org.jivesoftware.messenger.auth.UnauthorizedException;
import org.jivesoftware.messenger.disco.*;
import org.jivesoftware.messenger.forms.spi.XDataFormImpl;
import org.jivesoftware.messenger.handler.IQHandler;
import org.jivesoftware.util.JiveGlobals;
import org.jivesoftware.util.StringUtils;
import org.xmpp.forms.DataForm;
import org.xmpp.forms.FormField;
import org.xmpp.packet.IQ;
import org.xmpp.packet.JID;
import org.xmpp.packet.PacketError;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
/**
* An AdHocCommandHandler is responsbile for providing discoverable information about the
* supported commands and for handling commands requests. This is an implementation of JEP-50:
* Ad-Hoc Commands.<p>
*
* Ad-hoc commands that require user interaction will have one or more stages. For each stage the
* user will complete a data form and send it back to the server. The data entered by the user is
* kept in a SessionData. Instances of {@link AdHocCommand} are stateless. In order to prevent
* "bad" users from consuming all system memory there exists a limit of simultaneous commands that
* a user might perform. Configure the system property <tt>"xmpp.command.limit"</tt> to control
* this limit. User sessions will also timeout and their data destroyed if they have not been
* executed within a time limit since the session was created. The default timeout value is 10
* minutes. The timeout value can be modified by setting the system property
* <tt>"xmpp.command.timeout"</tt>.<p>
*
* New commands can be added dynamically by sending the message {@link #addCommand(AdHocCommand)}.
* The command will immediatelly appear in the disco#items list and might be executed by those
* users with enough execution permissions.
*
* @author Gaston Dombiak
*/
public class AdHocCommandHandler extends IQHandler
implements ServerFeaturesProvider, DiscoInfoProvider, DiscoItemsProvider {
private static final String NAMESPACE = "http://jabber.org/protocol/commands";
private String serverName;
private IQHandlerInfo info;
private IQDiscoInfoHandler infoHandler;
private IQDiscoItemsHandler itemsHandler;
/**
* Map that holds the offered commands by this server. Note: Key=commandCode, Value=command.
* commandCode matches the node attribute sent by command requesters.
*/
private Map<String, AdHocCommand> commands = new ConcurrentHashMap<String, AdHocCommand>();
/**
* Map that holds the number of command sessions of each requester.
* Note: Key=requester full's JID, Value=number of sessions
*/
private Map<String, AtomicInteger> sessionsCounter = new ConcurrentHashMap<String, AtomicInteger>();
/**
* Map that holds the command sessions. Used mainly to quickly locate a SessionData.
* Note: Key=sessionID, Value=SessionData
*/
private Map<String, SessionData> sessions = new ConcurrentHashMap<String, SessionData>();
public AdHocCommandHandler() {
super("Ad-Hoc Commands Handler");
info = new IQHandlerInfo("command", NAMESPACE);
}
public IQ handleIQ(IQ packet) throws UnauthorizedException {
IQ reply = IQ.createResultIQ(packet);
Element iqCommand = packet.getChildElement();
// Only packets of type SET can be processed
if (!IQ.Type.set.equals(packet.getType())) {
// Answer a bad_request error
reply.setChildElement(iqCommand.createCopy());
reply.setError(PacketError.Condition.bad_request);
return reply;
}
String sessionid = iqCommand.attributeValue("sessionid");
String commandCode = iqCommand.attributeValue("node");
String from = packet.getFrom().toString();
AdHocCommand command = commands.get(commandCode);
if (sessionid == null) {
// A new execution request has been received. Check that the command exists
if (command == null) {
// Requested command does not exist so return item_not_found error.
reply.setChildElement(iqCommand.createCopy());
reply.setError(PacketError.Condition.item_not_found);
}
else {
// TODO Check that the requester has enough permission. Answer forbidden error if requester permissions are not enough
// Create new session ID
sessionid = StringUtils.randomString(15);
Element childElement = reply.setChildElement("command", NAMESPACE);
if (command.getMaxStages(null) == 0) {
// The command does not require any user interaction (returns results only)
// Execute the command and return the execution result which may be a
// data form (i.e. report data) or a note element
Element answer = command.execute(null);
childElement.addAttribute("sessionid", sessionid);
childElement.addAttribute("node", commandCode);
childElement.addAttribute("status", AdHocCommand.Status.completed.name());
// Add the execution result to the reply
childElement.add(answer);
}
else {
// The command requires user interactions (ie. has stages)
// Check that the user has not excedded the limit of allowed simultaneous
// command sessions.
AtomicInteger counter = sessionsCounter.get(from);
if (counter == null) {
synchronized (from.intern()) {
counter = sessionsCounter.get(from);
if (counter == null) {
counter = new AtomicInteger(0);
sessionsCounter.put(from, counter);
}
}
}
int limit = JiveGlobals.getIntProperty("xmpp.command.limit", 100);
if (counter.incrementAndGet() > limit) {
counter.decrementAndGet();
// Answer a not_allowed error since the user has exceeded limit. This
// checking prevents bad users from consuming all the system memory by not
// allowing them to create infinite simultaneous command sessions.
reply.setChildElement(iqCommand.createCopy());
reply.setError(PacketError.Condition.not_allowed);
return reply;
}
// Originate a new command session.
SessionData session = new SessionData(sessionid);
sessions.put(sessionid, session);
// Add to the child element the data form the user must complete and
// the allowed actions
command.addNextStageInformation(null, childElement);
}
}
}
else {
// An execution session already exists and the user has requested to perform a
// certain action.
String action = iqCommand.attributeValue("action");
SessionData session = sessions.get(sessionid);
// Check that a Session exists for the specified sessionID
if (session == null) {
// Answer a bad_request error (bad-sessionid)
reply.setChildElement(iqCommand.createCopy());
reply.setError(PacketError.Condition.bad_request);
return reply;
}
// Check if the Session data has expired (default is 10 minutes)
int timeout = JiveGlobals.getIntProperty("xmpp.command.timeout", 10 * 60 * 1000);
if (System.currentTimeMillis() - session.getCreationStamp() > timeout) {
// TODO Check all sessions that might have timed out (use another thread?)
// Remove the old session
removeSessionData(sessionid, from);
// Answer a not_allowed error (session-expired)
reply.setChildElement(iqCommand.createCopy());
reply.setError(PacketError.Condition.not_allowed);
return reply;
}
synchronized (sessionid.intern()) {
// Check if the user is requesting to cancel the command
if (AdHocCommand.Action.cancel.name().equals(action)) {
// User requested to cancel command execution so remove the session data
removeSessionData(sessionid, from);
// Generate a canceled confirmation response
Element childElement = reply.setChildElement("command", NAMESPACE);
childElement.addAttribute("sessionid", sessionid);
childElement.addAttribute("node", commandCode);
childElement.addAttribute("status", AdHocCommand.Status.canceled.name());
}
// If the user didn't specify an action then follow the default execute action
if (action == null || AdHocCommand.Action.execute.name().equals(action)) {
action = session.getExecuteAction().name();
}
// Check that the specified action was previously offered
if (!session.isValidAction(action)) {
// Answer a bad_request error (bad-action)
reply.setChildElement(iqCommand.createCopy());
reply.setError(PacketError.Condition.bad_request);
return reply;
}
else if (AdHocCommand.Action.prev.name().equals(action)) {
// Move to the previous stage and add to the child element the data form
// the user must complete and the allowed actions of the previous stage
Element childElement = reply.setChildElement("command", NAMESPACE);
childElement.addAttribute("sessionid", sessionid);
childElement.addAttribute("node", commandCode);
childElement.addAttribute("status", AdHocCommand.Status.executing.name());
command.addPreviousStageInformation(session, childElement);
}
else if (AdHocCommand.Action.next.name().equals(action)) {
// Store the completed form in the session data
saveCompletedForm(iqCommand, session);
// Move to the next stage and add to the child element the new data form
// the user must complete and the new allowed actions
Element childElement = reply.setChildElement("command", NAMESPACE);
childElement.addAttribute("sessionid", sessionid);
childElement.addAttribute("node", commandCode);
childElement.addAttribute("status", AdHocCommand.Status.executing.name());
command.addNextStageInformation(session, childElement);
}
else if (AdHocCommand.Action.complete.name().equals(action)) {
// Store the completed form in the session data
saveCompletedForm(iqCommand, session);
// Execute the command and return the execution result which may be a
// data form (i.e. report data) or a note element
Element answer = command.execute(session);
Element childElement = reply.setChildElement("command", NAMESPACE);
childElement.addAttribute("sessionid", sessionid);
childElement.addAttribute("node", commandCode);
childElement.addAttribute("status", AdHocCommand.Status.completed.name());
// Add the execution result to the reply
childElement.add(answer);
// Command has been executed so remove the session data
removeSessionData(sessionid, from);
}
}
}
return reply;
}
/**
* Stores in the SessionData the fields and their values as specified in the completed
* data form by the user.
*
* @param iqCommand the command element containing the data form element.
* @param session the SessionData for this command execution.
*/
private void saveCompletedForm(Element iqCommand, SessionData session) {
Element formElement = iqCommand.element(QName.get("x", "jabber:x:data"));
if (formElement != null) {
// Generate a Map with the variable names and variables values
Map<String, List<String>> data = new HashMap<String, List<String>>();
DataForm dataForm = new DataForm(formElement);
for (FormField field : dataForm.getFields()) {
data.put(field.getVariable(), field.getValues());
}
// Store the variables and their values in the session data
session.addStageForm(data);
}
}
/**
* Releases the data kept for the command execution whose id is sessionid. The number of
* commands executions currently being executed by the user (full JID) will be decreased.
*
* @param sessionid id of the session that identifies this command execution.
* @param from the full JID of the command requester.
*/
private void removeSessionData(String sessionid, String from) {
sessions.remove(sessionid);
if (sessionsCounter.get(from).decrementAndGet() <= 0) {
// Remove the AtomicInteger when no commands are being executed
sessionsCounter.remove(from);
}
}
public IQHandlerInfo getInfo() {
return info;
}
public Iterator<String> getFeatures() {
ArrayList<String> features = new ArrayList<String>();
features.add(NAMESPACE);
return features.iterator();
}
public Iterator<Element> getIdentities(String name, String node, JID senderJID) {
ArrayList<Element> identities = new ArrayList<Element>();
Element identity = DocumentHelper.createElement("identity");
identity.addAttribute("category", "automation");
identity.addAttribute("type", NAMESPACE.equals(node) ? "command-list" : "command-node");
identities.add(identity);
return identities.iterator();
}
public Iterator<String> getFeatures(String name, String node, JID senderJID) {
return Arrays.asList(NAMESPACE, "jabber:x:data").iterator();
}
public XDataFormImpl getExtendedInfo(String name, String node, JID senderJID) {
return null;
}
public boolean hasInfo(String name, String node, JID senderJID) {
if (NAMESPACE.equals(node)) {
return true;
}
else {
// TODO Should we include permission checking? Wait for answer from mailing list
return commands.containsKey(node);
}
}
public Iterator<Element> getItems(String name, String node, JID senderJID) {
List<Element> answer = new ArrayList<Element>();
if (!NAMESPACE.equals(node)) {
answer = Collections.emptyList();
}
else {
Element item;
for (AdHocCommand command : commands.values()) {
// TODO Only include commands that the sender can invoke (i.e. has enough permissions)
item = DocumentHelper.createElement("item");
item.addAttribute("jid", serverName);
item.addAttribute("node", command.getCode());
item.addAttribute("name", command.getLabel());
answer.add(item);
}
}
return answer.iterator();
}
public void initialize(XMPPServer server) {
super.initialize(server);
serverName = server.getServerInfo().getName();
infoHandler = server.getIQDiscoInfoHandler();
itemsHandler = server.getIQDiscoItemsHandler();
}
public void start() throws IllegalStateException {
super.start();
infoHandler.setServerNodeInfoProvider(NAMESPACE, this);
itemsHandler.setServerNodeInfoProvider(NAMESPACE, this);
// Add the "out of the box" commands
addDefaultCommands();
}
public void stop() {
super.stop();
infoHandler.removeServerNodeInfoProvider(NAMESPACE);
itemsHandler.removeServerNodeInfoProvider(NAMESPACE);
// Stop commands
for (AdHocCommand command : commands.values()) {
stopCommand(command);
}
}
/**
* Adds a new command to the list of supported ad-hoc commands by this server. The new
* command will appear in the discoverable items list and will be executed for those users
* with enough permission.
*
* @param command the new ad-hoc command to add.
*/
public void addCommand(AdHocCommand command) {
commands.put(command.getCode(), command);
startCommand(command);
}
/**
* Removes the command from the list of ad-hoc commands supported by this server. The command
* will no longer appear in the discoverable items list.
*
* @param command the ad-hoc command to remove.
*/
public void removeCommand(AdHocCommand command) {
if (commands.remove(command.getCode()) != null) {
stopCommand(command);
}
}
private void addDefaultCommands() {
// TODO Complete when out of the box commands are implemented
//addCommand(new TimeCommand());
}
private void startCommand(AdHocCommand command) {
infoHandler.setServerNodeInfoProvider(command.getCode(), this);
itemsHandler.setServerNodeInfoProvider(command.getCode(), this);
}
private void stopCommand(AdHocCommand command) {
infoHandler.removeServerNodeInfoProvider(command.getCode());
itemsHandler.removeServerNodeInfoProvider(command.getCode());
}
}
/**
* $Revision: 3023 $
* $Date: 2005-11-02 18:00:15 -0300 (Wed, 02 Nov 2005) $
*
* Copyright (C) 2005 Jive Software. All rights reserved.
*
* This software is published under the terms of the GNU Public License (GPL),
* a copy of which is included in this distribution.
*/
package org.jivesoftware.messenger.commands;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* A SessionData instance is responsible for keeping information gathered during the many stages
* of the command being executed. Each session data is associated with the <tt>sessionid</tt>
* attribute included in the <tt>command</tt> child element of the IQ packet.
*
* @author Gaston Dombiak
*/
class SessionData {
private long creationStamp;
private String id;
/**
* Map that keeps the association of variables and values obtained in each stage.
* Note: Key=stage number, Value=Map with key=variable name and value=variable values.
*/
private Map<Integer, Map<String, List<String>>> stagesData = new HashMap<Integer, Map<String, List<String>>>();
/**
* Keeps the default execution action to follow if the command requester does not include
* an action in his command.
*/
private AdHocCommand.Action executeAction;
private List<AdHocCommand.Action> allowedActions = new ArrayList<AdHocCommand.Action>();
/**
* Indicates the current stage where the requester is located. Stages are numbered from 0.
*/
private int stage;
public SessionData(String sessionid) {
this.id = sessionid;
this.creationStamp = System.currentTimeMillis();
this.stage = 0;
}
public String getId() {
return id;
}
public long getCreationStamp() {
return creationStamp;
}
public AdHocCommand.Action getExecuteAction() {
return executeAction;
}
public void setExecuteAction(AdHocCommand.Action executeAction) {
this.executeAction = executeAction;
}
/**
* Sets the valid actions that the user can follow from the current stage.
*
* @param allowedActions list of valid actions.
*/
public void setAllowedActions(List<AdHocCommand.Action> allowedActions) {
if (allowedActions == null) {
allowedActions = new ArrayList<AdHocCommand.Action>();
}
this.allowedActions = allowedActions;
}
/**
* Returns true if the specified action is valid in the current stage. The action should have
* previously been offered to the user.
*
* @param actionName the name of the action to validate.
* @return true if the specified action is valid in the current stage.
*/
public boolean isValidAction(String actionName) {
for (AdHocCommand.Action action : allowedActions) {
if (actionName.equals(action.name())) {
return true;
}
}
return false;
}
public void addStageForm(Map<String, List<String>> data) {
stagesData.put(stage, data);
}
/**
* Returns a Map with all the variables and values obtained during all the command stages.
*
* @return a Map with all the variables and values obtained during all the command stages.
*/
public Map<String, List<String>> getData() {
Map<String, List<String>> data = new HashMap<String, List<String>>();
data.putAll((Map<String, List<String>>) stagesData.values());
return data;
}
/**
* Returns the current stage where the requester is located. Stages are numbered from 0. A
* stage with value 0 means that a command request has just been received and no data form
* has been sent to the requester yet. The first sent data form of the first stage would be
* represented as stage 1.
*
* @return the current stage where the requester is located.
*/
public int getStage() {
return stage;
}
/**
* Sets the current stage where the requester is located. Stages are numbered from 0. A
* stage with value 0 means that a command request has just been received and no data form
* has been sent to the requester yet. The first sent data form of the first stage would be
* represented as stage 1.
*
* @param stage the current stage where the requester is located.
*/
public void setStage(int stage) {
this.stage = stage;
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment