作业:
开发一个支持多用户在线的FTP程序
要求:
- 用户加密认证
- 允许同时多用户登录
- 每个用户有自己的家目录 ,且只能访问自己的家目录
- 对用户进行磁盘配额,每个用户的可用空间不同
- 允许用户在ftp server上随意切换目录
- 允许用户查看当前目录下文件
- 允许上传和下载文件,保证文件一致性
- 文件传输过程中显示进度条
- 附加功能:支持文件的断点续传
README:
1.client连接server端需要验证账号密码,密码使用MD5加密传输,三次验证不成功即退出。
2.用户信息保存在服务器本地文件中,密码MD5加密存储。磁盘配额大小也保存在其中。
3.用户连接上来后,可以执行命令如下
目录变更:cd /cd dirname / cd . /cd ..
文件浏览:ls
文件删除:rm filename
目录增删:mkdir dirname /rmdir dirname
查看当前目录:pwd
查看当前目录大小: du
移动和重命名: mv filename/dirname filename/dirname
上传文件:put filename [True] (True代表覆盖)
下载文件:get filename [True]
上传断点续传: newput filename [o/r] (o代表覆盖,r代表断点续传)
下载断点续传: newget filename [o/r]
4.涉及到目录的操作,用户登录后,程序会给用户一个“锚位”----以用户名字命名的家目录,使用户无论怎么操作,都只能在这个目录底下。而在发给用户的目录信息时,隐去上层目录信息。
5.用户在创建时,磁盘配额大小默认是100M,在上传文件时,程序会计算当前目录大小加文件大小是否会超过配额上限。未超过,上传;超过,返回磁盘大小不够的信息。磁盘配额可通过用户管理程序修改。
6.文件上传和下载后都会进行MD5值比对,验证文件是否一致。
7.服务端和客户端都有显示进度条功能,启用该功能会降低文件传输速度,这是好看的代价。
8.文件断点续传,支持文件上传和下载断点续传。断点续传上传功能还会检测用户磁盘空间是否足够。(断点续传命令使用前面new+put/get命名,包含put/get所有功能,由于逻辑增多,代码复杂,特地保留原put/get,以备后用)。
程序结构:
完整代码:
1.客户端
#Author:Zheng Na import os,sys BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) ####获取当前文件的上一级的上一级目录 sys.path.append(BASE_DIR) from core.client import FtpClient if __name__ == '__main__': ftp = FtpClient() ftp.connect('localhost', 9999) auth_tag = False count = 0 while auth_tag != True: ####功能:3次验证不通过即退出 count += 1 if count <= 3: auth_tag = ftp.auth() else: exit() ftp.interactive() ftp.close()
####用户端配置文件#### [DEFAULT] logfile = ../log/client.log download_dir= ../temp ####日志文件位置#### [log] logfile = ../log/client.log ####下载文件存放位置#### [download] download_dir= ../temp
#Author:Zheng Na import os,configparser,logging ####读取配置文件#### base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) config_file = os.path.join(base_dir, 'conf/client.conf') cf = configparser.ConfigParser() cf.read(config_file, encoding='utf-8') ####设定日志目录#### if os.path.exists(cf.get('log', 'logfile')): logfile = cf.get('log', 'logfile') else: logfile = os.path.join(base_dir, 'log/client.log') ####设定下载/上传目录#### if os.path.exists(cf.get('download', 'download_dir')): download_dir = cf.get('download', 'download_dir') else: download_dir = os.path.join(base_dir, 'temp') ####设置日志格式#### logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s', datefmt='%Y-%m-%d %H:%M:%S', filename=logfile, filemode='a+')
# Author:Zheng Na import socket,os,json,hashlib,sys,time,getpass,logging import core.settings def hashmd5(*args): ####MD5加密 m = hashlib.md5() m.update(str(*args).encode()) ciphertexts = m.hexdigest() ####密文 return ciphertexts def processbar(part, total): ####进度条,运行会导致程序变慢 if total != 0: done = int(50 * part / total) sys.stdout.write(" [%s%s]" % ('█' * done, ' ' * (50 - done))) ####注意:一个方块对应2个空格 sys.stdout.write('{:.2%}'.format(part / total) + ' ' * 3 + str(part) + '/' + str(total)) sys.stdout.flush() class FtpClient(object): def __init__(self): self.client = socket.socket() def connect(self, ip, port): ####连接 self.client.connect((ip, port)) def auth(self): ####用户认证 username = input("请输入用户名>>>:").strip() # password = getpass.getpass("请输入密码>>>:").strip() ####在linux上输入密码不显示,此模块在pycharm中无法使用 password = input("请输入密码>>>:").strip() ####Windows测试用 password = hashmd5(password) msg = { 'username': username, 'password': password } self.client.send(json.dumps(msg).encode('utf-8')) server_response = self.client.recv(1024).decode('utf-8') logging.info(server_response) if server_response == 'ok': print("认证通过!") return True else: print(server_response) return False def interactive(self): ####交互 while True: self.pwd('pwd') ####打印家目录 cmd = input(">> ").strip() if len(cmd) == 0: continue cmd_str = cmd.split()[0] ####用户输入的第一个值必定是命令 if hasattr(self, cmd_str): ####反射:判断一个对象中是否有字符串对应的方法或属性 func = getattr(self, cmd_str) ####利用反射来解耦:根据字符串去获取对象里对应的方法的内存地址或对应属性的值 func(cmd) ####调用命令对应的方法 else: self.help() def help(self): ####帮助 msg = ''' 仅支持如下命令: ls du pwd cd dirname/cd ./cd .. mkdir dirname rm filename rmdir dirname mv filename/dirname filename/dirname get filename [True] (True代表覆盖) put filename [True] (True代表覆盖) newget filename [o/r] (后续增加的新功能,支持断点续传,o代表覆盖,r代表断点续传) newput filename [o/r] (后续增加的新功能,支持断点续传,o代表覆盖,r代表断点续传) ''' print(msg) def pwd(self, *args): ####查看当前目录 cmd_split = args[0].split() if len(cmd_split) == 1: msg = {'action': 'pwd'} self.exec_linux_cmd(msg) else: self.help() def ls(self, *args): ####文件浏览 cmd_split = args[0].split() if len(cmd_split) == 1: msg = {'action': 'ls'} self.exec_linux_cmd(msg) else: self.help() def du(self, *args): ####查看当前目录大小 cmd_split = args[0].split() if len(cmd_split) == 1: msg = {'action': 'du'} self.exec_linux_cmd(msg) else: self.help() def cd(self, *args): ####切换目录 cmd_split = args[0].split() if len(cmd_split) == 1: dirname = '' elif len(cmd_split) == 2: dirname = cmd_split[1] else: return help() msg = { "action": 'cd', "dirname": dirname } self.exec_linux_cmd(msg) def mkdir(self, *args): ####生成目录 cmd_split = args[0].split() if len(cmd_split) == 2: dirname = cmd_split[1] msg = { "action": 'mkdir', "dirname": dirname } self.exec_linux_cmd(msg) else: help() def rm(self, *args): ####删除文件 cmd_split = args[0].split() if len(cmd_split) == 2: filename = cmd_split[1] msg = { "action": 'rm', "filename": filename, "confirm": True ####确认是否直接删除标志 } self.exec_linux_cmd(msg) else: help() def rmdir(self, *args): ####删除目录 cmd_split = args[0].split() if len(cmd_split) == 2: dirname = cmd_split[1] msg = { "action": 'rmdir', "dirname": dirname, "confirm": True ####确认是否直接删除标志 } self.exec_linux_cmd(msg) else: help() def mv(self, *args): ####实现功能:移动文件,移动目录,文件重命名,目录重命名 cmd_split = args[0].split() if len(cmd_split) == 3: objname = cmd_split[1] dstname = cmd_split[2] msg = { "action": 'mv', "objname": objname, "dstname": dstname } self.exec_linux_cmd(msg) else: help() def exec_linux_cmd(self, dict): ####用于后面调用linux命令 logging.info(dict) ####将发送给服务端的命令保存到日志中 self.client.send(json.dumps(dict).encode('utf-8')) server_response = json.loads(self.client.recv(4096).decode('utf-8')) if isinstance(server_response, list): ####判断是否为list类型 for i in server_response: print(i) else: print(server_response) def get(self, *args): ####下载文件 cmd_split = args[0].split() override = cmd_split[-1] ####override:是否覆盖参数,True表示覆盖,放在最后一位 # print(override,type(override)) if override != 'True': override = 'False' # print(override) if len(cmd_split) > 1: filename = cmd_split[1] filepath = os.path.join(core.settings.download_dir, filename) if override != 'True' and os.path.isfile(filepath): ####判断下载目录是否已存在同名文件 override_tag = input('文件已存在,要覆盖文件请输入yes >>>:').strip() if override_tag == 'yes': self.put('put %s True' % filename) else: print('下载取消') else: msg = { 'action': 'get', 'filename': filename, 'filesize': 0, 'filemd5': '', 'override': 'True' } # logging.info(msg) self.client.send(json.dumps(msg).encode('utf-8')) server_response = json.loads(self.client.recv(1024).decode('utf-8')) logging.info(server_response) if server_response == 'Filenotfound': print('File no found!') else: print(server_response) self.client.send(b'client have been ready to receive') ####发送信号,防止粘包 filesize = server_response['filesize'] filemd5 = server_response['filemd5'] receive_size = 0 f = open(filepath, 'wb') while filesize > receive_size: if filesize - receive_size > 1024: size = 1024 else: size = filesize - receive_size data = self.client.recv(size) f.write(data) receive_size += len(data) processbar(receive_size, filesize) ####打印进度条 f.close() # receive_filemd5 = os.popen('md5sum %s' % filepath).read().split()[0] receive_filemd5 = 'a' ####Windows测试用 print(' ', filename, 'md5:', receive_filemd5, '原文件md5:', filemd5) if receive_filemd5 == filemd5: print('文件接收完成!') else: print('Error,文件接收异常!') else: help() def put(self, *args): ####上传文件 cmd_split = args[0].split() override = cmd_split[-1] ####override:是否覆盖参数,True表示覆盖,放在最后一位 if override != 'True': override = 'False' # print(cmd_split,override) if len(cmd_split) > 1: filename = cmd_split[1] filepath = os.path.join(core.settings.download_dir, filename) if os.path.isfile(filepath): filesize = os.path.getsize(filepath) ####法1 # filesize = os.stat(filepath).st_size ####法2 ####直接调用系统命令取得MD5值,如果使用hashlib,需要写open打开文件-》read读取文件(可能文件大会很耗时)-》m.update计算三步,代码量更多,效率也低 # filemd5 = os.popen('md5sum %s' % filepath).read().split()[0] filemd5 = 'a' ####Windows测试 msg = { "action": 'put', "filename": filename, "filesize": filesize, "filemd5": filemd5, "override": override } # logging.info(msg) self.client.send(json.dumps(msg).encode('utf-8')) ###防止粘包,等服务器确认:这里最好列出一些标准请求码,告诉客户端是否有权限传输文件,类似200 403等 server_response = self.client.recv(1024) # logging.info(server_response) if server_response == b'file have exits, do nothing!': override_tag = input('文件已存在,要覆盖文件请输入yes >>>:') if override_tag == 'yes': self.put('put %s True' % filename) else: print('文件未上传') else: self.client.send(b'client have ready to send') ####发送确认信号,防止粘包,代号:P01 server_response = self.client.recv(1024).decode('utf-8') print(server_response) ####注意:用于打印服务器反馈信息,例如磁盘空间不足信息,不能取消 if server_response == 'begin': f = open(filepath, 'rb') send_size = 0 for line in f: send_size += len(line) self.client.send(line) processbar(send_size, filesize) else: print(' ', "file upload success...") f.close() server_response = self.client.recv(1024).decode('utf-8') print(server_response) else: print(filename, 'is not exist') else: self.help() def newget(self, *args): ####下载文件,具有断点续传功能 cmd_split = args[0].split() tag = cmd_split[-1] ####tag:o代表覆盖,r代表续传,放在最后一位 if len(cmd_split) > 1: filename = cmd_split[1] filepath = os.path.join(core.settings.download_dir, filename) if tag not in ('o', 'r'): if os.path.isfile(filepath): ####判断下载目录是否已存在同名文件 tag = input('文件已存在,要覆盖文件请输入o,要断点续传请输入r >>>:').strip() else: tag = 'o' if tag in ('o', 'r'): if tag == 'r': local_filesize = os.path.getsize(filepath) else: local_filesize = 0 # 本地文件大小 msg = { 'action': 'newget', 'filename': filename, 'filesize': local_filesize, 'filemd5': '', } logging.info(msg) self.client.send(json.dumps(msg).encode('utf-8')) server_response = json.loads(self.client.recv(1024).decode('utf-8')) logging.info(server_response) if server_response == 'Filenotfound': print('File no found!') else: print(server_response) self.client.send(b'client have been ready to receive') # 发送信号,防止粘包 filesize = server_response['filesize'] filemd5 = server_response['filemd5'] receive_size = local_filesize if tag == 'r': f = open(filepath, 'ab+') ####用于断点续传 else: f = open(filepath, 'wb+') ####用于覆盖或者新生成文件 while filesize > receive_size: if filesize - receive_size > 1024: size = 1024 else: size = filesize - receive_size data = self.client.recv(size) f.write(data) receive_size += len(data) # print(receive_size, len(data)) ####打印数据流情况 processbar(receive_size, filesize) ####打印进度条 f.close() # receive_filemd5 = os.popen('md5sum %s' % filepath).read().split()[0] receive_filemd5 = 'a' ####Windows测试用 print(' ', filename, 'md5:', receive_filemd5, '原文件md5:', filemd5) if receive_filemd5 == filemd5: print('文件接收完成!') else: print('Error,文件接收异常!') else: print("文件未下载") else: help() def newput(self, *args): ####上传文件,具有断点续传功能 cmd_split = args[0].split() tag = cmd_split[-1] ####tag:r代表续传,o代表覆盖,放在最后一位 if tag not in ('o', 'r'): tag = 'unknown' # print(cmd_split,tag) if len(cmd_split) > 1: filename = cmd_split[1] filepath = os.path.join(core.settings.download_dir, filename) if os.path.isfile(filepath): filesize = os.path.getsize(filepath) ####法1 # filesize = os.stat(filepath).st_size ####法2 ####直接调用系统命令取得MD5值,如果使用hashlib,需要写open打开文件-》read读取文件(可能文件大会很耗时)-》m.update计算三部,代码量更多,效率也低 # filemd5 = os.popen('md5sum %s' % filepath).read().split()[0] filemd5 = 'a' # Windows测试 msg = { "action": 'newput', "filename": filename, "filesize": filesize, "filemd5": filemd5, "tag": tag } # logging.info(msg) self.client.send(json.dumps(msg).encode('utf-8')) ####发送msg server_response1 = self.client.recv(1024).decode() ####接收文件存在或者文件不存在 # logging.info(server_response) print(server_response1) if server_response1 == '文件存在': ####再确认一遍tag if tag == 'unknown': tag = input('文件已存在,要覆盖文件请输入o,要断点续传请输入r >>>:').strip() if tag not in ('o', 'r'): tag = 'unknown' else: ####文件不存在时 tag = 'o' self.client.send(tag.encode()) server_response2 = json.loads(self.client.recv(1024).decode('utf-8')) # print('server_response2:', server_response2) content = server_response2['content'] if tag == 'o' or tag == 'r': if content == 'begin': position = server_response2['position'] print(position) f = open(filepath, 'rb') f.seek(position, 0) send_size = position for line in f: send_size += len(line) self.client.send(line) processbar(send_size, filesize) else: print(' ', "file upload success...") f.close() server_response3 = self.client.recv(1024).decode('utf-8') ####服务端对比md5后发送是否成功接收文件,成功或失败 print(server_response3) else: print(content) ####content:服务器已存在同名文件 或。。。 else: print(content) ####content:文件未上传 else: print(filename, 'is not exist') else: self.help() def newput2(self, *args): ####上传文件,具有断点续传功能,网友写的,与我写的newput功能差不多 cmd_split = args[0].split() override = cmd_split[-1] ####override:是否覆盖参数,放在最后一位 if override != 'True': override = 'False' # print(cmd_split,override) if len(cmd_split) > 1: filename = cmd_split[1] filepath = os.path.join(core.settings.download_dir, filename) if os.path.isfile(filepath): filesize = os.path.getsize(filepath) ####法1 # filesize = os.stat(filepath).st_size ####法2 ####直接调用系统命令取得MD5值,如果使用hashlib,需要写open打开文件-》read读取文件(可能文件大会很耗时)-》m.update计算三部,代码量更多,效率也低 filemd5 = os.popen('md5sum %s' % filepath).read().split()[0] filemd5 = 'a' ####Windows测试 msg = { "action": 'newput2', "filename": filename, "filesize": filesize, "filemd5": filemd5, "override": override } # logging.info(msg) self.client.send(json.dumps(msg).encode('utf-8')) ####防止粘包,等服务器确认:这里最好列出一些标准请求码,告诉客户端是否有权限传输文件,类似200 403等 server_response = self.client.recv(1024) # logging.info(server_response) print(server_response) if server_response == b'file have exits, and is a directory, do nothing!': print('文件已存在且为目录,请先修改文件或目录名字,然后再上传') elif server_response == b'file have exits, do nothing!': override_tag = input('文件已存在,要覆盖文件请输入yes,要断点续传请输入r >>>:').strip() if override_tag == 'yes': self.client.send(b'no need to do anything') ####服务端在等待是否续传的信号,发送给服务端确认(功能号:s1) time.sleep(0.5) ####防止黏贴,功能需改进 self.put('put %s True' % filename) elif override_tag == 'r': self.client.send(b'ready to resume from break point') ####服务端在等待是否续传的信号,发送给服务端确认(功能号:s1) self.client.recv(1024) ####这边接收服务端发送过来的du信息,不显示,直接丢弃 server_response = json.loads(self.client.recv(1024).decode('utf-8')) print(server_response) if server_response['state'] == 'True': exist_file_size = server_response['position'] f = open(filepath, 'rb') f.seek(exist_file_size, 0) send_size = exist_file_size for line in f: send_size += len(line) self.client.send(line) processbar(send_size, filesize) else: print(' ', '文件传输完毕') f.close() server_response = self.client.recv(1024).decode('utf-8') print(server_response) else: print(server_response['content']) else: self.client.send(b'no need to do anything') ####服务端在等待是否续传的信号,发送给服务端确认(功能号:s1) print('文件未上传') else: self.client.send(b'client have ready to send') ####发送确认信号,防止粘包,代号:P01 server_response = self.client.recv(1024).decode('utf-8') print(server_response) ####注意:用于打印服务器反馈信息,例如磁盘空间不足信息,不能取消 if server_response == 'begin': f = open(filepath, 'rb') send_size = 0 for line in f: send_size += len(line) self.client.send(line) processbar(send_size, filesize) else: print(' ', "file upload success...") f.close() server_response = self.client.recv(1024).decode('utf-8') print(server_response) else: print(filename, 'is not exist') else: self.help() def close(self): self.client.close()
2.服务端
# Author:Zheng Na ####os.path.abspath(__file__) 获取当前当前文件的绝对路径 ####os.path.dirname()获取当前文件上一层目录 import os,sys BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) ####获取当前文件的上一级的上一级目录 sys.path.append(BASE_DIR) import socketserver from core.server import MyTCPHandler from core.usermanagement import UserOpr if __name__ == '__main__': mainpage = ''' 主页 1、启动服务器 2、进入用户管理 退出请按q ''' while True: print('