1/*
2 * Copyright 2007 the original author or authors.
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16package org.mockftpserver.core.session;
17
18import org.apache.log4j.Logger;
19import org.mockftpserver.core.MockFtpServerException;
20import org.mockftpserver.core.command.Command;
21import org.mockftpserver.core.command.CommandHandler;
22import org.mockftpserver.core.command.CommandNames;
23import org.mockftpserver.core.socket.DefaultServerSocketFactory;
24import org.mockftpserver.core.socket.DefaultSocketFactory;
25import org.mockftpserver.core.socket.ServerSocketFactory;
26import org.mockftpserver.core.socket.SocketFactory;
27import org.mockftpserver.core.util.Assert;
28import org.mockftpserver.core.util.AssertFailedException;
29
30import java.io.BufferedReader;
31import java.io.ByteArrayOutputStream;
32import java.io.IOException;
33import java.io.InputStream;
34import java.io.InputStreamReader;
35import java.io.OutputStream;
36import java.io.PrintWriter;
37import java.io.Writer;
38import java.net.InetAddress;
39import java.net.ServerSocket;
40import java.net.Socket;
41import java.net.SocketTimeoutException;
42import java.util.ArrayList;
43import java.util.HashMap;
44import java.util.List;
45import java.util.Map;
46import java.util.Set;
47import java.util.StringTokenizer;
48
49/**
50 * Default implementation of the {@link Session} interface.
51 *
52 * @author Chris Mair
53 * @version $Revision$ - $Date$
54 */
55public class DefaultSession implements Session {
56
57    private static final Logger LOG = Logger.getLogger(DefaultSession.class);
58    private static final String END_OF_LINE = "\r\n";
59    protected static final int DEFAULT_CLIENT_DATA_PORT = 21;
60
61    protected SocketFactory socketFactory = new DefaultSocketFactory();
62    protected ServerSocketFactory serverSocketFactory = new DefaultServerSocketFactory();
63
64    private BufferedReader controlConnectionReader;
65    private Writer controlConnectionWriter;
66    private Socket controlSocket;
67    private Socket dataSocket;
68    ServerSocket passiveModeDataSocket; // non-private for testing
69    private InputStream dataInputStream;
70    private OutputStream dataOutputStream;
71    private Map commandHandlers;
72    private int clientDataPort = DEFAULT_CLIENT_DATA_PORT;
73    private InetAddress clientHost;
74    private InetAddress serverHost;
75    private Map attributes = new HashMap();
76    private volatile boolean terminate = false;
77
78    /**
79     * Create a new initialized instance
80     *
81     * @param controlSocket   - the control connection socket
82     * @param commandHandlers - the Map of command name -> CommandHandler. It is assumed that the
83     *                        command names are all normalized to upper case. See {@link Command#normalizeName(String)}.
84     */
85    public DefaultSession(Socket controlSocket, Map commandHandlers) {
86        Assert.notNull(controlSocket, "controlSocket");
87        Assert.notNull(commandHandlers, "commandHandlers");
88
89        this.controlSocket = controlSocket;
90        this.commandHandlers = commandHandlers;
91        this.serverHost = controlSocket.getLocalAddress();
92    }
93
94    /**
95     * Return the InetAddress representing the client host for this session
96     *
97     * @return the client host
98     * @see org.mockftpserver.core.session.Session#getClientHost()
99     */
100    public InetAddress getClientHost() {
101        return controlSocket.getInetAddress();
102    }
103
104    /**
105     * Return the InetAddress representing the server host for this session
106     *
107     * @return the server host
108     * @see org.mockftpserver.core.session.Session#getServerHost()
109     */
110    public InetAddress getServerHost() {
111        return serverHost;
112    }
113
114    /**
115     * Send the specified reply code and text across the control connection.
116     * The reply text is trimmed before being sent.
117     *
118     * @param code - the reply code
119     * @param text - the reply text to send; may be null
120     */
121    public void sendReply(int code, String text) {
122        assertValidReplyCode(code);
123
124        StringBuffer buffer = new StringBuffer(Integer.toString(code));
125
126        if (text != null && text.length() > 0) {
127            String replyText = text.trim();
128            if (replyText.indexOf("\n") != -1) {
129                int lastIndex = replyText.lastIndexOf("\n");
130                buffer.append("-");
131                for (int i = 0; i < replyText.length(); i++) {
132                    char c = replyText.charAt(i);
133                    buffer.append(c);
134                    if (i == lastIndex) {
135                        buffer.append(Integer.toString(code));
136                        buffer.append(" ");
137                    }
138                }
139            } else {
140                buffer.append(" ");
141                buffer.append(replyText);
142            }
143        }
144        LOG.debug("Sending Reply [" + buffer.toString() + "]");
145        writeLineToControlConnection(buffer.toString());
146    }
147
148    /**
149     * @see org.mockftpserver.core.session.Session#openDataConnection()
150     */
151    public void openDataConnection() {
152        try {
153            if (passiveModeDataSocket != null) {
154                LOG.debug("Waiting for (passive mode) client connection from client host [" + clientHost
155                        + "] on port " + passiveModeDataSocket.getLocalPort());
156                // TODO set socket timeout
157                try {
158                    dataSocket = passiveModeDataSocket.accept();
159                    LOG.debug("Successful (passive mode) client connection to port "
160                            + passiveModeDataSocket.getLocalPort());
161                }
162                catch (SocketTimeoutException e) {
163                    throw new MockFtpServerException(e);
164                }
165            } else {
166                Assert.notNull(clientHost, "clientHost");
167                LOG.debug("Connecting to client host [" + clientHost + "] on data port [" + clientDataPort
168                        + "]");
169                dataSocket = socketFactory.createSocket(clientHost, clientDataPort);
170            }
171            dataOutputStream = dataSocket.getOutputStream();
172            dataInputStream = dataSocket.getInputStream();
173        }
174        catch (IOException e) {
175            throw new MockFtpServerException(e);
176        }
177    }
178
179    /**
180     * Switch to passive mode
181     *
182     * @return the local port to be connected to by clients for data transfers
183     * @see org.mockftpserver.core.session.Session#switchToPassiveMode()
184     */
185    public int switchToPassiveMode() {
186        try {
187            passiveModeDataSocket = serverSocketFactory.createServerSocket(0);
188            return passiveModeDataSocket.getLocalPort();
189        }
190        catch (IOException e) {
191            throw new MockFtpServerException("Error opening passive mode server data socket", e);
192        }
193    }
194
195    /**
196     * @see org.mockftpserver.core.session.Session#closeDataConnection()
197     */
198    public void closeDataConnection() {
199        try {
200            LOG.debug("Flushing and closing client data socket");
201            dataOutputStream.flush();
202            dataOutputStream.close();
203            dataInputStream.close();
204            dataSocket.close();
205        }
206        catch (IOException e) {
207            LOG.error("Error closing client data socket", e);
208        }
209    }
210
211    /**
212     * Write a single line to the control connection, appending a newline
213     *
214     * @param line - the line to write
215     */
216    private void writeLineToControlConnection(String line) {
217        try {
218            controlConnectionWriter.write(line + END_OF_LINE);
219            controlConnectionWriter.flush();
220        }
221        catch (IOException e) {
222            LOG.error("Error writing to control connection", e);
223            throw new MockFtpServerException("Error writing to control connection", e);
224        }
225    }
226
227    /**
228     * @see org.mockftpserver.core.session.Session#close()
229     */
230    public void close() {
231        LOG.trace("close()");
232        terminate = true;
233    }
234
235    /**
236     * @see org.mockftpserver.core.session.Session#sendData(byte[], int)
237     */
238    public void sendData(byte[] data, int numBytes) {
239        Assert.notNull(data, "data");
240        try {
241            dataOutputStream.write(data, 0, numBytes);
242        }
243        catch (IOException e) {
244            throw new MockFtpServerException(e);
245        }
246    }
247
248    /**
249     * @see org.mockftpserver.core.session.Session#readData()
250     */
251    public byte[] readData() {
252        return readData(Integer.MAX_VALUE);
253    }
254
255    /**
256     * @see org.mockftpserver.core.session.Session#readData()
257     */
258    public byte[] readData(int numBytes) {
259        ByteArrayOutputStream bytes = new ByteArrayOutputStream();
260        int numBytesRead = 0;
261        try {
262            while (numBytesRead < numBytes) {
263                int b = dataInputStream.read();
264                if (b == -1) {
265                    break;
266                }
267                bytes.write(b);
268                numBytesRead++;
269            }
270            return bytes.toByteArray();
271        }
272        catch (IOException e) {
273            throw new MockFtpServerException(e);
274        }
275    }
276
277    /**
278     * Wait for and read the command sent from the client on the control connection.
279     *
280     * @return the Command sent from the client; may be null if the session has been closed
281     *         <p/>
282     *         Package-private to enable testing
283     */
284    Command readCommand() {
285
286        final long socketReadIntervalMilliseconds = 20L;
287
288        try {
289            while (true) {
290                if (terminate) {
291                    return null;
292                }
293                // Don't block; only read command when it is available
294                if (controlConnectionReader.ready()) {
295                    String command = controlConnectionReader.readLine();
296                    LOG.info("Received command: [" + command + "]");
297                    return parseCommand(command);
298                }
299                try {
300                    Thread.sleep(socketReadIntervalMilliseconds);
301                }
302                catch (InterruptedException e) {
303                    throw new MockFtpServerException(e);
304                }
305            }
306        }
307        catch (IOException e) {
308            LOG.error("Read failed", e);
309            throw new MockFtpServerException(e);
310        }
311    }
312
313    /**
314     * Parse the command String into a Command object
315     *
316     * @param commandString - the command String
317     * @return the Command object parsed from the command String
318     */
319    Command parseCommand(String commandString) {
320        Assert.notNullOrEmpty(commandString, "commandString");
321
322        List parameters = new ArrayList();
323        String name;
324
325        int indexOfFirstSpace = commandString.indexOf(" ");
326        if (indexOfFirstSpace != -1) {
327            name = commandString.substring(0, indexOfFirstSpace);
328            StringTokenizer tokenizer = new StringTokenizer(commandString.substring(indexOfFirstSpace + 1),
329                    ",");
330            while (tokenizer.hasMoreTokens()) {
331                parameters.add(tokenizer.nextToken());
332            }
333        } else {
334            name = commandString;
335        }
336
337        String[] parametersArray = new String[parameters.size()];
338        return new Command(name, (String[]) parameters.toArray(parametersArray));
339    }
340
341    /**
342     * @see org.mockftpserver.core.session.Session#setClientDataHost(java.net.InetAddress)
343     */
344    public void setClientDataHost(InetAddress clientHost) {
345        this.clientHost = clientHost;
346    }
347
348    /**
349     * @see org.mockftpserver.core.session.Session#setClientDataPort(int)
350     */
351    public void setClientDataPort(int dataPort) {
352        this.clientDataPort = dataPort;
353
354        // Clear out any passive data connection mode information
355        if (passiveModeDataSocket != null) {
356            try {
357                this.passiveModeDataSocket.close();
358            }
359            catch (IOException e) {
360                throw new MockFtpServerException(e);
361            }
362            passiveModeDataSocket = null;
363        }
364    }
365
366    /**
367     * @see java.lang.Runnable#run()
368     */
369    public void run() {
370        try {
371
372            InputStream inputStream = controlSocket.getInputStream();
373            OutputStream outputStream = controlSocket.getOutputStream();
374            controlConnectionReader = new BufferedReader(new InputStreamReader(inputStream));
375            controlConnectionWriter = new PrintWriter(outputStream, true);
376
377            LOG.debug("Starting the session...");
378
379            CommandHandler connectCommandHandler = (CommandHandler) commandHandlers.get(CommandNames.CONNECT);
380            connectCommandHandler.handleCommand(new Command(CommandNames.CONNECT, new String[0]), this);
381
382            while (!terminate) {
383                readAndProcessCommand();
384            }
385        }
386        catch (Exception e) {
387            LOG.error(e);
388            throw new MockFtpServerException(e);
389        }
390        finally {
391            LOG.debug("Cleaning up the session");
392            try {
393                controlConnectionReader.close();
394                controlConnectionWriter.close();
395            }
396            catch (IOException e) {
397                LOG.error(e);
398            }
399            LOG.debug("Session stopped.");
400        }
401    }
402
403    /**
404     * Read and process the next command from the control connection
405     *
406     * @throws Exception - if any error occurs
407     */
408    private void readAndProcessCommand() throws Exception {
409
410        Command command = readCommand();
411        if (command != null) {
412            String normalizedCommandName = Command.normalizeName(command.getName());
413            CommandHandler commandHandler = (CommandHandler) commandHandlers.get(normalizedCommandName);
414
415            if (commandHandler == null) {
416                commandHandler = (CommandHandler) commandHandlers.get(CommandNames.UNSUPPORTED);
417            }
418
419            Assert.notNull(commandHandler, "CommandHandler for command [" + normalizedCommandName + "]");
420            commandHandler.handleCommand(command, this);
421        }
422    }
423
424    /**
425     * Assert that the specified number is a valid reply code
426     *
427     * @param replyCode - the reply code to check
428     */
429    private void assertValidReplyCode(int replyCode) {
430        Assert.isTrue(replyCode > 0, "The number [" + replyCode + "] is not a valid reply code");
431    }
432
433    /**
434     * Return the attribute value for the specified name. Return null if no attribute value
435     * exists for that name or if the attribute value is null.
436     *
437     * @param name - the attribute name; may not be null
438     * @return the value of the attribute stored under name; may be null
439     * @see org.mockftpserver.core.session.Session#getAttribute(java.lang.String)
440     */
441    public Object getAttribute(String name) {
442        Assert.notNull(name, "name");
443        return attributes.get(name);
444    }
445
446    /**
447     * Store the value under the specified attribute name.
448     *
449     * @param name  - the attribute name; may not be null
450     * @param value - the attribute value; may be null
451     * @see org.mockftpserver.core.session.Session#setAttribute(java.lang.String, java.lang.Object)
452     */
453    public void setAttribute(String name, Object value) {
454        Assert.notNull(name, "name");
455        attributes.put(name, value);
456    }
457
458    /**
459     * Return the Set of names under which attributes have been stored on this session.
460     * Returns an empty Set if no attribute values are stored.
461     *
462     * @return the Set of attribute names
463     * @see org.mockftpserver.core.session.Session#getAttributeNames()
464     */
465    public Set getAttributeNames() {
466        return attributes.keySet();
467    }
468
469    /**
470     * Remove the attribute value for the specified name. Do nothing if no attribute
471     * value is stored for the specified name.
472     *
473     * @param name - the attribute name; may not be null
474     * @throws AssertFailedException - if name is null
475     * @see org.mockftpserver.core.session.Session#removeAttribute(java.lang.String)
476     */
477    public void removeAttribute(String name) {
478        Assert.notNull(name, "name");
479        attributes.remove(name);
480    }
481
482}
483