##############################################################################
# 
# Zope Public License (ZPL) Version 1.0
# -------------------------------------
# 
# Copyright (c) Digital Creations.  All rights reserved.
# 
# This license has been certified as Open Source(tm).
# 
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are
# met:
# 
# 1. Redistributions in source code must retain the above copyright
#    notice, this list of conditions, and the following disclaimer.
# 
# 2. Redistributions in binary form must reproduce the above copyright
#    notice, this list of conditions, and the following disclaimer in
#    the documentation and/or other materials provided with the
#    distribution.
# 
# 3. Digital Creations requests that attribution be given to Zope
#    in any manner possible. Zope includes a "Powered by Zope"
#    button that is installed by default. While it is not a license
#    violation to remove this button, it is requested that the
#    attribution remain. A significant investment has been put
#    into Zope, and this effort will continue if the Zope community
#    continues to grow. This is one way to assure that growth.
# 
# 4. All advertising materials and documentation mentioning
#    features derived from or use of this software must display
#    the following acknowledgement:
# 
#      "This product includes software developed by Digital Creations
#      for use in the Z Object Publishing Environment
#      (http://www.zope.org/)."
# 
#    In the event that the product being advertised includes an
#    intact Zope distribution (with copyright and license included)
#    then this clause is waived.
# 
# 5. Names associated with Zope or Digital Creations must not be used to
#    endorse or promote products derived from this software without
#    prior written permission from Digital Creations.
# 
# 6. Modified redistributions of any form whatsoever must retain
#    the following acknowledgment:
# 
#      "This product includes software developed by Digital Creations
#      for use in the Z Object Publishing Environment
#      (http://www.zope.org/)."
# 
#    Intact (re-)distributions of any official Zope release do not
#    require an external acknowledgement.
# 
# 7. Modifications are encouraged but must be packaged separately as
#    patches to official Zope releases.  Distributions that do not
#    clearly separate the patches from the original work must be clearly
#    labeled as unofficial distributions.  Modifications which do not
#    carry the name Zope may be packaged in any form, as long as they
#    conform to all of the clauses above.
# 
# 
# Disclaimer
# 
#   THIS SOFTWARE IS PROVIDED BY DIGITAL CREATIONS ``AS IS'' AND ANY
#   EXPRESSED OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
#   IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
#   PURPOSE ARE DISCLAIMED.  IN NO EVENT SHALL DIGITAL CREATIONS OR ITS
#   CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
#   SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
#   LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF
#   USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
#   ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
#   OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT
#   OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
#   SUCH DAMAGE.
# 
# 
# This software consists of contributions made by Digital Creations and
# many individuals on behalf of Digital Creations.  Specific
# attributions are listed in the accompanying credits file.
# 
##############################################################################

#	Rename capability added, d.d. 20010120 by tom.vijlbrief@ision.nl
#

"""ZServer FTP Channel for use the medusa's ftp server.

FTP Service for Zope.

  This server allows FTP connections to Zope. In general FTP is used
  to manage content. You can:

    * Create and delete Folders, Documents, Files, and Images

    * Edit the contents of Documents, Files, Images

  In the future, FTP may be used to edit object properties.

FTP Protocol

  The FTP protocol for Zope gives Zope objects a way to make themselves
  available to FTP services. See the 'lib/python/OFS/FTPInterface.py' for
  more details.

FTP Permissions

  FTP access is controlled by one permission: 'FTP access' if bound to a
  role, users of that role will be able to list directories, and cd to
  them. Creating and deleting and changing objects are all governed by
  existing Zope permissions.

  Permissions are to a certain extent reflected in the permission bits
  listed in FTP file listings.

FTP Authorization

  Zope supports both normal and anonymous logins. It can be difficult
  to authorize Zope users since they are defined in distributed user
  databases. Normally, all logins will be accepted and then the user must
  proceed to 'cd' to a directory in which they are authorized. In this
  case for the purpose of FTP limits, the user is considered anonymous
  until they cd to an authorized directory.

  Optionally, users can login with a special username which indicates
  where they are defined. Their login will then be authenticated in
  the indicated directory, and they will not be considered anonymous.
  The form of the name is '<username>@<path>' where path takes the form
  '<folder id>[/<folder id>...]' For example: 'amos@Foo/Bar' This will
  authenticate the user 'amos' in the directory '/Foo/Bar'. In addition
  the user's FTP session will be rooted in the authenticated directory,
  i.e. they will not be able to cd out of the directory.

  The main reason to use the rooted FTP login, is to allow non-anonymous
  logins. This may be handy, if for example, you disallow anonymous logins,
  or if you set the limit for simultaneous anonymous logins very low.

"""

