# -*- Mode: Python; tab-width: 4 -*-

#	Author: Sam Rushing <rushing@nightmare.com>
#	Copyright 1996-2000 by Sam Rushing
#						 All Rights Reserved.
#

RCS_ID =  '$Id: ftp_server.py 24763 2004-05-17 05:59:28Z philikon $'

# An extensible, configurable, asynchronous FTP server.
# 
# All socket I/O is non-blocking, however file I/O is currently
# blocking.  Eventually file I/O may be made non-blocking, too, if it
# seems necessary.  Currently the only CPU-intensive operation is
# getting and formatting a directory listing.  [this could be moved
# into another process/directory server, or another thread?]
#
# Only a subset of RFC 959 is implemented, but much of that RFC is
# vestigial anyway.  I've attempted to include the most commonly-used
# commands, using the feature set of wu-ftpd as a guide.

import asyncore
import asynchat

import os
import socket
import stat
import string
import sys
import time

# TODO: implement a directory listing cache.  On very-high-load
# servers this could save a lot of disk abuse, and possibly the
# work of computing emulated unix ls output.

# Potential security problem with the FTP protocol?  I don't think
# there's any verification of the origin of a data connection.  Not
# really a problem for the server (since it doesn't send the port
# command, except when in PASV mode) But I think a data connection
# could be spoofed by a program with access to a sniffer - it could
# watch for a PORT command to go over a command channel, and then
# connect to that port before the server does.

# Unix user id's:
# In order to support assuming the id of a particular user,
# it seems there are two options:
# 1) fork, and seteuid in the child
# 2) carefully control the effective uid around filesystem accessing
#    methods, using try/finally. [this seems to work]

if RCS_ID.startswith('$Id: '):
    VERSION = string.split(RCS_ID)[2]
else:
    VERSION = '0.0'

from counter import counter
import producers
import status_handler
import logger
import string

