实现支持多用户在线的FTP程序(C/S)
1. 需求
1. 用户加密认证
2. 允许多用户登录
3. 每个用户都有自己的家目录,且只能访问自己的家目录
4. 对用户进行磁盘分配,每一个用户的可用空间可以自己设置
5. 允许用户在ftp server上随意切换目录
6. 允许用户查看自己家目录下的文件
7. 允许用户上传和下载,保证文件的一致性(md5)
8. 文件上传、下载过程中显示进度条
9. 支持多并发的功能
10. 使用队列queue模块,实现线程池
11. 允许用户配置最大的并发数,比如允许只有10并发用户
升级需求:10%
1. 文件支持断点续传
2. 开发环境
Python 3.7.3
3. 软件开发
客户端:
|-conf
|-setting.py # 配置文件,存放服务端ip和port, 客户端下载文件的目录等
|-core
|-main.py # FTP客户端功能
|-files # 用户下载, 上传文件的存放目录
|-.download # 目录存放用户未下载完的文件的配置文件
|-ftp_client.py # 客户端启动程序 服务端:
|-conf
|-settings.py # 配置文件,存放服务端ip和port, 用户目录及用户账户, 日志目录, 与用户确认交互的状态码, 日志配置文件等等
|-accounts.ini # 用户账户相关的信息
|-core
|-handler_request.py # 专门处理服务端就与客户端的请求, 以及命令
|-main.py # FTP服务端专门与客户端建立连接
|-management.py # 管理FTP的的启动, 停止, 重启等
|-mythreadpool.py # 使用queue实现的简单版的线程池, 缺点: 线程不能重复利用
|-home
|-egon # 用户家目录,每一个用户以用户名作为家目录
|-.upload # 目录存放用户未上传完的文件的配置文件信息
|-.... # 每个用户下都有: 用户家目录,每一个用户以用户名作为家目录
|-.upload # 每个用户下都有: 用户未上传完的文件的配置文件信息
|-ftp_client.py # 服务端启动程序
目录结构
4. 服务端与客户端的启动
1.打开cmd命令行终端2.python3+启动文件路径+startftpserver3.例子:C:\Users\洋辣子>python3Z:\pycharm\开发FTP程序之路\第2次FTP_修改后的内容\第二次实现方式\server\ftp_server.pystartftpserver
服务端启动
1. 打开cmd命令行终端
2. python3 + 启动文件路径
3. 例子:
C:\Users\洋辣子> python3 Z:\pycharm\开发FTP程序之路\第2次FTP_修改后的内容\第二次实现方式\client\ftp_client.py
客户端启动
5. 用户配置信息
用户名: 用户密码:
alex 123
egon 123
ly 123
jzd 123
shx 123
6. 所有功能测试
(1) 登陆
username>>:egon
password>>:123
用户名密码正确, 认证成功!
(2) 查看所有命令所对应的帮助信息
查看方法:
命令 + –-help
[egon@localhost ~]# ls --help 查看当前目录下的文件:
ls
指定目录下的文件(只能查看到自己家目录的范围):
ls /我是egon的目录 [egon@localhost ~]# cd --help 相对路径切换:
cd /我是egon的目录
cd /我是江傻子的目录
切换到上一层目录:
cd ..
绝对路径切换:
cd /我是egon的目录/我是江傻子的目录
在当前目录下切当前目录:
cd .
(3) ls: 查看
① 支持功能:
查看当前目录下的文件:ls
指定目录下的文件(只能查看到自己家目录的范围):ls /目录1/目录2
查看帮助信息:ls /?
② 运行效果:
[egon@localhost ~]# ls
驱动器 Z 中的卷是 固态硬盘
卷的序列号是 AA26-64F0 Z:\pycharm\开发FTP程序之路\第2次FTP_修改后的内容\第二次实现方式\server\home\egon 的目录 2019-10-26 22:34 <DIR> .
2019-10-26 22:34 <DIR> ..
2019-10-19 20:03 1,081,540 123.docx
2019-10-27 13:45 <DIR> 我是egon的目录
1 个文件 1,081,540 字节
3 个目录 56,465,575,936 可用字节
1) 查看当前目录下的文件
[egon@localhost ~]# ls /我是egon的目录
驱动器 Z 中的卷是 固态硬盘
卷的序列号是 AA26-64F0 Z:\pycharm\开发FTP程序之路\第2次FTP_修改后的内容\第二次实现方式\server\home\egon\我是egon的目录 的目录 2019-10-27 13:45 <DIR> .
2019-10-27 13:45 <DIR> ..
2019-10-27 13:45 <DIR> 我是江傻子的目录
2019-10-27 12:34 0 江傻子
1 个文件 0 字节
3 个目录 56,465,575,936 可用字节
2) 指定目录下的文件(只能查看到自己家目录的范围)
[egon@localhost ~]# ls /?
显示目录中的文件和子目录列表。 DIR [drive:][path][filename] [/A[[:]attributes]] [/B] [/C] [/D] [/L] [/N]
[/O[[:]sortorder]] [/P] [/Q] [/R] [/S] [/T[[:]timefield]] [/W] [/X] [/4] [drive:][path][filename]
指定要列出的驱动器、目录和/或文件。 /A 显示具有指定属性的文件。
属性 D 目录 R 只读文件
H 隐藏文件 A 准备存档的文件
S 系统文件 I 无内容索引文件
L 重新分析点 O 脱机文件
- 表示“否”的前缀
/B 使用空格式(没有标题信息或摘要)。
/C 在文件大小中显示千位数分隔符。这是默认值。用 /-C 来
禁用分隔符显示。
/D 跟宽式相同,但文件是按栏分类列出的。
/L 用小写。
/N 新的长列表格式,其中文件名在最右边。
/O 用分类顺序列出文件。
排列顺序 N 按名称(字母顺序) S 按大小(从小到大)
E 按扩展名(字母顺序) D 按日期/时间(从先到后)
G 组目录优先 - 反转顺序的前缀
/P 在每个信息屏幕后暂停。
/Q 显示文件所有者。
/R 显示文件的备用数据流。
/S 显示指定目录和所有子目录中的文件。
/T 控制显示或用来分类的时间字符域
时间段 C 创建时间
A 上次访问时间
W 上次写入的时间
/W 用宽列表格式。
/X 显示为非 8dot3 文件名产生的短名称。格式是 /N 的格式,
短名称插在长名称前面。如果没有短名称,在其位置则
显示空白。
/4 以四位数字显示年份 可以在 DIRCMD 环境变量中预先设定开关。通过添加前缀 - (破折号)
来替代预先设定的开关。例如,/-W。 [egon@localhost ~]#
3) 查看帮助信息
(4) cd: 切换目录
① 支持功能:
相对路径切换:cd /目录1 或者 cd /目录2
切换到上一层目录:cd ..
绝对路径切换:cd /目录1/目录2
在当前目录下切当前目录:cd .
② 运行效果:
[egon@localhost ~]# cd /我是egon的目录
切换目录成功
[egon@localhost /home/egon/我是egon的目录]# ls
驱动器 Z 中的卷是 固态硬盘
卷的序列号是 AA26-64F0 Z:\pycharm\开发FTP程序之路\第2次FTP_修改后的内容\第二次实现方式\server\home\egon\我是egon的目录 的目录 2019-10-27 13:45 <DIR> .
2019-10-27 13:45 <DIR> ..
2019-10-27 13:45 <DIR> 我是江傻子的目录
2019-10-27 12:34 0 江傻子
1 个文件 0 字节
3 个目录 56,465,563,648 可用字节 [egon@localhost /home/egon/我是egon的目录]# cd /我是江傻子的目录
切换目录成功
[egon@localhost /home/egon/我是egon的目录/我是江傻子的目录]# ls
驱动器 Z 中的卷是 固态硬盘
卷的序列号是 AA26-64F0 Z:\pycharm\开发FTP程序之路\第2次FTP_修改后的内容\第二次实现方式\server\home\egon\我是egon的目录\我是江傻子的目录 的目录 2019-10-27 13:45 <DIR> .
2019-10-27 13:45 <DIR> ..
2019-10-27 13:45 0 我是江大傻.txt
1 个文件 0 字节
2 个目录 56,465,563,648 可用字节 [egon@localhost /home/egon/我是egon的目录/我是江傻子的目录]#
1) 相对路径切换
[egon@localhost /home/egon/我是egon的目录/我是江傻子的目录]# cd ..
切换目录成功
[egon@localhost /home/egon/我是egon的目录]# ls
驱动器 Z 中的卷是 固态硬盘
卷的序列号是 AA26-64F0 Z:\pycharm\开发FTP程序之路\第2次FTP_修改后的内容\第二次实现方式\server\home\egon\我是egon的目录 的目录 2019-10-27 13:45 <DIR> .
2019-10-27 13:45 <DIR> ..
2019-10-27 13:45 <DIR> 我是江傻子的目录
2019-10-27 12:34 0 江傻子
1 个文件 0 字节
3 个目录 56,465,559,552 可用字节 [egon@localhost /home/egon/我是egon的目录]#
2) 切换到上一层目录
[egon@localhost ~]# cd /我是egon的目录/我是江傻子的目录
切换目录成功
[egon@localhost /home/egon/我是egon的目录/我是江傻子的目录]# ls
驱动器 Z 中的卷是 固态硬盘
卷的序列号是 AA26-64F0 Z:\pycharm\开发FTP程序之路\第2次FTP_修改后的内容\第二次实现方式\server\home\egon\我是egon的目录\我是江傻子的目录 的目录 2019-10-27 13:45 <DIR> .
2019-10-27 13:45 <DIR> ..
2019-10-27 13:45 0 我是江大傻.txt
1 个文件 0 字节
2 个目录 56,465,559,552 可用字节 [egon@localhost /home/egon/我是egon的目录/我是江傻子的目录]#
3) 绝对路径切换
[egon@localhost /home/egon/我是egon的目录/我是江傻子的目录]# cd .
切换目录成功
[egon@localhost /home/egon/我是egon的目录/我是江傻子的目录]# ls
驱动器 Z 中的卷是 固态硬盘
卷的序列号是 AA26-64F0 Z:\pycharm\开发FTP程序之路\第2次FTP_修改后的内容\第二次实现方式\server\home\egon\我是egon的目录\我是江傻子的目录 的目录 2019-10-27 13:45 <DIR> .
2019-10-27 13:45 <DIR> ..
2019-10-27 13:45 0 我是江大傻.txt
1 个文件 0 字节
2 个目录 56,465,559,552 可用字节
4) 在当前目录下切当前目录
(5) mkdir: 创建目录(支持递归创建目录)
① 支持功能:
相对路径创建: mkdir /目录
生成多层递归目录: mkdir /目录1/目录2
② 运行效果:
[egon@localhost ~]# mkdir /a
创建目录成功!
1) 相对路径创建:
[egon@localhost ~]# mkdir /a/b
创建目录成功!
2) 绝对路径创建:
(6) rmdir: 删除空目录
① 支持功能:
删除空目录: rmdir /目录1/空目录2
② 运行效果:
[egon@localhost ~]# rmdir /a/b
删除目录成功!
1) 删除空目录
(7) remove: 删除文件
① 支持功能:
删除文件:remove /目录1/文件
② 运行效果:
[egon@localhost ~]# ls /我是egon的目录/江傻子
驱动器 Z 中的卷是 固态硬盘
卷的序列号是 AA26-64F0 Z:\pycharm\开发FTP程序之路\第2次FTP_修改后的内容\第二次实现方式\server\home\egon\我是egon的目录 的目录 2019-10-27 12:34 0 江傻子
1 个文件 0 字节
0 个目录 56,465,010,688 可用字节 [egon@localhost ~]# remove /我是egon的目录/江傻子
删除文件成功! [egon@localhost ~]# ls /我是egon的目录/江傻子
找不到文件
驱动器 Z 中的卷是 固态硬盘
卷的序列号是 AA26-64F0 Z:\pycharm\开发FTP程序之路\第2次FTP_修改后的内容\第二次实现方式\server\home\egon\我是egon的目录 的目录
1) 删除文件
(8) upload: 上传文件到服务端
① 支持功能:
上传到服务端当前路径: upload 文件
通过cd切换目录上传文件到该目录下: cd /目录1/目录2 --> upload 文件
② 运行效果:
[egon@localhost ~]# upload 服务器管理综合报告.docx
你可以上传文件, 在您上传之前, 您的目前空间:68.97MB! upload running...
[##################################################] 100.00%
upload succeed!
上传文件成功, 您上传完后的剩余空间:66.07MB! [egon@localhost ~]# ls
驱动器 Z 中的卷是 固态硬盘
卷的序列号是 AA26-64F0 Z:\pycharm\开发FTP程序之路\第2次FTP_修改后的内容\第二次实现方式\server\home\egon 的目录 2019-10-31 16:37 <DIR> .
2019-10-31 16:37 <DIR> ..
2019-10-31 16:37 <DIR> .upload
2019-10-31 16:09 32,535,704 03_函数调用的三种形式.mp4
2019-10-28 09:53 <DIR> 我是egon的目录
2019-10-31 16:37 3,039,102 服务器管理综合报告.docx
2 个文件 35,574,806 字节
4 个目录 56,393,715,712 可用字节
1) 上传到服务端当前路径:
[egon@localhost ~]# cd /我是egon的目录
切换目录成功 [egon@localhost /我是的目录]# upload 服务器管理综合报告.docx
你可以上传文件, 在您上传之前, 您的目前空间:66.07MB! upload running...
[##################################################] 100.00%
upload succeed!
上传文件成功, 您上传完后的剩余空间:63.17MB! [egon@localhost /我是的目录]# ls
驱动器 Z 中的卷是 固态硬盘
卷的序列号是 AA26-64F0 Z:\pycharm\开发FTP程序之路\第2次FTP_修改后的内容\第二次实现方式\server\home\egon\我是egon的目录 的目录 2019-10-31 16:47 <DIR> .
2019-10-31 16:47 <DIR> ..
2019-10-27 13:45 <DIR> 我是江傻子的目录
2019-10-31 16:47 3,039,102 服务器管理综合报告.docx
1 个文件 3,039,102 字节
3 个目录 56,390,676,480 可用字节
通过cd切换目录上传文件到该目录下:
(9) resume_upload: 续传未上传完成的文件到服务端
① 支持功能:
继续上传文件到服务端当前路径: resume_upload 文件名
通过cd切换目录, 到该目录下指定服务端的某个目录下继续上传: cd /目录1/目录2 --> resume_upload 文件名
② 运行效果:
------您的files文件夹下所含有的文件------
1: .download
2: 03_函数调用的三种形式.mp4
3: 服务器管理综合报告.docx [egon@localhost ~]# upload 03_函数调用的三种形式.mp4
你可以上传文件, 在您上传之前, 您的目前空间:97.10MB! upload running...
[############ ] 25.43%
先断开传输:
username>>:egon
password>>:123
用户名密码正确, 认证成功!
您的还有为上传完的文件, 是否继续上传! 数量: 1 文件路径: /03_函数调用的三种形式.mp4 文件名: 03_函数调用的三种形式.mp4
文件原大小: 32535704字节 未完成的文件大小: 8273050字节 上传的百分比: 25.43% ------您的files文件夹下所含有的文件------
1: .download
2: 03_函数调用的三种形式.mp4
3: 服务器管理综合报告.docx [egon@localhost ~]# resume_upload 03_函数调用的三种形式.mp4
您正在继续上传文件, 在您继传之前, 您的目前空间:89.21MB!
8273050 upload running...
[##################################################] 100.00%
upload succeed!
上传文件成功, 您上传完后的剩余空间:66.07MB!
1) 继续上传文件到服务端当前路径:
username>>:egon
password>>:123
用户名密码正确, 认证成功!
您的还有为上传完的文件, 是否继续上传! 数量: 1 文件路径: 的目录/03_函数调用的三种形式.mp4 文件名: 03_函数调用的三种形式.mp4
文件原大小: 32535704字节 未完成的文件大小: 12534221字节 上传的百分比: 38.52% ------您的files文件夹下所含有的文件------
1: .download
2: 03_函数调用的三种形式.mp4
3: 服务器管理综合报告.docx [egon@localhost ~]# cd /我是egon的目录
切换目录成功 ------您的files文件夹下所含有的文件------
1: .download
2: 03_函数调用的三种形式.mp4
3: 服务器管理综合报告.docx [egon@localhost /我是的目录]# resume_upload 03_函数调用的三种形式.mp4
您正在继续上传文件, 在您继传之前, 您的目前空间:66.07MB! upload running...
[##################################################] 100.00%
upload succeed!
上传文件成功, 您上传完后的剩余空间:47.00MB!
2) 通过cd切换目录, 到该目录下指定服务端的某个目录下继续上传:
(10) download: 下载文件
① 支持功能:
从服务端当前目录下下载文件
download 文件
从服务端绝对路径下下载文件
download /目录1/文件
② 运行效果:
[egon@localhost ~]# download 服务器管理综合报告.docx download run...
[##################################################] 100.00%
download succeed!
1) 从服务端当前目录下下载文件
[egon@localhost ~]# download /我是egon的目录/03_函数调用的三种形式.mp4 download run...
[##################################################] 100.00%
download succeed!
2) 从服务端绝对路径下下载文件
(11) 在download基础之上: 继续从服务端续传下载文件
① 支持功能:
用户登陆的时候显示为下载完的文件、用户根据序号选择要继续续传的文件
用户可以多次循环选择
支持断点以后据续断点续传
② 运行效果:
username>>:egon
password>>:123
用户名密码正确, 认证成功!
服务端检测您没有未上传完成的文件!
检测到您本地还有未上传完成的文件
--------------------------------------------------------------------未完成续传的数量: 2个---------------------------------------------------------------------
序号: 1 未完成的文件路径: Z:\pycharm\开发FTP程序之路\第2次FTP_修改后的内容\第二次实现方式\client\files\03_函数调用的三种形式.mp4.download 文件名: 03_函数调用的三种形式.mp4
文件原大小: 32535704字节 已完成的文件大小: 3511466字节 上传的百分比: 10.79% 序号: 2 未完成的文件路径: Z:\pycharm\开发FTP程序之路\第2次FTP_修改后的内容\第二次实现方式\client\files\服务器管理综合报告.docx.download 文件名: 服务器管理综合报告.docx
文件原大小: 3039102字节 已完成的文件大小: 712297字节 上传的百分比: 23.44% [退出: q/Q]请根据序号选择您是否继续下载没有完成的文件>>:1
开始续传...... download run...
[##################################################] 100.00%
download succeed! 续传完毕!
--------------------------------------------------------------------未完成续传的数量: 1个---------------------------------------------------------------------
序号: 1 未完成的文件路径: Z:\pycharm\开发FTP程序之路\第2次FTP_修改后的内容\第二次实现方式\client\files\服务器管理综合报告.docx.download 文件名: 服务器管理综合报告.docx
文件原大小: 3039102字节 已完成的文件大小: 712297字节 上传的百分比: 23.44% [退出: q/Q]请根据序号选择您是否继续下载没有完成的文件>>:1
开始续传...... download run...
[##################################################] 100.00%
download succeed! 续传完毕! ------您的files文件夹下所含有的文件------
1: .download
2: 03_函数调用的三种形式.mp4
3: 服务器管理综合报告.docx
(12) 为用户磁盘配额
[egon@localhost /我是的目录/我是江傻子的目录/目录1]# upload 03_函数调用的三种形式.mp4
你可以上传文件, 在您上传之前, 您的目前空间:35.04MB! upload running...
[##################################################] 100.00%
upload succeed!
上传文件成功, 您上传完后的剩余空间:4.02MB! [egon@localhost /我是的目录/我是江傻子的目录/目录2]# upload 03_函数调用的三种形式.mp4
上传文件失败, 您的空间不足, 您的剩余空间:4.02MB!
(13) 使用队列queue模块,实现线程, 允许用户配置最大的并发数5个
(14) 记录了日志功能
① 终端打印
② 保存文件之中
7. 不足之处
没有实现多文件, 以及多文件夹打包上传
client用户暂时只能用files文件夹下的路径进行上传下载, 不能动态指定
8.代码展示
① client
1) conf
import os BASE_DIR = os.path.normpath(os.path.join(__file__, '..', '..')) FILES_PATH = os.path.join(BASE_DIR, 'files')
UNFINISHED_DOWNLOAD_FILES_PATH = os.path.join(FILES_PATH, '.download', 'unfinished.shv') HOST = '127.0.0.1'
PORT = 8080 help_dic = {
'ls --help': """
查看当前目录下的文件:
ls
指定目录下的文件(只能查看到自己家目录的范围):
ls /目录1/目录2
查看ls的详细帮助:
ls /?
""",
'cd --help': """
相对路径切换:
cd /目录1
cd /目录2
绝对路径切换:
cd /目录1/目录2
切换到上一层目录:
cd ..
在当前目录下切当前目录:
cd .
""",
'mkdir --help': """
相对路径创建:
mkdir /目录
生成多层递归目录:
mkdir /目录1/目录2
""",
'rmdir --help': """
删除空目录:
rmdir /目录1/空目录2
""",
'remove --help': """
删除文件:
remove /目录1/文件
""",
'upload --help': """
上传到服务端当前路径:
upload 文件名
通过cd切换目录上传文件到该目录下:
cd /目录1/目录2
upload 文件
""",
'resume_upload --help': """
继续上传文件到服务端当前路径:
resume_upload 文件名
通过cd切换目录, 到该目录下指定服务端的某个目录下继续上传:
cd /目录1/目录2
resume_upload 文件名
""",
None: """
查看相对应的帮助信息:
1. ls + --help
2. cd --help
3. mkdir --help
4. rmdir --help
5. remove --help
6. upload --help
7. resume_upload --help
""",
}
settings.py
2) core
import hashlib
import json
import os
import shelve
import socket
import struct from conf import settings class FTPClient:
"""FTP客户端."""
address_family = socket.AF_INET
socket_type = socket.SOCK_STREAM
max_packet_size = 8192
encoding = 'utf-8'
windows_encoding = 'gbk' struct_fmt = 'i'
fixed_packet_size = 4 def __init__(self, server_address, connect=True):
self.server_address = server_address
self.socket = socket.socket(self.address_family, self.socket_type) self.breakpoint_resume = shelve.open(settings.UNFINISHED_DOWNLOAD_FILES_PATH) self.username = None
self.current_dir = '~'
if connect:
try:
self.client_connect()
except Exception:
self.client_close()
raise def client_connect(self):
"""客户端连接服务端ip和port."""
self.socket.connect(self.server_address) def client_close(self):
"""关闭连接通道."""
self.socket.close() def interactive(self):
"""与服务端进行所有的交互."""
if self.auth():
self.unfinished_file_check()
while True:
self.show_str()
msg = input('[%s@localhost %s]# ' % (self.username, self.current_dir)).strip()
if not msg:
continue
if not self.help_msg(msg):
continue
# 核验命令参数
cmd, path = self.verify_args(msg)
if hasattr(self, '_%s' % cmd):
func = getattr(self, '_%s' % cmd)
func(path)
else:
self.help_msg() @staticmethod
def verify_args(msg):
"""
效验参数.
:param msg: ls 或 ls /路径 或 ls /路径1/路径2/
:return: (ls, []) 或 (ls, ['路径']) 或 (ls, ['路径1', '路径2'])
"""
cmd_args = msg.split()
cmd, path = cmd_args[0], cmd_args[1:]
if path:
path = ''.join(cmd_args[1:]).strip('//').split('/')
# print('cmd, path:', cmd, path)
return cmd, path def unfinished_file_check(self):
if not list(self.breakpoint_resume.keys()):
return print('检测到您本地还有未上传完成的文件')
unfinished_path_list = []
msg_list = []
for unfinished_file_path in self.breakpoint_resume.keys():
file_name = self.breakpoint_resume[unfinished_file_path]['file_name']
file_size = self.breakpoint_resume[unfinished_file_path]['file_size']
unfinished_file_size = os.path.getsize(unfinished_file_path)
percent = unfinished_file_size / file_size * 100
path = self.breakpoint_resume[unfinished_file_path]['path']
dic = {'unfinished_file_size': unfinished_file_size, 'path': path}
unfinished_path_list.append(dic)
msg = """
未完成的文件路径: %s 文件名: %s
文件原大小: %s字节 已完成的文件大小: %s字节 上传的百分比: %.2f%%
""" % (unfinished_file_path, file_name, file_size, unfinished_file_size, percent)
msg_list.append(msg) while msg_list:
print("未完成续传的数量: %s个".center(150, '-') % len(msg_list))
for msg in msg_list:
print('序号: %s' % (int(msg_list.index(msg) + 1)))
print(msg) choice = input('[退出: q/Q]请根据序号选择您是否继续下载没有完成的文件>>:').strip()
if choice.lower() == 'q':
break
if not choice.isdigit():
continue
choice = int(choice)
if 0 < choice <= len(unfinished_path_list): # len(unfinished_path_list)=3
dic = unfinished_path_list[choice - 1]
path, unfinished_file_size = dic['path'], dic['unfinished_file_size'] print('开始续传......')
self.__resume_download(path, unfinished_file_size)
print('\n续传完毕!') unfinished_path_list.pop(choice-1)
msg_list.pop(choice-1)
else:
print('您的选择超出了范围!') def auth(self):
"""
登陆.
100: '用户名密码正确, 认证成功!',
199: '用户名密码不正确, 认证失败!',
850: '您的还有为上传完的文件, 是否继续上传!',
851: '检测您不存在未上传完成的文件!',
"""
count = 0
while count < 3:
username = input('username>>:').strip()
password = input('password>>:').strip()
if not all([username, password]):
print('用户名密码不能为空.')
count += 1
continue
# 发报头
self.send_header(action_type='auth', username=username, password=password)
# 收报头
response_dic = self.receive_header()
status_code, status_msg = response_dic.get('status_code'), response_dic.get('status_msg')
# 100: '用户名密码正确, 认证成功!',
if status_code == 100: # 100确认成功
print(status_msg)
self.username = username # 850: '您的还有为上传完的文件, 是否继续上传!',
# 851: '检测您不存在未上传完成的文件!',
response_dic = self.receive_header()
status_code, status_msg, msg_list, msg_dic = response_dic.get('status_code'), response_dic.get(
'status_msg'), response_dic.get('msg_list'), response_dic.get('msg_dic')
if msg_list:
print(status_msg)
for unfinished_msg in msg_list:
print(unfinished_msg)
else:
print(status_msg) return True
else:
# 199: '用户名密码不正确, 认证失败!',
print(status_msg)
count += 1
else:
print('输入次数过多,强制退出!')
return False def _ls(self, path):
"""
显示目录的文件列表.
:param path: [] 或 ['目录1', '目录2']
:return: None
"""
# 发送报头
self.send_header(action_type='ls', path=path)
# 接收报头
response_dic = self.receive_header()
status_code, status_msg, cmd_size = response_dic.get('status_code'), response_dic.get(
'status_msg'), response_dic.get('cmd_size')
if status_code == 301 and cmd_size:
# print('status_msg:', status_msg)
# print('cmd_size:', cmd_size)
# 收消息
windows_cmd = self.socket.recv(cmd_size).decode(self.windows_encoding)
print(windows_cmd)
else:
print(status_msg) def _cd(self, path):
"""
切换目录.
:param path: ['..'] 或 ['路径1', '目录2']
:return: None
"""
# 发送报头
self.send_header(action_type='cd', path=path)
# 接收报头
response_dic = self.receive_header()
status_code, status_msg, current_dir = response_dic.get('status_code'), response_dic.get(
'status_msg'), response_dic.get('current_dir')
if status_code == 400:
self.current_dir = current_dir
print(status_msg)
else:
print(status_msg) def _mkdir(self, path):
"""
新建目录.
:param path: ['目录1']
或 [目录2', '目录3']
:return: None
"""
# print(path)
# 发送报头
self.send_header(action_type='mkdir', path=path)
# 接收报头
response_dic = self.receive_header()
status_code, status_msg = response_dic.get('status_code'), response_dic.get(
'status_msg')
if status_code == 500:
print(status_msg)
else:
print(status_msg) def _rmdir(self, path):
"""
删除空目录.
:param path: ['', '12312都1的发']
:return: None
"""
# print(path)
# 发送报头
self.send_header(action_type='rmdir', path=path)
# 接收报头
response_dic = self.receive_header()
status_code, status_msg = response_dic.get('status_code'), response_dic.get(
'status_msg')
if status_code == 600:
print(status_msg)
else:
print(status_msg) def _remove(self, path):
"""
删除文件.
:param path: ['目录1', '文件1']
:return:
"""
# print(path)
# 发送报头
self.send_header(action_type='remove', path=path)
# 接收报头
response_dic = self.receive_header()
status_code, status_msg = response_dic.get('status_code'), response_dic.get(
'status_msg')
if status_code == 700:
print(status_msg)
else:
print(status_msg) def parser_path(self, action_type, path, **kwargs):
"""
解析路径参数, 判断路径是文件名, 还是路径下的文件名.
:param action_type: 用户上传的功能类型
:param path: 用户路径例子: ['目录1', '文件1'] 或 ['文件1']
:param kwargs:
:return: path列表长度合理的时候返回True, 不合理返回False
"""
if len(path) > 1:
self.send_header(action_type=action_type, **kwargs, file_name=path[-1],
path=path[:-1])
elif len(path) == 1:
self.send_header(action_type=action_type, **kwargs, file_name=path[-1],
path=None)
else:
print('必须指定路径, 或者文件名!')
return False
return True def _resume_upload(self, path):
"""
upload的断点续传功能.
860: '您正在继续上传文件, 在您继传之前, 您的目前空间:%s!',
869: '您选择文件路径中没有要续传的文件, 请核对!',
"""
self._upload(path, resume_upload=True) def _upload(self, path, resume_upload=False):
"""
上传文件到服务端.
正常上传:
800: '你可以上传文件, 在您上传之前, 您的目前空间:%s!',
801: '上传文件成功, 您上传完后的剩余空间:%s!',
852: '您不能进行续传, 因为该文件是完整文件!',
894: '您不需要再本路径下上传文件, 该文件在您的当前路径下已经存在!',
895: '上传文件失败, md5效验不一致, 部分文件内容在网络中丢失, 请重新上传!',
896: '上传文件失败, 您的空间不足, 您的上传虚假文件大小, 您的剩余空间:%s!',
897: '上传文件失败, 您的空间不足, 您的剩余空间:%s!',
898: '上传文件失败, 上传命令不规范!',
899: '上传文件必须要有文件的md5值以及文件名!',
续传:
860: '您正在继续上传文件, 在您继传之前, 您的目前空间:%s!',
869: '您选择文件路径中没有要续传的文件, 请核对!',
:param path: ['目录1', '文件1'] 或 ['文件1']
:return: None
"""
# 判断用户文件路径是否是FILES_PATH路径下的文件
file_path = os.path.normpath(os.path.join(settings.FILES_PATH, *path))
if not os.path.isfile(file_path):
print('您要上传的文件不存在!')
return # 解析用户路径, 并提交upload的相关功能
file_size = os.path.getsize(file_path)
file_md5 = self.md5(file_path) if resume_upload: # 断点续传时执行
action_type = 'resume_upload'
else: # 正常长传时执行
action_type = 'upload' if not self.parser_path(action_type=action_type, file_md5=file_md5, file_size=file_size, path=path):
return # 接收服务端相应字典
# 正常: 800, 894, 897, 898, 899
# 800: '你可以上传文件, 在您上传之前, 您的目前空间:%s!',
# 894: '您不需要再本路径下上传文件, 该文件在您的当前路径下已经存在!',
# 898: '上传文件失败, 上传命令不规范!',
# 897: '上传文件失败, 您的空间不足, 您的剩余空间:%s!',
# 899: '上传文件必须要有文件的md5值以及文件名!',
# 续传: 860, 869
# 860: '您正在继续上传文件, 在您继传之前, 您的目前空间:%s!',
# 869: '您选择文件路径中没有要续传的文件, 请核对!',
response_dic = self.receive_header()
status_code, status_msg, residual_space_size, already_upload_size = response_dic.get(
'status_code'), response_dic.get(
'status_msg'), response_dic.get('residual_space_size'), response_dic.get('already_upload_size') # 判断状态码
# 800: '你可以上传文件, 在您上传之前, 您的目前空间:%s!',
# 860: '您正在继续上传文件, 在您继传之前, 您的目前空间:%s!',
if status_code == 800 or status_code == 860: # 800正常发送文件确认 860续传文件确认
print(status_msg % self.conversion_quota(residual_space_size)) initial_size = 0
if resume_upload: # 断点续传时执行: 目前文件总大小要减去上次没有上传完位置的大小
total_size = file_size - already_upload_size
else: # 正常上传时执行
total_size = file_size
with open(file_path, 'rb') as f:
if resume_upload: # 断点续传时执行: 光标移动到上次没有上传完位置
f.seek(already_upload_size)
print('\nupload running...')
for line in f:
self.socket.sendall(line)
initial_size += len(line)
percent = initial_size / total_size
self.progress_bar(percent)
print('\nupload succeed!') # 第二次接收消息, 确认文件上传完毕
# 801, 895, 896
# 801: '上传文件成功, 您上传完后的剩余空间:%s!',
# 895: '上传文件失败, md5效验不一致, 部分文件内容在网络中丢失, 请重新上传!',
# 896: '上传文件失败, 您的空间不足, 您的上传虚假文件大小, 您的剩余空间:%s!',
response_dic = self.receive_header()
status_code, status_msg, residual_space_size = response_dic.get('status_code'), response_dic.get(
'status_msg'), response_dic.get('residual_space_size')
if residual_space_size: # 801, 896
print(status_msg % self.conversion_quota(residual_space_size))
else: #
print(status_msg)
else:
# 正常: 894, 897, 898, 899
# 894: '您不需要再本路径下上传文件, 该文件在您的当前路径下已经存在!',
# 897: '上传文件失败, 您的空间不足, 您的剩余空间:%s!',
# 898: '上传文件失败, 上传命令不规范!',
# 899: '上传文件必须要有文件的md5值以及文件名!',
# 续传:
# 869: '您选择文件路径中没有要续传的文件, 请核对!',
if residual_space_size: #
print(status_msg % self.conversion_quota(residual_space_size))
else: # 869, 894, 898, 899
print(status_msg) def __resume_download(self, path, unfinished_file_size):
self._download(path, unfinished_file_size, resume_download=True) def _download(self, path, unfinished_file_size=None, resume_download=False):
""" 900: '准备开始下载文件!',
999: '下载文件失败, 您要下载的文件路径不规范!',
:param path:
:param resume_download:
:return:
"""
if resume_download:
action_type = 'resume_download'
else:
action_type = 'download'
self.send_header(action_type=action_type, path=path, unfinished_file_size=unfinished_file_size) # 接收服务端消息
# self.send_header(status_code=900, file_name=file_name, file_size=file_size, file_md5=file_md5)
response_dic = self.receive_header()
status_code, status_msg, file_name, file_size, file_md5 = response_dic.get('status_code'), response_dic.get(
'status_msg'), response_dic.get('file_name'), response_dic.get('file_size'), response_dic.get('file_md5') # 判断状态码
# 900: '准备开始下载文件!',
# 950: '准备开始续传文件!',
# 998: '下载文件失败, 您要下载的文件路径不存在!',
# 999: '下载文件失败, 您要下载的文件路径不规范!',
if status_code == 900 or status_code == 950: file_path = os.path.join(settings.FILES_PATH, file_name)
if resume_download and file_path in self.breakpoint_resume.keys():
unfinished_file_path = self.breakpoint_resume[file_path]['unfinished_file_path']
else:
# 判断本次路径下是否有文件, 有文件则提示
# file_path = os.path.join(settings.FILES_PATH, file_name)
if os.path.isfile(file_path):
print('本次路径下文件已经存在, 不需要继续下载!')
return
# 为没有下载完毕的文件名添加后缀
unfinished_file_path = '%s.%s' % (file_path, 'download') # 为出现下载终端添加断点记录
self.breakpoint_resume[unfinished_file_path] = {'file_name': file_name, 'file_size': file_size,
'path': path} # 开始进行下载
receive_size = 0
if resume_download:
total_size = file_size - os.path.getsize(unfinished_file_path)
mode = 'a'
else:
total_size = file_size
mode = 'w'
with open(unfinished_file_path, '%sb' % mode) as f:
print('\ndownload run...')
while receive_size < total_size:
data_bytes = self.socket.recv(self.max_packet_size)
f.write(data_bytes)
receive_size += len(data_bytes)
percent = receive_size / total_size
self.progress_bar(percent)
print('\ndownload succeed!')
f.flush() # 正常下载成功把后缀去除, 文件改名, 删除断点记录
del self.breakpoint_resume[unfinished_file_path]
os.rename(unfinished_file_path, file_path) # 效验md5值询问用户是否保存
server_file_md5 = file_md5
current_file_md5 = self.md5(file_path)
if server_file_md5 != current_file_md5:
print('您的文件不完成, 可能不能打开, 请重新下载!')
else:
# 998: '下载文件失败, 您要下载的文件路径不存在!',
# 999: '下载文件失败, 您要下载的文件路径不规范!',
print(status_msg) @staticmethod
def conversion_quota(residual_space_size):
"""
换算服务端发送过来的字节为MB, 人性化的展现用户的空间剩余.
:param residual_space_size: 剩余空间字节数
:return: MB为单位的字节
"""
residual_space_mb = residual_space_size / (1024 ** 2)
return '%.2fMB' % residual_space_mb def receive_header(self):
"""
接收服务端发送过来的报头字典.
:return: {'status_code': 100, 'status_msg': '认证成功', 'cmd_size': 199}
"""
header_bytes = self.socket.recv(self.fixed_packet_size)
header_dic_json_length = struct.unpack(self.struct_fmt, header_bytes)[0]
# 接收报头
header_dic_json = self.socket.recv(header_dic_json_length).decode(self.encoding)
header_dic = json.loads(header_dic_json)
return header_dic def send_header(self, *, action_type, **kwargs):
"""
发送报头字典给客户端.
:param action_type: action_type='auth'
:param kwargs: {'username': 'egon', 'password': '123'}
:return: None
"""
request_dic = kwargs
request_dic['action_type'] = action_type
request_dic.update(request_dic) request_dic_json_bytes = json.dumps(request_dic).encode(self.encoding)
request_dic_json_bytes_length = len(request_dic_json_bytes)
header_bytes = struct.pack(self.struct_fmt, request_dic_json_bytes_length) # 发送报头
self.socket.sendall(header_bytes)
# 发送json后bytes后的字典request_dic
self.socket.sendall(request_dic_json_bytes) @staticmethod
def md5(file_path):
"""
md5加密哈希文件.
:param file_path: files下的文件路径
:return: 文件hash值
"""
md5_obj = hashlib.md5()
with open(file_path, 'rb') as f:
for line in f:
md5_obj.update(line)
return md5_obj.hexdigest() @staticmethod
def progress_bar(percent, width=50, symbol='#'):
"""进度条功能."""
if percent > 1:
percent = 1
show_str = ('[%%-%ds]' % width) % (int(width * percent) * symbol)
print('\r%s %.2f%%' % (show_str, percent * 100), end='') @staticmethod
def show_str():
"""显示客户端flies中的文件列表."""
print('\n------您的files文件夹下所含有的文件------')
for index, filename in enumerate(os.listdir(settings.FILES_PATH), 1):
print('%s: %s' % (index, filename))
print() @staticmethod
def help_msg(msgs=None):
"""帮助信息."""
if msgs in settings.help_dic:
print(settings.help_dic[msgs])
else:
return True
main.py
3) files
- 存放上传服务器的目录
# encoding: utf-8 import os
import sys BASE_DIR = os.path.normpath(os.path.join(__file__, '..'))
print(BASE_DIR)
sys.path.append(BASE_DIR) if __name__ == '__main__':
from core import main
client = main.FTPClient(('127.0.0.1', 8080))
client.interactive()
ftp_client.py
②server
1) conf
[egon]
password = 202cb962ac59075b964b07152d234b70
quota = 100 [alex]
password = 202cb962ac59075b964b07152d234b70
quota = 100 [ly]
password = 202cb962ac59075b964b07152d234b70
quota = 200 [jzd]
password = 202cb962ac59075b964b07152d234b70
quota = 300 [shx]
password = 202cb962ac59075b964b07152d234b70
quota = 300 [xxx]
password = 202cb962ac59075b964b07152d234b70
quota = 300
accounts.ini
import os def base_dir(*args):
return os.path.normpath(os.path.join(__file__, '..', '..', *args)) # 用户家目录存放路径
USER_HOME_DIR = base_dir('home') # 用户账户信息文件路径
ACCOUNTS_FILE = base_dir('conf', 'accounts.ini') # 本机测试的ip和port
HOST = '127.0.0.1'
PORT = 8080 # 状态码: 负责提供交互成功及失败的提示信息反馈
STATUS_CODE = {
100: '用户名密码正确, 认证成功!',
199: '用户名密码不正确, 认证失败!',
200: '您的功能指定不能为空!',
201: '没有该功能, 请查看帮助信息!',
301: '本次返回结果包含命令大小.',
400: '切换目录成功',
498: '切换目录失败, 切换命令不规范',
499: '切换目录失败, 目标地址不存在!',
500: '创建目录成功!',
598: '创建目录命令输入不规范!',
599: '创建的目录已存在!',
600: '删除目录成功!',
699: '删除目录失败, 该目录不为空!',
698: '删除目录失败, 不存在该目录!',
697: '删除目录失败, 删除命令不规范!',
700: '删除文件成功!',
799: '删除文件失败, 不存在该文件!',
800: '你可以上传文件, 在您上传之前, 您的目前空间:%s!',
801: '上传文件成功, 您上传完后的剩余空间:%s!',
850: '服务端检测您还有为上传完的文件, 是否继续上传!',
851: '服务端检测您没有未上传完成的文件!',
852: '您不能进行续传, 因为该文件是完整文件!',
860: '您正在继续上传文件, 在您继传之前, 您的目前空间:%s!',
869: '您选择文件路径中没有要续传的文件, 请核对!',
894: '您不需要再对本路径下上传文件, 该文件在您的当前路径下已经存在!',
895: '上传文件失败, md5效验不一致, 部分文件内容在网络中丢失, 请重新上传!',
896: '上传文件失败, 您的空间不足, 您的上传虚假文件大小, 您的剩余空间:%s!',
897: '上传文件失败, 您的空间不足, 您的剩余空间:%s!',
898: '上传文件失败, 上传命令不规范!',
899: '上传文件必须要有文件的md5值以及文件名!',
900: '准备开始下载文件!',
950: '准备开始续传文件!',
998: '下载文件失败, 您要下载的文件路径不存在!',
999: '下载文件失败, 您要下载的文件路径不规范!',
} # log日志路径
ACCESS_LOG_PATH = base_dir('log', 'access.log') # 定义log日志输出格式
standard_format = '%(asctime)s - %(threadName)s:%(thread)d - task_id:%(name)s - %(filename)s:%(lineno)d - ' \
'%(levelname)s - %(message)s' # 其中name为getlogger指定的名字 simple_format = '\n%(levelname)s - %(asctime)s - %(filename)s:%(lineno)d - %(message)s\n' # log配置字典
LOGGING_DIC = {
'version': 1,
'disable_existing_loggers': False,
'formatters': {
'standard': {
'format': standard_format
},
'simple': {
'format': simple_format,
},
},
'filters': {},
'handlers': {
# 打印到终端的日志
'console': {
'level': 'DEBUG',
'class': 'logging.StreamHandler', # 打印到屏幕
'formatter': 'simple'
},
# 打印到文件的日志,收集info及以上的日志
'access': {
'level': 'DEBUG',
'class': 'logging.handlers.RotatingFileHandler', # 保存到文件
'formatter': 'standard',
'filename': ACCESS_LOG_PATH, # 日志文件
# 'maxBytes': 1024 * 1024 * 5, # 日志大小 5M
'maxBytes': 1024 * 1024 * 5,
'backupCount': 10,
'encoding': 'utf-8', # 日志文件的编码,再也不用担心中文log乱码了
},
},
'loggers': {
# logging.getLogger(__name__)拿到的logger配置
'': {
'handlers': ['access', 'console'], # 这里把上面定义的两个handler都加上,即log数据既写入文件又打印到屏幕
'level': 'DEBUG',
'propagate': False, # 向上(更高level的logger)传递
},
},
}
settings.py
2) core
import json
import os
import shelve
import struct
import subprocess from conf import settings
from lib import common class HandlerRequest:
"""处理用户请求."""
max_packet_size = 8192
encoding = 'utf-8' struct_fmt = 'i'
fixed_packet_size = 4 logger = common.load_my_logging_cfg() def __init__(self, request, address):
self.request = request
self.address = address self.residual_space_size = None self.breakpoint_resume = None self.username = None
self.user_obj = None
self.user_current_dir = None def client_close(self):
"""关闭客户端连接."""
self.request.close() def handle_request(self):
"""处理客户端请求."""
count = 0
while count < 3: # 连接循环
try:
if self.auth():
# 收消息
user_dic = self.receive_header()
action_type = user_dic.get('action_type')
if action_type:
if hasattr(self, '_%s' % action_type):
func = getattr(self, '_%s' % action_type)
func(user_dic)
else:
self.send_header(status_code=201)
# 发消息
else:
self.send_header(status_code=200)
else:
count += 1
self.send_header(status_code=199)
except ConnectionResetError:
break
# 关闭客户端连接
self.logger.info('----连接断开---- ip:%s port:%s' % self.address)
self.client_close() def unfinished_file_check(self):
self.logger.info('#执行unfinished_file_check命令# ip:%s port:%s' % self.address) if not list(self.breakpoint_resume.keys()):
self.send_header(status_code=851)
return # self.breakpoint_resume[file_path] =
# {'file_size': _file_size, 'unfinished_file_path': unfinished_file_path, 'file_name': _file_name}
msg_list = [] for index, abs_path in enumerate(self.breakpoint_resume.keys(), 1):\ user_path = '/'.join(abs_path.split(self.username)[-1].split(os.sep))
print('abs_path:', user_path)
file_name = self.breakpoint_resume[abs_path]['file_name']
src_file_size = self.breakpoint_resume[abs_path]['file_size']
unfinished_file_size = os.path.getsize(self.breakpoint_resume[abs_path]['unfinished_file_path'])
percent = unfinished_file_size / src_file_size * 100 msg = """
数量: %s 文件路径: %s 文件名: %s
文件原大小: %s字节 未完成的文件大小: %s字节 上传的百分比: %.2f%%
""" % (index, user_path, file_name, src_file_size, unfinished_file_size, percent) msg_list.append(msg)
# msg_dic['/03_函数调用的三种形式.mp4'] = 5772100
# msg_dic[user_path] = unfinished_file_size
# self.send_header(status_code=850, msg_list=msg_list, msg_dic=msg_dic)
self.send_header(status_code=850, msg_list=msg_list) def auth(self):
"""用户登陆认证."""
if self.user_current_dir:
return True # 涉及到交叉导入
from core import main
# 收消息
auth_dic = self.receive_header() user_name = auth_dic.get('username')
user_password = auth_dic.get('password')
md5_password = common.md5('password', password=user_password) # print(user_name, user_password, md5_password) accounts = main.FTPServer.load_accounts()
if user_name in accounts.sections():
if md5_password == accounts[user_name]['password']:
self.send_header(status_code=100) self.username = user_name
self.user_obj = accounts[user_name]
self.user_obj['home'] = os.path.join(settings.USER_HOME_DIR, user_name)
self.user_current_dir = self.user_obj['home'] # print('self.user_obj:', self.user_obj)
# print("self.user_obj['home']:", self.user_obj['home']) self.residual_space_size = common.conversion_quota(
self.user_obj['quota']) - common.get_size(self.user_obj['home']) breakpoint_resume_dir_path = os.path.join(self.user_obj['home'], '.upload')
if not os.path.isdir(breakpoint_resume_dir_path):
os.mkdir(breakpoint_resume_dir_path)
self.breakpoint_resume = shelve.open(os.path.join(breakpoint_resume_dir_path, '.upload.shv'))
self.unfinished_file_check() self.logger.info('#认证成功# ip:%s port:%s' % self.address)
return True
self.logger.info('#认证失败# ip:%s port:%s' % self.address)
return False def _ls(self, cmd_dic):
"""
运行dir命令将结果发送到客户端.
:param cmd_dic: {'path': [], 'action_type': 'ls'}
或 {'path': ['目录1', '目录2', '目录xxx'], 'action_type': 'ls'}
或 {'path': ['?'], 'action_type': 'ls'}
:return: None
"""
# print('_ls:', cmd_dic)
self.logger.info('#执行ls命令# ip:%s port:%s' % self.address) # 核验路径
dir_path = self.verify_path(cmd_dic)
if not dir_path:
dir_path = self.user_current_dir if cmd_dic.get('path') == ['?']: # 为用户提供ls /?命令
dir_path = '/?' sub_obj = subprocess.Popen(
'dir %s' % dir_path,
shell=True,
stderr=subprocess.PIPE,
stdout=subprocess.PIPE
)
stderr_bytes, stdout_bytes = sub_obj.stderr.read(), sub_obj.stdout.read()
cmd_size = len(stderr_bytes) + len(stdout_bytes) # 发报头
self.send_header(status_code=301, cmd_size=cmd_size)
# 发消息
self.request.sendall(stderr_bytes)
self.request.sendall(stdout_bytes) def _cd(self, cmd_dic):
"""
根据用户的目标目录, 改变用户的当前目录的值.
:param cmd_dic: {'action_type': 'cd', 'path': ['..']}
或 {'action_type': 'cd', 'path': ['目录1', '目录2', '目录xxx'], }
:return: None
Z:\\pycharm\\开发FTP程序之路\\第2次FTP_第四模块作业\\FUCK_FTP\\server\\home\\egon\\目录1
"""
# print('_cd:', cmd_dic)
self.logger.info('#执行cd命令# ip:%s port:%s' % self.address) # 核验路径
dir_path = self.verify_path(cmd_dic)
if dir_path:
if os.path.isdir(dir_path): # 判断用户切换的路径是否存在
self.user_current_dir = dir_path
if dir_path == self.user_obj['home']:
current_dir = '~'
else:
join_dir = ''.join(dir_path.split('%s' % self.username)[1:])
current_dir = '/'.join(join_dir.split('\\'))
self.send_header(status_code=400, current_dir=current_dir)
else:
self.send_header(status_code=499)
else:
self.send_header(status_code=498) def _mkdir(self, cmd_dic):
"""
更具用户的目标目录, 且目录不存在, 创建目录标目录, 生成多层递归目录.
:param cmd_dic: {'action_type': 'mkdir', 'path': ['目录1']}
或 {'action_type': 'mkdir', 'path': ['目录2', '目录3', '目录xxx']}
:return: None
"""
# print('_mkdir:', cmd_dic)
self.logger.info('#执行mkdir命令# ip:%s port:%s' % self.address) dir_path = self.verify_path(cmd_dic)
if dir_path:
if not os.path.isdir(dir_path): # 判断用户要创建的目录时否存在
os.makedirs(dir_path)
self.send_header(status_code=500)
else:
self.send_header(status_code=599)
else:
self.send_header(status_code=598) def _rmdir(self, cmd_dic):
"""
更具用户的目标目录, 删除不为空的目录.
:param cmd_dic: {'path': ['目录1', '目录xxx', '空目录'], 'action_type': 'rmdir'}
:return: None
"""
# print('_rmdir:', cmd_dic)
self.logger.info('#执行rmdir命令# ip:%s port:%s' % self.address) dir_path = self.verify_path(cmd_dic)
if dir_path:
if os.path.isdir(dir_path):
if os.listdir(dir_path):
self.send_header(status_code=699)
else:
os.rmdir(dir_path)
self.send_header(status_code=600)
else:
self.send_header(status_code=698)
else:
self.send_header(status_code=697) def _remove(self, cmd_dic):
"""
更具用户的目标文件, 删除该文件
:param cmd_dic: {'path': ['目录1', '目录xxx', '文件'], 'action_type': 'remove'}
:return:
"""
# print('_remove:', cmd_dic)
self.logger.info('#执行remove命令# ip:%s port:%s' % self.address)
file_path = self.verify_path(cmd_dic) if file_path:
if os.path.isfile(file_path):
# 判断用户删除的文件是否是要续传的文件, 如果是则先把把续传的记录删除
if file_path in self.breakpoint_resume.keys:
del self.breakpoint_resume[file_path]
os.remove(file_path)
self.send_header(status_code=700)
else:
self.send_header(status_code=799)
else:
self.send_header(status_code=798) def _resume_upload(self, cmd_dic):
"""
860: '您正在继续上传文件, 在您继传之前, 您的目前空间:%s!',
869: '您选择文件路径中没有要续传的文件, 请核对!',
:param cmd_dic:
:return:
"""
# print('def _resume_upload ===> cmd_args', cmd_dic)
self.logger.info('#执行resume_upload命令# ip:%s port:%s' % self.address)
self._upload(cmd_dic, resume_upload=True) def _upload(self, cmd_dic, resume_upload=False):
"""客户端
800: '你可以上传文件, 在您上传之前, 您的目前空间:%s!',
801: '上传文件成功, 您上传完后的剩余空间:%s!',
850: '您的还有为上传完的文件, 是否继续上传!',
851: '检测您不存在未上传完成的文件!',
852: '您不能进行续传, 因为该文件是完整文件!',
860: '您正在继续上传文件, 在您继传之前, 您的目前空间:%s!',
869: '您选择文件路径中没有要续传的文件, 请核对!',
894: '您不需要再对本路径下上传文件, 该文件在您的当前路径下已经存在!',
895: '上传文件失败, md5效验不一致, 部分文件内容在网络中丢失, 请重新上传!',
896: '上传文件失败, 您的空间不足, 您的上传虚假文件大小, 您的剩余空间:%s!',
897: '上传文件失败, 您的空间不足, 您的剩余空间:%s!',
898: '上传文件失败, 上传命令不规范!',
899: '上传文件必须要有文件的md5值以及文件名!',
"""
# print('_upload:', cmd_dic)
if not resume_upload:
self.logger.info('#执行upload命令# ip:%s port:%s' % self.address) # 效验: 897, 898, 899
_path, _file_md5, _file_name, _file_size = cmd_dic.get('path'), cmd_dic.get('file_md5'), cmd_dic.get(
'file_name'), cmd_dic.get('file_size')
file_path = self.verify_upload_action(cmd_dic, _path=_path, _file_md5=_file_md5, _file_name=_file_name, _file_size=_file_size) if resume_upload: # 断点续传时执行
if not file_path or file_path not in self.breakpoint_resume.keys():
# 869: '您选择文件路径中没有要续传的文件, 请核对!',
self.send_header(status_code=869)
return # 找到之前未穿完的文件名
unfinished_file_path = self.breakpoint_resume[file_path]['unfinished_file_path']
already_upload_size = os.path.getsize(unfinished_file_path) # 效验成功通知续传信号
# 860: '您正在继续上传文件, 在您继传之前, 您的目前空间:%s!',
self.send_header(status_code=860, residual_space_size=self.residual_space_size,
already_upload_size=already_upload_size) total_size = _file_size - already_upload_size
mode = 'a'
else: # 正常上传执行
if not file_path:
return # 判断用户上传的文件是否重复
if os.path.isfile(file_path):
# 894: '您不需要再对本路径下上传文件, 该文件在您的当前路径下已经存在!',
self.send_header(status_code=894)
return
else:
unfinished_file_path = '%s.%s' % (file_path, 'upload') # 效验成功通知上传信号: 800
# 800: '你可以上传文件, 在您上传之前, 您的目前空间:%s!',
self.send_header(status_code=800, residual_space_size=self.residual_space_size) total_size = _file_size
mode = 'w' # 记录断点的功能: 在服务端用户的路径, 记录文件大小, 加上后缀的路径, 文件名
# 或再次为未传完的文件记录断点
self.breakpoint_resume[file_path] = {'file_size': _file_size, 'unfinished_file_path': unfinished_file_path,
'file_name': _file_name} # 开始接收文件
receive_size = 0
with open(unfinished_file_path, '%sb' % mode) as f:
while receive_size < total_size:
data_bytes = self.request.recv(self.max_packet_size)
receive_size += len(data_bytes)
f.write(data_bytes)
# 接收完毕, 把后缀改成用户上传的文件名
os.rename(unfinished_file_path, file_path)
# 删除记录断点的功能
del self.breakpoint_resume[file_path] # 801, 895, 896
# 效验用户端发送的md5于本次上传完毕的md5值
upload_file_md5 = common.md5(encryption_type='file', path=file_path)
if upload_file_md5 != _file_md5:
# print('def _upload ===> upload_file_md5:%s, _file_md5:%s' % (upload_file_md5, _file_md5))
# 895: '上传文件失败, md5效验不一致, 部分文件内容在网络中丢失, 请重新上传!',
self.send_header(status_code=895)
os.remove(file_path)
return # 安全性问题: 再次判断用户是否以假的文件大小来跳出服务端限制的配额
if receive_size > self.residual_space_size:
# 896: '上传文件失败, 您的空间不足, 您的上传虚假文件大小, 您的剩余空间:%s!',
self.send_header(status_code=896, residual_space_size=self.residual_space_size)
os.remove(file_path)
return
else:
self.residual_space_size = self.residual_space_size - receive_size
# print('def _upload ===> receive_size:', receive_size)
# print('def _upload ===> os.path.getsize(file_path)', os.path.getsize('%s' % file_path))
# 801: '上传文件成功, 您上传完后的剩余空间:%s!',
self.send_header(status_code=801, residual_space_size=self.residual_space_size) def _resume_download(self, cmd_dic):
self._download(cmd_dic, resume_download=True) def _download(self, cmd_dic, resume_download=False):
self.logger.info('#执行download命令# ip:%s port:%s' % self.address) file_path = self.verify_path(cmd_dic)
if not file_path:
# 999: '下载文件失败, 您要下载的文件路径不规范!',
self.send_header(status_code=999)
return if not os.path.isfile(file_path):
# 998: '下载文件失败, 您要下载的文件路径不存在!',
self.send_header(status_code=998)
return # 通知可以开始下载
# 900: '准备开始下载文件!'.
file_name = file_path.split(os.sep)[-1]
file_size = os.path.getsize(file_path)
file_md5 = common.md5('file', file_path)
unfinished_file_size = cmd_dic.get('unfinished_file_size')
if resume_download:
# 950: '准备开始续传文件!',
self.send_header(status_code=950, file_name=file_name, file_size=file_size, file_md5=file_md5)
else:
# 900: '准备开始下载文件!'.
self.send_header(status_code=900, file_name=file_name, file_size=file_size, file_md5=file_md5) # 打开文件发送给客户端
with open(file_path, 'rb') as f:
if resume_download:
f.seek(unfinished_file_size)
for line in f:
self.request.sendall(line) def verify_upload_action(self, cmd_dic, *, _path, _file_name, _file_md5, _file_size):
"""
核验上传功能.
897: '上传文件失败, 您的空间不足, 您的剩余空间:%s!',
898: '上传文件失败, 上传命令不规范!',
899: '上传文件必须要有文件的md5值以及文件名!',
"""
# _path=['03_函数调用的三种形式.mp4']
if _path is None:
if _file_name and _file_md5 and _file_size:
if _file_size > self.residual_space_size:
# print('def _upload ===> self.residual_space_size:', self.residual_space_size) # 897: '上传文件失败, 您的空间不足, 您的剩余空间:%s!',
self.send_header(status_code=897, residual_space_size=self.residual_space_size)
return False
else:
# Z:\pycharm\开发FTP程序之路\第2次FTP_第四模块作业\FUCK_FTP\server\home\egon\03_函数调用的三种形式.mp4
file_path = os.path.join(self.user_current_dir, _file_name)
else:
# 899: '上传文件必须要有文件的md5值以及文件名!',
self.send_header(status_code=899)
return False
else:
path = self.verify_path(cmd_dic) if not path:
# 898: '上传文件失败, 上传命令不规范!',
self.send_header(status_code=898)
return False
else:
# Z:\pycharm\开发FTP程序之路\第2次FTP_第四模块作业\FUCK_FTP\server\home\egon\03_函数调用的三种形式.mp4
file_path = os.path.join(path, _file_name)
return file_path def verify_path(self, cmd_dic):
"""
核验客户端传过来的路径.
:param cmd_dic: {'action_type': 'ls', 'path': []}
或 {'action_type': 'ls', 'path': ['目录1', '目录xxx']}
或 {action_type': 'cd', 'path': ['目录2', '目录xxx']}
:return: None
Z:\\pycharm\\开发FTP程序之路\\第2次FTP_第四模块作业\\FUCK_FTP\\server\\home\\egon\\目录1
Z:\\pycharm\\开发FTP程序之路\\第2次FTP_第四模块作业\\FUCK_FTP\\server\\home\\egon\\目录1
"""
# print(cmd_dic)
path = cmd_dic.get('path')
if path:
if isinstance(path, list):
for element in path:
if not isinstance(element, str):
path = None
return path
abspath = os.path.normpath(os.path.join(self.user_current_dir, *path))
# print('def verify_path() ===> abspath:', abspath)
if abspath.startswith(self.user_obj['home']):
path = abspath
else:
path = None # 用户目录超出限制
else:
path = None # 不是列表类型例: '字符串'
else:
path = None # []
# print('def verify_path() ====> path', path)
return path def receive_header(self):
"""
接收客户端数据.
:return: {'action_type': 'cd', 'path': ['目录1', '目录xxx']}
"""
header_bytes = self.request.recv(self.fixed_packet_size)
request_dic_json_length = struct.unpack(self.struct_fmt, header_bytes)[0]
# print('request_dic_json_length:', request_dic_json_length)
# 接收报头
request_dic_json = self.request.recv(request_dic_json_length).decode(self.encoding)
request_dic = json.loads(request_dic_json) # print('request_dic:', request_dic) if not request_dic:
return {}
# print("def receive_header():", request_dic)
return request_dic def send_header(self, *, status_code, **kwargs):
"""
发送数据给客户端.
:param status_code: 400
:param kwargs: {'current_dir': '/home/egon/目录1/目录xxx'}
:return: None
"""
# print(status_code)
# print(kwargs)
from core import main response_dic = kwargs
response_dic['status_code'] = status_code
response_dic['status_msg'] = main.FTPServer.STATUS_CODE[status_code]
response_dic.update(kwargs) response_dic_json_bytes = json.dumps(response_dic).encode(self.encoding)
response_dic_json_bytes_length = len(response_dic_json_bytes)
header_bytes = struct.pack(self.struct_fmt, response_dic_json_bytes_length) # print('header_bytes:', header_bytes) # 发送报头
self.request.sendall(header_bytes)
# 发送json后bytes后的字典response_dic
self.request.sendall(response_dic_json_bytes)
handler_request.py
import configparser
import socket from conf import settings
from core import handler_request, mythreadpool
from lib import common class FTPServer:
"""FTP服务器."""
address_family = socket.AF_INET
socket_type = socket.SOCK_STREAM
allow_reuse_address = False
request_queue_size = 5 max_pool_size = 5 STATUS_CODE = settings.STATUS_CODE logger = common.load_my_logging_cfg() def __init__(self, management_instance, bind_address, bind_and_activate=True):
self.management_instance = management_instance self.pool = mythreadpool.MyThreadPool(self.max_pool_size) self.bind_address = bind_address
self.socket = socket.socket(self.address_family, self.socket_type) if bind_and_activate:
try:
self.server_bind()
self.server_activate()
except Exception:
self.server_close()
raise def server_bind(self):
"""服务器绑定IP,端口."""
if self.allow_reuse_address:
self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self.socket.bind(self.bind_address) def server_activate(self):
"""服务器激活."""
self.socket.listen(self.request_queue_size) def server_close(self):
"""关闭服务socket对象."""
self.socket.close() def serve_forever(self):
"""服务器永远运行."""
while True: # 通信循环
request, address = self.socket.accept() self.logger.info('----连接----# ip:%s port:%s' % address) # 来一个连接, 实例化一个处理用户请求的对象
handler_response = handler_request.HandlerRequest(request, address)
# 来了一个连接取走一个线程
thread = self.pool.get_thread()
# 同时再添加一个线程
self.pool.put_thread()
t = thread(target=handler_response.handle_request)
t.start() @staticmethod
def load_accounts():
conf_obj = configparser.ConfigParser()
conf_obj.read(settings.ACCOUNTS_FILE)
return conf_obj
main.py
import sys from conf import settings
from core import main class ManagementTool(object):
"""管理服务器."""
center_args1, center_args2 = 50, '-' def __init__(self):
self.script_argv = sys.argv
self.commands = None # print(self.script_argv) self.verify_argv() def verify_argv(self):
"""
核查参数时否合理.
例:
['启动文件路径', 'start', 'ftp', 'server']
"""
if len(self.script_argv) != 4:
self.help_msg() action_type = self.script_argv[1]
self.commands = self.script_argv[2:]
if hasattr(self, action_type):
func = getattr(self, action_type)
func()
else:
self.help_msg() @staticmethod
def help_msg():
msg = """
------严格要求输入以下命令:------
① start ftp server
② stop ftp server
③ restart ftp server
"""
exit(msg) def start(self):
"""启动ftp服务."""
if self.execute():
print('FTP started successfully!')
# FTPServer中可能用到ManagementTool中功能
server = main.FTPServer(self, (settings.HOST, settings.PORT))
server.serve_forever()
else:
self.help_msg() def execute(self):
"""解析命令."""
args1, args2 = self.commands
if args1 == 'ftp' and args2 == 'server':
return True
return False
management.py
import os
import queue
from threading import Thread class MyThreadPool:
def __init__(self, max_workers=None):
if not max_workers:
max_workers = os.cpu_count() * 5
if max_workers <= 0:
raise ValueError('max_workers 必须大于0') self.queue = queue.Queue(max_workers)
for count in range(max_workers):
self.put_thread() def put_thread(self):
self.queue.put(Thread) def get_thread(self):
return self.queue.get()
mythreadpool.py
3) home
- 用户目录,以用户名作为文件名
4) lib
import hashlib
import logging.config
import os from conf import settings def md5(encryption_type, path=None, password=None):
"""
md5加密.
:param encryption_type: 加密的类型, 支持file和password两种
:param path: 文件或目录路径
:param password: 明文密码
:return: 加密后的md5值
"""
md5_obj = hashlib.md5()
if encryption_type == 'file':
if os.path.isfile(path):
with open(path, 'rb') as f:
for line in f:
md5_obj.update(line)
return md5_obj.hexdigest()
for filename in os.listdir(path):
current_path = os.path.join(path, filename)
if os.path.isdir(current_path):
md5(encryption_type, path=current_path)
else:
with open(current_path, 'rb') as f:
for line in f:
md5_obj.update(line)
elif encryption_type == 'password':
md5_obj.update(password.encode('utf-8'))
return md5_obj.hexdigest() def load_my_logging_cfg():
"""
加载日志字典.
:return: logger对象
"""
logging.config.dictConfig(settings.LOGGING_DIC)
logger = logging.getLogger(__name__)
return logger def get_size(path):
"""
遍历用户path, 拿到path的路径大小, 该大小包含目录下的所有文件.
:param path: 路径
:return: 该路径下的所有文件的大小
"""
initial_size = 0
if os.path.isfile(path):
return os.path.getsize(path)
for filename in os.listdir(path):
current_path = os.path.join(path, filename)
if os.path.isdir(current_path):
get_size(current_path)
else:
initial_size += os.path.getsize(current_path)
return initial_size def conversion_quota(quota_mb: str):
"""
换算用户磁盘配额, 把MB换算成bytes.
:param quota_mb:
:return: 满足isdigit返回quota_bytes, 不满足设置默认的配额大小
"""
if quota_mb.isdigit():
quota_mb = int(quota_mb)
quota_bytes = quota_mb * 1024 ** 2
# print('def conversion_quota ===> quota_bytes:', quota_bytes)
return quota_bytes
else:
default_quota_bytes = 50 * 1024 ** 2
return default_quota_bytes
common.py
5) log
- access.log
# encoding:utf-8 import os
import sys BASE_DIR = os.path.normpath(os.path.join(__file__, '..'))
sys.path.append(BASE_DIR) if __name__ == '__main__':
from core import management
management = management.ManagementTool()
management.execute()
ftp_server.py
实现支持多用户在线的FTP程序(C/S)的更多相关文章
- Python3学习之路~8.6 开发一个支持多用户在线的FTP程序-代码实现
作业: 开发一个支持多用户在线的FTP程序 要求: 用户加密认证 允许同时多用户登录 每个用户有自己的家目录 ,且只能访问自己的家目录 对用户进行磁盘配额,每个用户的可用空间不同 允许用户在ftp s ...
- (转)Python开发程序:支持多用户在线的FTP程序
原文链接:http://www.itnose.net/detail/6642756.html 作业:开发一个支持多用户在线的FTP程序 要求: 用户加密认证 允许同时多用户登录 每个用户有自己的家目录 ...
- 老男孩python作业7-开发一个支持多用户在线的FTP程序
作业6:开发一个支持多用户在线的FTP程序 要求: 用户加密认证 允许同时多用户登录 每个用户有自己的家目录 ,且只能访问自己的家目录 对用户进行磁盘配额,每个用户的可用空间不同 允许用户在ftp s ...
- 开发一个支持多用户在线的FTP程序
要求: 用户加密认证 允许同时多用户登录 每个用户有自己的家目录 ,且只能访问自己的家目录 对用户进行磁盘配额,每个用户的可用空间不同 允许用户在ftp server上随意切换目录 允许用户查看当前目 ...
- python 开发一个支持多用户在线的FTP
### 作者介绍:* author:lzl### 博客地址:* http://www.cnblogs.com/lianzhilei/p/5813986.html### 功能实现 作业:开发一个支持多用 ...
- python之FTP程序(支持多用户在线)
转发注明出处:http://www.cnblogs.com/0zcl/p/6259128.html 一.需求 1. 用户加密认证 (完成)2. 允许同时多用户登录 (完成)3. 每个用户有自己的家目录 ...
- 开发一个支持多用户同时在线的FTP程序
FTP 要求: .用户加密认证 .允许同时多用户登录 .每个用户有自己的家目录,且只能访问自己的家目录 .对用户进行磁盘配额,每个用户的可用空间不同 .允许用户在ftp server上随意切换目录 . ...
- socketserver+socket实现较为复杂的ftp,支持多用户在线
客户端(ftp_client.py) import socketserver,json,hashlib,os from pymongo import MongoClient ''' *****要点** ...
- Python开发程序:FTP程序
作业:开发一个支持多用户在线的FTP程序 要求: 用户加密认证 允许同时多用户登录 每个用户有自己的家目录 ,且只能访问自己的家目录 对用户进行磁盘配额,每个用户的可用空间不同 允许用户在ftp se ...
随机推荐
- VM虚拟机启动夜神模拟器卡99%解决办法
VM虚拟机启动夜神模拟器卡99%解决办法 本人出现的情况: 物理机装的是win7系统,安装了vmware14(安装过程未出现报错),在vmware14 上 win10系统(安装过程未出现报错),安装夜 ...
- indexeddb:浏览器中的数据库
随着浏览器功能的不断加强,越来越多的网站开始考虑将大量的数据存储在客户端.这样的考虑是为了直接从本地获取数据,减少从服务器获取数据耗费的网络资源. 原有的浏览器数据存储方案都不适合存储大量数据.Coo ...
- 10个比较流行的JavaScript面试题
1.如何理解 JS 中的this关键字? JS 初学者总是对this关键字感到困惑,因为与其他现代编程语言相比,JS 中的这this关键字有点棘手. “this” 一般是表示当前所在的对象,但是事情并 ...
- SQLMAP SSI注入错误解决
记一次SQL注入 目标地址:https://www.xxxx.com/ 之前补天提交过这个注入 后来貌似”修复了”(实际就是装了安全狗和过滤了一些关键字) 不过今天试了下 还是可以注入 可以看到已经 ...
- nginx::环境搭建
ubuntu18.04 环境 1.需要gcc 环境,如果没有gcc环境,则需要安装 apt install gcc .安装pcre依赖库 PCRE(Perl Compatible Regular Ex ...
- libevent::事件::定时器
#include <cstdio> #include <errno.h> #include <sys/types.h> #include <event.h&g ...
- python 可变数量参数 ( 多参数返回求 参数个数,最大值,最大值)
一. 自定义一串数字求 参数个数,最大值,最大值()---------方法一: def max(*a): m=a[0] p=a[0] n=0 for x in a: if x>m: m=x n+ ...
- 自定义的Spring Boot starter如何设置自动配置注解
本文首发于个人网站: 在Spring Boot实战之定制自己的starter一文最后提到,触发Spring Boot的配置过程有两种方法: spring.factories:由Spring Boot触 ...
- JavaScript 实用技巧
1数组中删除重复 let arr = [1,2,4,3,6,4] Array.from(new Set(arr)) // es6中 .from()[1,2,4,3,6] [...new Set(arr ...
- Mysql UTF-8mb4字符集的问题
官方Mysql手册链接 https://dev.mysql.com/doc/connectors/en/connector-j-reference-charsets.html Notes For Co ...