from PubCore import handle
from medusa.ftp_server import ftp_channel, ftp_server, recv_channel
from medusa import asyncore, asynchat, filesys
from FTPResponse import make_response
from FTPRequest import FTPRequest

from ZServer import CONNECTION_LIMIT, requestCloseOnExec

from cStringIO import StringIO
import string
import os
from mimetypes import guess_type
import marshal
import stat
import time


class zope_ftp_channel(ftp_channel):
    "Passes its commands to Zope, not a filesystem"
    
    read_only=0
    anonymous=1
    
    def __init__ (self, server, conn, addr, module):
        ftp_channel.__init__(self,server,conn,addr)
        requestCloseOnExec(conn)
        self.module=module
        self.userid=''
        self.password=''
        self.path='/'
        self.cookies={}
    
    def _join_paths(self,*args):
        path=apply(os.path.join,args)
        path=os.path.normpath(path)
        if os.sep != '/':
            path=string.replace(path,os.sep,'/')
        return path
    
    # Overriden async_chat methods

    def push(self, producer, send=1):
        # this is thread-safe when send is false
        # note, that strings are not wrapped in 
        # producers by default
        self.producer_fifo.push(producer)
        if send: self.initiate_send()
        
    push_with_producer=push
    
    
    # Overriden ftp_channel methods

    def cmd_nlst (self, line):
        'give name list of files in directory'
        self.get_dir_list(line,0)

    def cmd_list (self, line):
        'give list files in a directory'
        
        # handles files as well as directories.
        # XXX also should maybe handle globbing, yuck.
  
        self.get_dir_list(line,1)
    
    def get_dir_list(self, line, long=0):
        # we need to scan the command line for arguments to '/bin/ls'...
        # XXX clean this up, maybe with getopts
        if len(line) > 1:
            args = string.split(line[1])
        else:
            args =[]
        path_args = []
        for arg in args:
            if arg[0] != '-':
                path_args.append (arg)
            else:
                if 'l' in arg:
                    long=1
        if len(path_args) < 1:
            dir = '.'
        else:
            dir = path_args[0]
        self.listdir(dir, long)
    
    def listdir (self, path, long=0):
        response=make_response(self, self.listdir_completion, long)
        request=FTPRequest(path, 'LST', self, response)
        handle(self.module, request, response)         
        
    def listdir_completion(self, long, response):
        status=response.getStatus()
        if status==200:
            if self.anonymous and not self.userid=='anonymous':
                self.anonymous=None
            dir_list=''
            file_infos=response._marshalledBody()
            if type(file_infos[0])==type(''):
                file_infos=(file_infos,)    
            if long:
                for id, stat_info in file_infos:
                    dir_list=dir_list+filesys.unix_longify(id,stat_info)+'\r\n'
            else:
                for id, stat_info in file_infos:
                    dir_list=dir_list+id+'\r\n'
            self.make_xmit_channel()
            self.client_dc.push(dir_list)
            self.client_dc.close_when_done()                
            self.respond(
                '150 Opening %s mode data connection for file list' % (
                    self.type_map[self.current_mode]
                    )
                )   
        elif status==401:
            self.respond('530 Unauthorized.')
        else:
            self.respond('550 Could not list directory.')

    def cmd_cwd (self, line):
        'change working directory'
        response=make_response(self, self.cwd_completion, 
                self._join_paths(self.path,line[1]))
        request=FTPRequest(line[1],'CWD',self,response)
        handle(self.module,request,response)       

    def cwd_completion(self,path,response):
        'cwd completion callback'
        status=response.getStatus()
        if status==200:
            listing=response._marshalledBody()
            # check to see if we are cding to a non-foldoid object
            if type(listing[0])==type(''):
                self.respond('550 No such directory.')
                return
            else:
                self.path=path or '/'
                self.respond('250 CWD command successful.')
                # now that we've sucussfully cd'd perhaps we are no
                # longer anonymous
                if self.anonymous and not self.userid=='anonymous':
                    self.anonymous=None
        elif status==401:
            self.respond('530 Unauthorized.')
        else:
            self.respond('550 No such directory.')

    def cmd_cdup (self, line):
        'change to parent of current working directory'
        self.cmd_cwd((None,'..'))

    def cmd_pwd (self, line):
        'print the current working directory'
        self.respond (
            '257 "%s" is the current directory.' % (
                self.path
                )
            )
    
    cmd_xpwd=cmd_pwd
    
    def cmd_mdtm(self, line):
        'show last modification time of file'
        if len (line) != 2:
            self.command.not_understood (string.join (line))
            return
        response=make_response(self, self.mdtm_completion)
        request=FTPRequest(line[1],'MDTM',self,response)
        handle(self.module,request,response)       
    
    def mdtm_completion(self, response):
        status=response.getStatus()
        if status==200:
            mtime=response._marshalledBody()[stat.ST_MTIME]
            mtime=time.gmtime(mtime)
            self.respond('213 %4d%02d%02d%02d%02d%02d' % (
                                mtime[0],
                                mtime[1],
                                mtime[2],
                                mtime[3],
                                mtime[4],
                                mtime[5]
                                ))
        elif status==401:
            self.respond('530 Unauthorized.') 
        else:
            self.respond('550 Error getting file modification time.')  
                
    def cmd_size(self, line):
        'return size of file'
        # The size command sometimes returns the
        # size of identical named objects in other dirs....
	# This confuses the KDE FTP client
        # I've not found the cause (yet) -- TomV
        if len (line) != 2:
          self.command.not_understood (string.join (line))
          return
        response=make_response(self, self.size_completion)
        request=FTPRequest(line[1],'SIZE',self,response)
        handle(self.module,request,response)       
        
    def size_completion(self,response):
        status=response.getStatus()
        if status==200:
            self.respond('213 %d'% response._marshalledBody()[stat.ST_SIZE])
        elif status==401:
            self.respond('530 Unauthorized.') 
        else:
            self.respond('550 Error getting file size.')   
            
            #self.client_dc.close_when_done()

    def cmd_retr(self,line):
        if len(line) < 2:
            self.command_not_understood (string.join (line))
            return
        response=make_response(self, self.retr_completion, line[1])
        self._response_producers = response.stdout._producers
        request=FTPRequest(line[1],'RETR',self,response)
        handle(self.module,request,response) 

    def retr_completion(self, file, response):        
        status=response.getStatus()
        if status==200:
            self.make_xmit_channel()
            if not response._wrote:
                self.client_dc.push(response.body)
            else:
                for producer in self._response_producers:
                    self.client_dc.push_with_producer(producer)
            self._response_producers = None
            self.client_dc.close_when_done()
            self.respond(
                    "150 Opening %s mode data connection for file '%s'" % (
                        self.type_map[self.current_mode],
                        file
                        ))
        elif status==401:
            self.respond('530 Unauthorized.')
        else:
            self.respond('550 Error opening file.')    

    def cmd_stor (self, line, mode='wb'):
        'store a file'
        if len (line) < 2:
            self.command_not_understood (string.join (line))
            return
        elif self.restart_position:
            restart_position = 0
            self.respond ('553 restart on STOR not yet supported')
            return
            
        # XXX Check for possible problems first?
        #     Right now we are limited in the errors we can issue, since
        #     we agree to accept the file before checking authorization
        
        fd=ContentReceiver(self.stor_callback, line[1])
        self.respond (
            '150 Opening %s connection for %s' % (
                self.type_map[self.current_mode],
                line[1]
                )
            )
        self.make_recv_channel(fd)
    
    def stor_callback(self,path,data):
        'callback to do the STOR, after we have the input'
        response=make_response(self, self.stor_completion)
        request=FTPRequest(path,'STOR',self,response,stdin=data)
        handle(self.module,request,response)       
           
    def stor_completion(self,response):
        status=response.getStatus()        
        if status in (200,201,204,302):
            self.client_dc.channel.respond('226 Transfer complete.')
        elif status==401:
            self.client_dc.channel.respond('426 Unauthorized.')
        else:
            self.client_dc.channel.respond('426 Error creating file.')       
        self.client_dc.close()
        
    def cmd_rnto (self, line):
	if len (line) != 2:
		self.command_not_understood (string.join (line))
		return
        pathf,idf=os.path.split(self.fromfile)
        patht,idt=os.path.split(line[1])
        response=make_response(self, self.rnto_completion)
        request=FTPRequest(pathf,('RNTO',idf,idt),self,response)
        handle(self.module,request,response)       

    def rnto_completion(self,response):   
        status=response.getStatus()
        if status==200:
		self.respond ('250 RNTO command successful.')
	else:
		self.respond ('550 error renaming file.')
		
    def cmd_dele(self, line):
        if len (line) != 2:
            self.command.not_understood (string.join (line))
            return
        path,id=os.path.split(line[1])
        response=make_response(self, self.dele_completion)
        request=FTPRequest(path,('DELE',id),self,response)
        handle(self.module,request,response)       
        
    def dele_completion(self,response):   
        status=response.getStatus()
        if status==200 and string.find(response.body,'Not Deletable')==-1:
            self.respond('250 DELE command successful.')
        elif status==401:
            self.respond('530 Unauthorized.') 
        else:
            self.respond('550 Error deleting file.')       

    def cmd_mkd(self, line):
        if len (line) != 2:
            self.command.not_understood (string.join (line))
            return
        path,id=os.path.split(line[1])
        response=make_response(self, self.mkd_completion)
        request=FTPRequest(path,('MKD',id),self,response)
        handle(self.module,request,response)       
    
    cmd_xmkd=cmd_mkd
    
    def mkd_completion(self,response):
        status=response.getStatus()
        if status==200:
            self.respond('257 MKD command successful.')
        elif status==401:
            self.respond('530 Unauthorized.')
        else:
            self.respond('550 Error creating directory.')

    def cmd_rmd(self, line):
        # XXX should object be checked to see if it's folderish
        #     before we allow it to be RMD'd?
        if len (line) != 2:
            self.command.not_understood (string.join (line))
            return
        path,id=os.path.split(line[1])
        response=make_response(self, self.rmd_completion)
        request=FTPRequest(path,('RMD',id),self,response)
        handle(self.module,request,response)       
        
    cmd_xrmd=cmd_rmd

    def rmd_completion(self,response):
        status=response.getStatus()
        if status==200 and string.find(response.body,'Not Deletable')==-1:
            self.respond('250 RMD command successful.')
        elif status==401:
            self.respond('530 Unauthorized.') 
        else:
            self.respond('550 Error removing directory.')          

    def cmd_user(self, line):
        'specify user name'
        if len(line) > 1:
            self.userid = 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]
        self.password=pw
        i=string.find(self.userid,'@')
        if i ==-1:
            if self.server.limiter.check_limit(self):
                self.respond ('230 Login successful.')
                self.authorized = 1
                self.anonymous = 1
                self.log_info ('Successful login.')
            else:
                self.respond('421 User limit reached. Closing connection.')
                self.close_when_done()
        else:   
            path=self.userid[i+1:]
            self.userid=self.userid[:i]
            self.anonymous=None
            response=make_response(self, self.pass_completion,
                    self._join_paths('/',path))
            request=FTPRequest(path,'PASS',self,response)
            handle(self.module,request,response) 


    def pass_completion(self,path,response):
        status=response.getStatus()
        if status==200:
            if not self.server.limiter.check_limit(self):
                self.close_when_done()
                self.respond('421 User limit reached. Closing connection.')    
                return
            listing=response._marshalledBody()
            # check to see if we are cding to a non-foldoid object
            if type(listing[0])==type(''):
                self.respond('530 Unauthorized.')
                return
            self.path=path or '/'
            self.authorized = 1
            if self.userid=='anonymous':
                self.anonymous=1
            self.log_info('Successful login.')           
            self.respond('230 Login successful.')
        else:
            self.respond('530 Unauthorized.')
        
    def cmd_appe(self, line):
        self.respond('502 Command not implemented.')