class ftp_channel (asynchat.async_chat):

        # defaults for a reliable __repr__
    addr = ('unknown','0')
    
    # unset this in a derived class in order
    # to enable the commands in 'self.write_commands'
    read_only = 1
    write_commands = ['appe','dele','mkd','rmd','rnfr','rnto','stor','stou']
    
    restart_position = 0
    
    # comply with (possibly troublesome) RFC959 requirements
    # This is necessary to correctly run an active data connection
    # through a firewall that triggers on the source port (expected
    # to be 'L-1', or 20 in the normal case).
    bind_local_minus_one = 0
    
    def __init__ (self, server, conn, addr):
        self.server = server
        self.current_mode = 'a'
        self.addr = addr
        asynchat.async_chat.__init__ (self, conn)
        self.set_terminator ('\r\n')
        
        # client data port.  Defaults to 'the same as the control connection'.
        self.client_addr = (addr[0], 21)
        
        self.client_dc = None
        self.in_buffer = ''
        self.closing = 0
        self.passive_acceptor = None
        self.passive_connection = None
        self.filesystem = None
        self.authorized = 0
        # send the greeting
        self.respond (
                '220 %s FTP server (Medusa Async V%s [experimental]) ready.' % (
                        self.server.hostname,
                        VERSION
                        )
                )
        
        #	def __del__ (self):
        #		print 'ftp_channel.__del__()'
        
        # --------------------------------------------------
        # async-library methods
        # --------------------------------------------------
        
    def handle_expt (self):
            # this is handled below.  not sure what I could
            # do here to make that code less kludgish.
        pass
        
    def collect_incoming_data (self, data):
        self.in_buffer = self.in_buffer + data
        if len(self.in_buffer) > 4096:
                # silently truncate really long lines
                # (possible denial-of-service attack)
            self.in_buffer = ''
            
    def found_terminator (self):
    
        line = self.in_buffer
        
        if not len(line):
            return
            
        sp = string.find (line, ' ')
        if sp != -1:
            line = [line[:sp], line[sp+1:]]
        else:
            line = [line]
            
        command = string.lower (line[0])
        # watch especially for 'urgent' abort commands.
        if string.find (command, 'abor') != -1:
                # strip off telnet sync chars and the like...
            while command and command[0] not in string.letters:
                command = command[1:]
        fun_name = 'cmd_%s' % command
        if command != 'pass':
            self.log ('<== %s' % repr(self.in_buffer)[1:-1])
        else:
            self.log ('<== %s' % line[0]+' <password>')
        self.in_buffer = ''
        if not hasattr (self, fun_name):
            self.command_not_understood (line[0])
            return
        fun = getattr (self, fun_name)
        if (not self.authorized) and (command not in ('user', 'pass', 'help', 'quit')):
            self.respond ('530 Please log in with USER and PASS')
        elif (not self.check_command_authorization (command)):
            self.command_not_authorized (command)
        else:
            try:
                result = apply (fun, (line,))
            except:
                self.server.total_exceptions.increment()
                (file, fun, line), t,v, tbinfo = asyncore.compact_traceback()
                if self.client_dc:
                    try:
                        self.client_dc.close()
                    except:
                        pass
                self.respond (
                        '451 Server Error: %s, %s: file: %s line: %s' % (
                                t,v,file,line,
                                )
                        )
                
    closed = 0
    def close (self):
        if not self.closed:
            self.closed = 1
            if self.passive_acceptor:
                self.passive_acceptor.close()
            if self.client_dc:
                self.client_dc.close()
            self.server.closed_sessions.increment()
            asynchat.async_chat.close (self)
            
            # --------------------------------------------------
            # filesystem interface functions.
            # override these to provide access control or perform
            # other functions.
            # --------------------------------------------------
            
    def cwd (self, line):
        return self.filesystem.cwd (line[1])
        
    def cdup (self, line):
        return self.filesystem.cdup()
        
    def open (self, path, mode):
        return self.filesystem.open (path, mode)
        
        # returns a producer
    def listdir (self, path, long=0):
        return self.filesystem.listdir (path, long)
        
    def get_dir_list (self, line, long=0):
            # we need to scan the command line for arguments to '/bin/ls'...
        args = line[1:]
        path_args = []
        for arg in args:
            if arg[0] != '-':
                path_args.append (arg)
            else:
                    # ignore arguments
                pass
        if len(path_args) < 1:
            dir = '.'
        else:
            dir = path_args[0]
        return self.listdir (dir, long)
        
        # --------------------------------------------------
        # authorization methods
        # --------------------------------------------------
        
    def check_command_authorization (self, command):
        if command in self.write_commands and self.read_only:
            return 0
        else:
            return 1
            
            # --------------------------------------------------
            # utility methods
            # --------------------------------------------------
            
    def log (self, message):
        self.server.logger.log (
                self.addr[0],
                '%d %s' % (
                        self.addr[1], message
                        )
                )
        
    def respond (self, resp):
        self.log ('==> %s' % resp)
        self.push (resp + '\r\n')
        
    def command_not_understood (self, command):
        self.respond ("500 '%s': command not understood." % command)
        
    def command_not_authorized (self, command):
        self.respond (
                "530 You are not authorized to perform the '%s' command" % (
                        command
                        )
                )
        
    def make_xmit_channel (self):
            # In PASV mode, the connection may or may _not_ have been made
            # yet.  [although in most cases it is... FTP Explorer being
            # the only exception I've yet seen].  This gets somewhat confusing
            # because things may happen in any order...
        pa = self.passive_acceptor
        if pa:
            if pa.ready:
                    # a connection has already been made.
                conn, addr = self.passive_acceptor.ready
                cdc = xmit_channel (self, addr)
                cdc.set_socket (conn)
                cdc.connected = 1
                self.passive_acceptor.close()
                self.passive_acceptor = None				
            else:
                    # we're still waiting for a connect to the PASV port.
                cdc = xmit_channel (self)
        else:
                # not in PASV mode.
            ip, port = self.client_addr
            cdc = xmit_channel (self, self.client_addr)
            cdc.create_socket (socket.AF_INET, socket.SOCK_STREAM)
            if self.bind_local_minus_one:
                cdc.bind (('', self.server.port - 1))
            try:
                cdc.connect ((ip, port))
            except socket.error, why:
                self.respond ("425 Can't build data connection")
        self.client_dc = cdc
        
        # pretty much the same as xmit, but only right on the verge of
        # being worth a merge.
    def make_recv_channel (self, fd):
        pa = self.passive_acceptor
        if pa:
            if pa.ready:
                    # a connection has already been made.
                conn, addr = pa.ready
                cdc = recv_channel (self, addr, fd)
                cdc.set_socket (conn)
                cdc.connected = 1
                self.passive_acceptor.close()
                self.passive_acceptor = None				
            else:
                    # we're still waiting for a connect to the PASV port.
                cdc = recv_channel (self, None, fd)
        else:
                # not in PASV mode.
            ip, port = self.client_addr
            cdc = recv_channel (self, self.client_addr, fd)
            cdc.create_socket (socket.AF_INET, socket.SOCK_STREAM)
            try:
                cdc.connect ((ip, port))
            except socket.error, why:
                self.respond ("425 Can't build data connection")
        self.client_dc = cdc
        
    type_map = {
            'a':'ASCII',
            'i':'Binary',
            'e':'EBCDIC',
            'l':'Binary'
            }
    
    type_mode_map = {
            'a':'t',
            'i':'b',
            'e':'b',
            'l':'b'
            }
    
    # --------------------------------------------------
    # command methods
    # --------------------------------------------------
    
    def cmd_type (self, line):
        'specify data transfer type'
        # ascii, ebcdic, image, local <byte size>
        t = string.lower (line[1])
        # no support for EBCDIC
        # if t not in ['a','e','i','l']:
        if t not in ['a','i','l']:
            self.command_not_understood (string.join (line))
        elif t == 'l' and (len(line) > 2 and line[2] != '8'):
            self.respond ('504 Byte size must be 8')
        else:
            self.current_mode = t
            self.respond ('200 Type set to %s.' % self.type_map[t])
            
            
    def cmd_quit (self, line):
        'terminate session'
        self.respond ('221 Goodbye.')
        self.close_when_done()
        
    def cmd_port (self, line):
        'specify data connection port'
        info = string.split (line[1], ',')
        ip = string.join (info[:4], '.')
        port = string.atoi(info[4])*256 + string.atoi(info[5])
        # how many data connections at a time?
        # I'm assuming one for now...
        # TODO: we should (optionally) verify that the
        # ip number belongs to the client.  [wu-ftpd does this?]
        self.client_addr = (ip, port)
        self.respond ('200 PORT command successful.')
        
    def new_passive_acceptor (self):
            # ensure that only one of these exists at a time.
        if self.passive_acceptor is not None:
            self.passive_acceptor.close()
            self.passive_acceptor = None
        self.passive_acceptor = passive_acceptor (self)
        return self.passive_acceptor
        
    def cmd_pasv (self, line):
        'prepare for server-to-server transfer'
        pc = self.new_passive_acceptor()
        port = pc.addr[1]
        ip_addr = pc.control_channel.getsockname()[0]
        self.respond (
                '227 Entering Passive Mode (%s,%d,%d)' % (
                        string.join (string.split (ip_addr, '.'), ','),
                        port/256,
                        port%256
                        )
                )
        self.client_dc = None
        
    def cmd_nlst (self, line):
        'give name list of files in directory'
        # ncftp adds the -FC argument for the user-visible 'nlist'
        # command.  We could try to emulate ls flags, but not just yet.
        if '-FC' in line:
            line.remove ('-FC')
        try:
            dir_list_producer = self.get_dir_list (line, 0)
        except os.error, why:
            self.respond ('550 Could not list directory: %s' % repr(why))
            return
        self.respond (
                '150 Opening %s mode data connection for file list' % (
                        self.type_map[self.current_mode]
                        )
                )
        self.make_xmit_channel()
        self.client_dc.push_with_producer (dir_list_producer)
        self.client_dc.close_when_done()
        
    def cmd_list (self, line):
        'give list files in a directory'
        try:
            dir_list_producer = self.get_dir_list (line, 1)
        except os.error, why:
            self.respond ('550 Could not list directory: %s' % repr(why))
            return
        self.respond (
                '150 Opening %s mode data connection for file list' % (
                        self.type_map[self.current_mode]
                        )
                )
        self.make_xmit_channel()
        self.client_dc.push_with_producer (dir_list_producer)
        self.client_dc.close_when_done()
        
    def cmd_cwd (self, line):
        'change working directory'
        if self.cwd (line):
            self.respond ('250 CWD command successful.')
        else:
            self.respond ('550 No such directory.')			
            
    def cmd_cdup (self, line):
        'change to parent of current working directory'
        if self.cdup(line):
            self.respond ('250 CDUP command successful.')
        else:
            self.respond ('550 No such directory.')
            
    def cmd_pwd (self, line):
        'print the current working directory'
        self.respond (
                '257 "%s" is the current directory.' % (
                        self.filesystem.current_directory()
                        )
                )
        
        # modification time
        # example output:
        # 213 19960301204320
    def cmd_mdtm (self, line):
        'show last modification time of file'
        filename = line[1]
        if not self.filesystem.isfile (filename):
            self.respond ('550 "%s" is not a file' % filename)
        else:
            mtime = time.gmtime(self.filesystem.stat(filename)[stat.ST_MTIME])
            self.respond (
                    '213 %4d%02d%02d%02d%02d%02d' % (
                            mtime[0],
                            mtime[1],
                            mtime[2],
                            mtime[3],
                            mtime[4],
                            mtime[5]
                            )
                    )
            
    def cmd_noop (self, line):
        'do nothing'
        self.respond ('200 NOOP command successful.')
        
    def cmd_size (self, line):
        'return size of file'
        filename = line[1]
        if not self.filesystem.isfile (filename):
            self.respond ('550 "%s" is not a file' % filename)
        else:
            self.respond (
                    '213 %d' % (self.filesystem.stat(filename)[stat.ST_SIZE])
                    )
            
    def cmd_retr (self, line):
        'retrieve a file'
        if len(line) < 2:
            self.command_not_understood (string.join (line))
        else:
            file = line[1]
            if not self.filesystem.isfile (file):
                self.log_info ('checking %s' % file)
                self.respond ('550 No such file')
            else:
                try:
                        # FIXME: for some reason, 'rt' isn't working on win95
                    mode = 'r'+self.type_mode_map[self.current_mode]
                    fd = self.open (file, mode)
                except IOError, why:
                    self.respond ('553 could not open file for reading: %s' % (repr(why)))
                    return
                self.respond (
                        "150 Opening %s mode data connection for file '%s'" % (
                                self.type_map[self.current_mode],
                                file
                                )
                        )
                self.make_xmit_channel()
                
                if self.restart_position:
                        # try to position the file as requested, but
                        # give up silently on failure (the 'file object'
                        # may not support seek())
                    try:
                        fd.seek (self.restart_position)
                    except:
                        pass
                    self.restart_position = 0
                    
                self.client_dc.push_with_producer (
                        file_producer (self, self.client_dc, fd)
                        )
                self.client_dc.close_when_done()
                
    def cmd_stor (self, line, mode='wb'):
        'store a file'
        if len (line) < 2:
            self.command_not_understood (string.join (line))
        else:
            if self.restart_position:
                restart_position = 0
                self.respond ('553 restart on STOR not yet supported')
                return
            file = line[1]
            # todo: handle that type flag
            try:
                fd = self.open (file, mode)
            except IOError, why:
                self.respond ('553 could not open file for writing: %s' % (repr(why)))
                return
            self.respond (
                    '150 Opening %s connection for %s' % (
                            self.type_map[self.current_mode],
                            file
                            )
                    )
            self.make_recv_channel (fd)
            
    def cmd_abor (self, line):
        'abort operation'
        if self.client_dc:
            self.client_dc.close()
        self.respond ('226 ABOR command successful.')
        
    def cmd_appe (self, line):
        'append to a file'
        return self.cmd_stor (line, 'ab')
        
    def cmd_dele (self, line):
        if len (line) != 2:
            self.command_not_understood (string.join (line))
        else:
            file = line[1]
            if self.filesystem.isfile (file):
                try:
                    self.filesystem.unlink (file)
                    self.respond ('250 DELE command successful.')
                except:
                    self.respond ('550 error deleting file.')
            else:
                self.respond ('550 %s: No such file.' % file)
                
    def cmd_mkd (self, line):
        if len (line) != 2:
            self.command.not_understood (string.join (line))
        else:
            path = line[1]
            try:
                self.filesystem.mkdir (path)
                self.respond ('257 MKD command successful.')
            except:
                self.respond ('550 error creating directory.')
                
    def cmd_rmd (self, line):
        if len (line) != 2:
            self.command.not_understood (string.join (line))
        else:
            path = line[1]
            try:
                self.filesystem.rmdir (path)
                self.respond ('250 RMD command successful.')
            except:
                self.respond ('550 error removing directory.')
                
    def cmd_user (self, line):
        'specify user name'
        if len(line) > 1:
            self.user = line[1]
            self.respond ('331 Password required.')
        else:
            self.command_not_understood (string.join (line))
            
    def cmd_pass (self, line):
        'specify password'
        if len(line) < 2:
            pw = ''
        else:
            pw = line[1]
        result, message, fs = self.server.authorizer.authorize (self, self.user, pw)
        if result:
            self.respond ('230 %s' % message)
            self.filesystem = fs
            self.authorized = 1
            self.log_info('Successful login: Filesystem=%s' % repr(fs))
        else:
            self.respond ('530 %s' % message)
            
    def cmd_rest (self, line):
        'restart incomplete transfer'
        try:
            pos = string.atoi (line[1])
        except ValueError:
            self.command_not_understood (string.join (line))
        self.restart_position = pos
        self.respond (
                '350 Restarting at %d. Send STORE or RETRIEVE to initiate transfer.' % pos
                )
        
    def cmd_stru (self, line):
        'obsolete - set file transfer structure'
        if line[1] in 'fF':
                # f == 'file'
            self.respond ('200 STRU F Ok')
        else:
            self.respond ('504 Unimplemented STRU type')
            
    def cmd_mode (self, line):
        'obsolete - set file transfer mode'
        if line[1] in 'sS':
                # f == 'file'
            self.respond ('200 MODE S Ok')
        else:
            self.respond ('502 Unimplemented MODE type')
            
            # The stat command has two personalities.  Normally it returns status
            # information about the current connection.  But if given an argument,
            # it is equivalent to the LIST command, with the data sent over the
            # control connection.  Strange.  But wuftpd, ftpd, and nt's ftp server
            # all support it.
            #
            ##	def cmd_stat (self, line):
            ##		'return status of server'
            ##		pass
            
    def cmd_syst (self, line):
        'show operating system type of server system'
        # Replying to this command is of questionable utility, because
        # this server does not behave in a predictable way w.r.t. the
        # output of the LIST command.  We emulate Unix ls output, but
        # on win32 the pathname can contain drive information at the front
        # Currently, the combination of ensuring that os.sep == '/'
        # and removing the leading slash when necessary seems to work.
        # [cd'ing to another drive also works]
        #
        # This is how wuftpd responds, and is probably
        # the most expected.  The main purpose of this reply is so that
        # the client knows to expect Unix ls-style LIST output.
        self.respond ('215 UNIX Type: L8')
        # one disadvantage to this is that some client programs
        # assume they can pass args to /bin/ls.
        # a few typical responses:
        # 215 UNIX Type: L8 (wuftpd)
        # 215 Windows_NT version 3.51
        # 215 VMS MultiNet V3.3
        # 500 'SYST': command not understood. (SVR4)
        
    def cmd_help (self, line):
        'give help information'
        # find all the methods that match 'cmd_xxxx',
        # use their docstrings for the help response.
        attrs = dir(self.__class__)
        help_lines = []
        for attr in attrs:
            if attr[:4] == 'cmd_':
                x = getattr (self, attr)
                if type(x) == type(self.cmd_help):
                    if x.__doc__:
                        help_lines.append ('\t%s\t%s' % (attr[4:], x.__doc__))
        if help_lines:
            self.push ('214-The following commands are recognized\r\n')
            self.push_with_producer (producers.lines_producer (help_lines))
            self.push ('214 \r\n')
        else:
            self.push ('214-\r\n\tHelp Unavailable\r\n214 \r\n')
            
