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