# Override ftp server receive channel reponse mechanism 
# XXX hack alert, this should probably be redone in a more OO way.

def handle_close (self):
    """response and closure of channel is delayed."""
    s = self.channel.server
    s.total_files_in.increment()
    s.total_bytes_in.increment(self.bytes_in.as_long())
    self.fd.close()
    self.readable=lambda :0 # don't call close again

recv_channel.handle_close=handle_close

  
class ContentReceiver:
    "Write-only file object used to receive data from FTP"
    
    def __init__(self,callback,*args):
        self.data=StringIO()
        self.callback=callback
        self.args=args
        
    def write(self,data):
        self.data.write(data)
    
    def close(self):
        self.data.seek(0)
        args=self.args+(self.data,)
        c=self.callback
        self.callback=None
        self.args=None
        apply(c,args)


class FTPLimiter:
    """Rudimentary FTP limits. Helps prevent denial of service
    attacks. It works by limiting the number of simultaneous
    connections by userid. There are three limits, one for anonymous
    connections, and one for authenticated logins. The total number
    of simultaneous anonymous logins my be less than or equal to the
    anonymous limit. Each authenticated user can have up to the user
    limit number of simultaneous connections. The total limit is the
    maximum number of simultaneous connections of any sort. Do *not*
    set the total limit lower than or equal to the anonymous limit."""
    
    def __init__(self,anon_limit=10,user_limit=4,total_limit=25):
        self.anon_limit=anon_limit
        self.user_limit=user_limit
        self.total_limit=total_limit
    
    def check_limit(self,channel):
        """Check to see if the user has exhausted their limit or not.
        Check for existing channels with the same userid and the same
        ftp server."""
        total=0
        class_total=0
        if channel.anonymous:
            for existing_channel in asyncore.socket_map.values():
                if (hasattr(existing_channel,'server') and
                        existing_channel.server is channel.server):
                    total=total+1
                    if existing_channel.anonymous:
                        class_total=class_total+1
            if class_total > self.anon_limit:
                return None
        else:                
            for existing_channel in asyncore.socket_map.values():
                if (hasattr(existing_channel,'server') and
                        existing_channel.server is channel.server):
                    total=total+1
                    if channel.userid==existing_channel.userid:
                        class_total=class_total+1
            if class_total > self.user_limit:
                return None
        if total <= self.total_limit:
            return 1

        
class FTPServer(ftp_server):
    """FTP server for Zope."""
    
    ftp_channel_class = zope_ftp_channel
    limiter=FTPLimiter(10,1)
    shutup=0

    def __init__(self,module,*args,**kw):
        self.shutup=1
        apply(ftp_server.__init__, (self, None) + args, kw)
        self.shutup=0
        self.module=module
        self.log_info('FTP server started at %s\n'
                      '\tHostname: %s\n\tPort: %d' % (
			time.ctime(time.time()),
			self.hostname,
			self.port
			))

    def log_info(self, message, type='info'):
        if self.shutup: return
        asyncore.dispatcher.log_info(self, message, type)
        
    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, self.module)  

    def readable(self):
        return len(asyncore.socket_map) < CONNECTION_LIMIT

    def listen(self, num):
        # override asyncore limits for nt's listen queue size
        self.accepting = 1
        return self.socket.listen (num)