class ftp_server (asyncore.dispatcher):
        # override this to spawn a different FTP channel class.
    ftp_channel_class = ftp_channel
    
    SERVER_IDENT = 'FTP Server (V%s)' % VERSION
    
    def __init__ (
            self,
            authorizer,
            hostname	=None,
            ip			='',
            port		=21,
            resolver	=None,
            logger_object=logger.file_logger (sys.stdout)
            ):
        self.ip = ip
        self.port = port
        self.authorizer = authorizer
        
        if hostname is None:
            self.hostname = socket.gethostname()
        else:
            self.hostname = hostname
            
            # statistics
        self.total_sessions = counter()
        self.closed_sessions = counter()
        self.total_files_out = counter()
        self.total_files_in = counter()
        self.total_bytes_out = counter()
        self.total_bytes_in = counter()
        self.total_exceptions = counter()
        #
        asyncore.dispatcher.__init__ (self)
        self.create_socket (socket.AF_INET, socket.SOCK_STREAM)
        
        self.set_reuse_addr()
        self.bind ((self.ip, self.port))
        self.listen (5)
        
        if not logger_object:
            logger_object = sys.stdout
            
        if resolver:
            self.logger = logger.resolving_logger (resolver, logger_object)
        else:
            self.logger = logger.unresolving_logger (logger_object)
            
        self.log_info('FTP server started at %s\n\tAuthorizer:%s\n\tHostname: %s\n\tPort: %d' % (
                time.ctime(time.time()),
                repr (self.authorizer),
                self.hostname,
                self.port)
                )
        
    def writable (self):
        return 0
        
    def handle_read (self):
        pass
        
    def handle_connect (self):
        pass
        
    def handle_accept (self):
        conn, addr = self.accept()
        self.total_sessions.increment()
        self.log_info('Incoming connection from %s:%d' % (addr[0], addr[1]))
        self.ftp_channel_class (self, conn, addr)
        
        # return a producer describing the state of the server
    def status (self):
    
        def nice_bytes (n):
            return string.join (status_handler.english_bytes (n))
            
        return producers.lines_producer (
                ['<h2>%s</h2>'				% self.SERVER_IDENT,
                 '<br>Listening on <b>Host:</b> %s' % self.hostname,
                 '<b>Port:</b> %d'			% self.port,
                 '<br>Sessions',
                 '<b>Total:</b> %s'			% self.total_sessions,
                 '<b>Current:</b> %d'		% (self.total_sessions.as_long() - self.closed_sessions.as_long()),
                 '<br>Files',
                 '<b>Sent:</b> %s'			% self.total_files_out,
                 '<b>Received:</b> %s'		% self.total_files_in,
                 '<br>Bytes',
                 '<b>Sent:</b> %s'			% nice_bytes (self.total_bytes_out.as_long()),
                 '<b>Received:</b> %s'		% nice_bytes (self.total_bytes_in.as_long()),
                 '<br>Exceptions: %s'		% self.total_exceptions,
                 ]
                )
        
        # ======================================================================
        #						 Data Channel Classes
        # ======================================================================
        
        # This socket accepts a data connection, used when the server has been
        # placed in passive mode.  Although the RFC implies that we ought to
        # be able to use the same acceptor over and over again, this presents
        # a problem: how do we shut it off, so that we are accepting
        # connections only when we expect them?  [we can't]
        #
        # wuftpd, and probably all the other servers, solve this by allowing
        # only one connection to hit this acceptor.  They then close it.  Any
        # subsequent data-connection command will then try for the default
        # port on the client side [which is of course never there].  So the
        # 'always-send-PORT/PASV' behavior seems required.
        #
        # Another note: wuftpd will also be listening on the channel as soon
        # as the PASV command is sent.  It does not wait for a data command
        # first.
        
        # --- we need to queue up a particular behavior:
        #  1) xmit : queue up producer[s]
        #  2) recv : the file object
        #
        # It would be nice if we could make both channels the same.  Hmmm..
        #
        
class passive_acceptor (asyncore.dispatcher):
    ready = None
    
    def __init__ (self, control_channel):
            # connect_fun (conn, addr)
        asyncore.dispatcher.__init__ (self)
        self.control_channel = control_channel
        self.create_socket (socket.AF_INET, socket.SOCK_STREAM)
        # bind to an address on the interface that the
        # control connection is coming from.
        self.bind ((
                self.control_channel.getsockname()[0],
                0
                ))
        self.addr = self.getsockname()
        self.listen (1)
        
        #	def __del__ (self):
        #		print 'passive_acceptor.__del__()'
        
    def log (self, *ignore):
        pass
        
    def handle_accept (self):
        conn, addr = self.accept()
        conn.setblocking(0)
        dc = self.control_channel.client_dc
        if dc is not None:
            dc.set_socket (conn)
            dc.addr = addr
            dc.connected = 1
            self.control_channel.passive_acceptor = None
        else:
            self.ready = conn, addr
        self.close()
        
        
class xmit_channel (asynchat.async_chat):

        # for an ethernet, you want this to be fairly large, in fact, it
        # _must_ be large for performance comparable to an ftpd.  [64k] we
        # ought to investigate automatically-sized buffers...

    ac_out_buffer_size = 16384
    bytes_out = 0
    
    def __init__ (self, channel, client_addr=None):
        self.channel = channel
        self.client_addr = client_addr
        asynchat.async_chat.__init__ (self)
        
        #	def __del__ (self):
        #		print 'xmit_channel.__del__()'
        
    def log (*args):
        pass
        
    def readable (self):
        return not self.connected
        
    def writable (self):
        return 1
        
    def send (self, data):
        result = asynchat.async_chat.send (self, data)
        self.bytes_out = self.bytes_out + result
        return result
        
    def handle_error (self):
            # usually this is to catch an unexpected disconnect.
        self.log_info ('unexpected disconnect on data xmit channel', 'error')
        try:
            self.close()
        except:
            pass
            
            # TODO: there's a better way to do this.  we need to be able to
            # put 'events' in the producer fifo.  to do this cleanly we need
            # to reposition the 'producer' fifo as an 'event' fifo.

    # dummy function to suppress warnings caused by some FTP clients
    def handle_connect(self): pass
            
    def close (self):
        c = self.channel
        s = c.server
        c.client_dc = None
        s.total_files_out.increment()
        s.total_bytes_out.increment (self.bytes_out)
        if not len(self.producer_fifo):
            c.respond ('226 Transfer complete')
        elif not c.closed:
            c.respond ('426 Connection closed; transfer aborted')
        del c
        del s
        del self.channel
        asynchat.async_chat.close (self)
        
class recv_channel (asyncore.dispatcher):
    def __init__ (self, channel, client_addr, fd):
        self.channel = channel
        self.client_addr = client_addr
        self.fd = fd
        asyncore.dispatcher.__init__ (self)
        self.bytes_in = counter()
        
    def log (self, *ignore):
        pass
        
    def handle_connect (self):
        pass
        
    def writable (self):
        return 0
        
    def recv (*args):
        result = apply (asyncore.dispatcher.recv, args)
        self = args[0]
        self.bytes_in.increment(len(result))
        return result
        
    buffer_size = 8192
    
    def handle_read (self):
        block = self.recv (self.buffer_size)
        if block:
            try:
                self.fd.write (block)
            except IOError:
                self.log_info ('got exception writing block...', 'error')
                
    def handle_close (self):
        s = self.channel.server
        s.total_files_in.increment()
        s.total_bytes_in.increment(self.bytes_in.as_long())
        self.fd.close()
        self.channel.respond ('226 Transfer complete.')
        self.close()
        
import filesys

# not much of a doorman! 8^)
class dummy_authorizer:
    def __init__ (self, root='/'):
        self.root = root
    def authorize (self, channel, username, password):
        channel.persona = -1, -1
        channel.read_only = 1
        return 1, 'Ok.', filesys.os_filesystem (self.root)
        
class anon_authorizer:
    def __init__ (self, root='/'):
        self.root = root
        
    def authorize (self, channel, username, password):
        if username in ('ftp', 'anonymous'):
            channel.persona = -1, -1
            channel.read_only = 1
            return 1, 'Ok.', filesys.os_filesystem (self.root)
        else:
            return 0, 'Password invalid.', None
            
            # ===========================================================================
            # Unix-specific improvements
            # ===========================================================================
            
if os.name == 'posix':

    class unix_authorizer:
            # return a trio of (success, reply_string, filesystem)
        def authorize (self, channel, username, password):
            import crypt
            import pwd
            try:
                info = pwd.getpwnam (username)
            except KeyError:
                return 0, 'No such user.', None
            mangled = info[1]
            if crypt.crypt (password, mangled[:2]) == mangled:
                channel.read_only = 0
                fs = filesys.schizophrenic_unix_filesystem (
                        '/',
                        info[5],
                        persona = (info[2], info[3])
                        )
                return 1, 'Login successful.', fs
            else:
                return 0, 'Password invalid.', None
                
        def __repr__ (self):
            return '<standard unix authorizer>'
            
            # simple anonymous ftp support
    class unix_authorizer_with_anonymous (unix_authorizer):
        def __init__ (self, root=None, real_users=0):
            self.root = root
            self.real_users = real_users
            
        def authorize (self, channel, username, password):
            if string.lower(username) in ['anonymous', 'ftp']:
                import pwd
                try:
                        # ok, here we run into lots of confusion.
                        # on some os', anon runs under user 'nobody',
                        # on others as 'ftp'.  ownership is also critical.
                        # need to investigate.
                        # linux: new linuxen seem to have nobody's UID=-1,
                        #    which is an illegal value.  Use ftp.
                    ftp_user_info = pwd.getpwnam ('ftp')
                    if string.lower(os.uname()[0]) == 'linux':
                        nobody_user_info = pwd.getpwnam ('ftp')
                    else:
                        nobody_user_info = pwd.getpwnam ('nobody')
                    channel.read_only = 1
                    if self.root is None:
                        self.root = ftp_user_info[5]
                    fs = filesys.unix_filesystem (self.root, '/')
                    return 1, 'Anonymous Login Successful', fs
                except KeyError:
                    return 0, 'Anonymous account not set up', None
            elif self.real_users:
                return unix_authorizer.authorize (
                        self,
                        channel,
                        username,
                        password
                        )
            else:
                return 0, 'User logins not allowed', None
                
class file_producer:
    block_size = 16384
    def __init__ (self, server, dc, fd):
        self.fd = fd
        self.done = 0
        
    def more (self):
        if self.done:
            return ''
        else:
            block = self.fd.read (self.block_size)
            if not block:
                self.fd.close()
                self.done = 1
            return block
            
            # usage: ftp_server /PATH/TO/FTP/ROOT PORT
            # for example:
            # $ ftp_server /home/users/ftp 8021
            
if os.name == 'posix':
    def test (port='8021'):
        import sys
        fs = ftp_server (
                unix_authorizer(),
                port=string.atoi (port)
                )
        try:
            asyncore.loop()
        except KeyboardInterrupt:
            self.log_info('FTP server shutting down. (received SIGINT)', 'warning')
            # close everything down on SIGINT.
            # of course this should be a cleaner shutdown.
            asyncore.close_all()
            
    if __name__ == '__main__':
        test (sys.argv[1])
        # not unix
else:
    def test ():
        fs = ftp_server (dummy_authorizer())
    if __name__ == '__main__':
        test ()
        
        # this is the command list from the wuftpd man page
        # '*' means we've implemented it.
        # '!' requires write access
        #
command_documentation = {
        'abor':	'abort previous command',							#*
        'acct':	'specify account (ignored)',
        'allo':	'allocate storage (vacuously)',
        'appe':	'append to a file',									#*!
        'cdup':	'change to parent of current working directory',	#*
        'cwd':	'change working directory',							#*
        'dele':	'delete a file',									#!
        'help':	'give help information',							#*
        'list':	'give list files in a directory',					#*
        'mkd':	'make a directory',									#!
        'mdtm':	'show last modification time of file',				#*
        'mode':	'specify data transfer mode',
        'nlst':	'give name list of files in directory',				#*
        'noop':	'do nothing',										#*
        'pass':	'specify password',									#*
        'pasv':	'prepare for server-to-server transfer',			#*
        'port':	'specify data connection port',						#*
        'pwd':	'print the current working directory',				#*
        'quit':	'terminate session',								#*
        'rest':	'restart incomplete transfer',						#*
        'retr':	'retrieve a file',									#*
        'rmd':	'remove a directory',								#!
        'rnfr':	'specify rename-from file name',					#!
        'rnto':	'specify rename-to file name',						#!
        'site':	'non-standard commands (see next section)',
        'size':	'return size of file',								#*
        'stat':	'return status of server',							#*
        'stor':	'store a file',										#*!
        'stou':	'store a file with a unique name',					#!
        'stru':	'specify data transfer structure',
        'syst':	'show operating system type of server system',		#*
        'type':	'specify data transfer type',						#*
        'user':	'specify user name',								#*
        'xcup':	'change to parent of current working directory (deprecated)',
        'xcwd':	'change working directory (deprecated)',
        'xmkd':	'make a directory (deprecated)',					#!
        'xpwd':	'print the current working directory (deprecated)',
        'xrmd':	'remove a directory (deprecated)',					#!
}


# debugging aid (linux)
def get_vm_size ():
    return string.atoi (string.split(open ('/proc/self/stat').readline())[22])
    
def print_vm():
    print 'vm: %8dk' % (get_vm_size()/1024)
