pyfileserv 0.2.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,23 @@
1
+ """
2
+ 带登录认证的安全文件服务器
3
+ """
4
+
5
+ __version__ = "0.2.2"
6
+
7
+ from .config import ServerConfig
8
+ from .auth import (
9
+ PasswordHasher,
10
+ UserStore,
11
+ SessionManager,
12
+ Authenticator,
13
+ )
14
+ from .server import run_server
15
+
16
+ __all__ = [
17
+ 'ServerConfig',
18
+ 'PasswordHasher',
19
+ 'UserStore',
20
+ 'SessionManager',
21
+ 'Authenticator',
22
+ 'run_server',
23
+ ]
@@ -0,0 +1,5 @@
1
+ """命令行入口点"""
2
+ from .cli import main
3
+
4
+ if __name__ == "__main__":
5
+ main()
file_server/auth.py ADDED
@@ -0,0 +1,175 @@
1
+ """认证模块 - 密码哈希、用户存储、会话管理"""
2
+ import json
3
+ import logging
4
+ import secrets
5
+ import time
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Dict, Optional, Tuple
9
+
10
+ import bcrypt
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ @dataclass
16
+ class Session:
17
+ """会话数据"""
18
+ session_id: str
19
+ username: str
20
+ expires: float
21
+ created_at: float
22
+
23
+
24
+ class PasswordHasher:
25
+ """密码哈希工具"""
26
+
27
+ def __init__(self, rounds: int = 12):
28
+ self.rounds = rounds
29
+
30
+ def hash_password(self, password: str) -> str:
31
+ """使用 bcrypt 加密密码"""
32
+ salt = bcrypt.gensalt(rounds=self.rounds)
33
+ hashed = bcrypt.hashpw(password.encode('utf-8'), salt)
34
+ return hashed.decode('utf-8')
35
+
36
+ def verify_password(self, password: str, hashed_password: str) -> bool:
37
+ """验证密码"""
38
+ try:
39
+ return bcrypt.checkpw(
40
+ password.encode('utf-8'),
41
+ hashed_password.encode('utf-8')
42
+ )
43
+ except Exception:
44
+ return False
45
+
46
+
47
+ class UserStore:
48
+ """用户存储 - 负责用户数据的读写"""
49
+
50
+ def __init__(self, users_file_path: Path):
51
+ self.users_file_path = Path(users_file_path)
52
+
53
+ def load_users(self) -> Dict[str, dict]:
54
+ """加载用户数据"""
55
+ if not self.users_file_path.exists():
56
+ return {}
57
+ with open(self.users_file_path, 'r', encoding='utf-8') as f:
58
+ return json.load(f)
59
+
60
+ def save_users(self, users: Dict[str, dict]) -> None:
61
+ """保存用户数据"""
62
+ self.users_file_path.parent.mkdir(parents=True, exist_ok=True)
63
+ with open(self.users_file_path, 'w', encoding='utf-8') as f:
64
+ json.dump(users, f, indent=2, ensure_ascii=False)
65
+
66
+ def user_exists(self, username: str) -> bool:
67
+ """检查用户是否存在"""
68
+ return username in self.load_users()
69
+
70
+ def get_user(self, username: str) -> Optional[dict]:
71
+ """获取用户数据"""
72
+ users = self.load_users()
73
+ return users.get(username)
74
+
75
+ def add_user(self, username: str, password_hash: str) -> None:
76
+ """添加用户"""
77
+ users = self.load_users()
78
+ users[username] = {
79
+ "password": password_hash,
80
+ "created_at": str(time.time())
81
+ }
82
+ self.save_users(users)
83
+
84
+ def update_password(self, username: str, password_hash: str) -> None:
85
+ """更新密码"""
86
+ users = self.load_users()
87
+ if username in users:
88
+ users[username]["password"] = password_hash
89
+ self.save_users(users)
90
+
91
+ def delete_user(self, username: str) -> bool:
92
+ """删除用户"""
93
+ users = self.load_users()
94
+ if username in users:
95
+ del users[username]
96
+ self.save_users(users)
97
+ return True
98
+ return False
99
+
100
+
101
+ class SessionManager:
102
+ """会话管理器"""
103
+
104
+ def __init__(self, timeout_seconds: int = 3600):
105
+ self.timeout = timeout_seconds
106
+ self._sessions: Dict[str, Session] = {}
107
+
108
+ def create_session(self, username: str) -> str:
109
+ """创建新会话,返回 session_id"""
110
+ session_id = secrets.token_hex(32)
111
+ now = time.time()
112
+ self._sessions[session_id] = Session(
113
+ session_id=session_id,
114
+ username=username,
115
+ expires=now + self.timeout,
116
+ created_at=now
117
+ )
118
+ return session_id
119
+
120
+ def validate_session(self, session_id: str) -> Tuple[bool, Optional[str]]:
121
+ """验证会话,返回 (是否有效, 用户名)"""
122
+ if not session_id or session_id not in self._sessions:
123
+ return False, None
124
+
125
+ session = self._sessions[session_id]
126
+ if time.time() > session.expires:
127
+ del self._sessions[session_id]
128
+ return False, None
129
+
130
+ # 刷新会话过期时间
131
+ session.expires = time.time() + self.timeout
132
+ return True, session.username
133
+
134
+ def get_username(self, session_id: str) -> Optional[str]:
135
+ """获取会话对应的用户名"""
136
+ valid, username = self.validate_session(session_id)
137
+ return username if valid else None
138
+
139
+ def destroy_session(self, session_id: str) -> Optional[str]:
140
+ """销毁会话,返回被销毁会话的用户名"""
141
+ if session_id in self._sessions:
142
+ username = self._sessions[session_id].username
143
+ del self._sessions[session_id]
144
+ return username
145
+ return None
146
+
147
+
148
+ class Authenticator:
149
+ """认证器 - 整合密码验证和会话管理"""
150
+
151
+ def __init__(self, user_store: UserStore, hasher: PasswordHasher,
152
+ session_manager: SessionManager):
153
+ self.user_store = user_store
154
+ self.hasher = hasher
155
+ self.session_manager = session_manager
156
+
157
+ def authenticate(self, username: str, password: str) -> Tuple[bool, Optional[str]]:
158
+ """
159
+ 验证用户凭据
160
+
161
+ 返回: (是否成功, 失败原因或session_id)
162
+ """
163
+ user_data = self.user_store.get_user(username)
164
+ if not user_data:
165
+ logger.warning(f"登录失败 - 用户不存在: {username}")
166
+ return False, "用户不存在"
167
+
168
+ stored_hash = user_data['password']
169
+ if not self.hasher.verify_password(password, stored_hash):
170
+ logger.warning(f"登录失败 - 密码错误: {username}")
171
+ return False, "密码错误"
172
+
173
+ session_id = self.session_manager.create_session(username)
174
+ logger.info(f"登录成功 - 用户: {username}")
175
+ return True, session_id
file_server/cli.py ADDED
@@ -0,0 +1,186 @@
1
+ """命令行工具 - 服务器启动和用户管理"""
2
+ import argparse
3
+ import getpass
4
+ import logging
5
+ from pathlib import Path
6
+
7
+ from .config import ServerConfig
8
+ from .server import run_server
9
+ from .auth import PasswordHasher, UserStore
10
+
11
+
12
+ def main() -> None:
13
+ """主入口"""
14
+ logging.basicConfig(
15
+ level=logging.INFO,
16
+ format='%(asctime)s - %(levelname)s - %(message)s',
17
+ datefmt='%Y-%m-%d %H:%M:%S'
18
+ )
19
+
20
+ parser = argparse.ArgumentParser(
21
+ description='带登录认证的文件服务器',
22
+ formatter_class=argparse.RawDescriptionHelpFormatter,
23
+ epilog="""
24
+ 示例:
25
+ python -m file_server # 启动服务器
26
+ python -m file_server -p 9000 # 指定端口
27
+ python -m file_server user create # 创建用户
28
+ python -m file_server user list # 列出用户
29
+ """
30
+ )
31
+
32
+ subparsers = parser.add_subparsers(dest='command', help='子命令')
33
+
34
+ # 服务器命令 (默认)
35
+ parser.add_argument('-p', '--port', type=int, default=8000,
36
+ help='端口号 (默认: 8000)')
37
+ parser.add_argument('-d', '--directory', default='.',
38
+ help='文件服务目录')
39
+ parser.add_argument('-c', '--config-dir',
40
+ help='配置文件目录')
41
+ parser.add_argument('-s', '--static-dir',
42
+ help='静态文件目录')
43
+
44
+ # 用户管理命令
45
+ user_parser = subparsers.add_parser('user', help='用户管理')
46
+ user_subparsers = user_parser.add_subparsers(dest='user_command')
47
+
48
+ # user create
49
+ create_parser = user_subparsers.add_parser('create', help='创建用户')
50
+ create_parser.add_argument('-u', '--username', help='用户名')
51
+
52
+ # user list
53
+ user_subparsers.add_parser('list', help='列出用户')
54
+
55
+ # user delete
56
+ delete_parser = user_subparsers.add_parser('delete', help='删除用户')
57
+ delete_parser.add_argument('-u', '--username', help='用户名')
58
+
59
+ # user passwd
60
+ passwd_parser = user_subparsers.add_parser('passwd', help='修改密码')
61
+ passwd_parser.add_argument('-u', '--username', help='用户名')
62
+
63
+ args = parser.parse_args()
64
+
65
+ if args.command == 'user':
66
+ _handle_user_command(args)
67
+ elif args.command is None:
68
+ config = ServerConfig.from_args(
69
+ port=args.port,
70
+ directory=args.directory,
71
+ config_dir=args.config_dir,
72
+ static_dir=args.static_dir
73
+ )
74
+ run_server(config)
75
+ else:
76
+ parser.print_help()
77
+
78
+
79
+ def _handle_user_command(args) -> None:
80
+ """处理用户管理命令"""
81
+ users_file = Path("users.json")
82
+ user_store = UserStore(users_file)
83
+ hasher = PasswordHasher()
84
+
85
+ if args.user_command == 'create':
86
+ _create_user(user_store, hasher, args.username)
87
+ elif args.user_command == 'list':
88
+ _list_users(user_store)
89
+ elif args.user_command == 'delete':
90
+ _delete_user(user_store, args.username)
91
+ elif args.user_command == 'passwd':
92
+ _change_password(user_store, hasher, args.username)
93
+ else:
94
+ print("未知用户命令,使用 --help 查看帮助")
95
+
96
+
97
+ def _create_user(user_store: UserStore, hasher: PasswordHasher,
98
+ username: str = None) -> None:
99
+ """创建用户"""
100
+ if not username:
101
+ username = input("用户名: ").strip()
102
+
103
+ if not username:
104
+ print("错误: 用户名不能为空")
105
+ return
106
+
107
+ if user_store.user_exists(username):
108
+ print(f"错误: 用户 '{username}' 已存在")
109
+ return
110
+
111
+ password = getpass.getpass("密码: ")
112
+ if len(password) < 6:
113
+ print("错误: 密码长度至少为6位")
114
+ return
115
+
116
+ confirm = getpass.getpass("确认密码: ")
117
+ if password != confirm:
118
+ print("错误: 两次密码不一致")
119
+ return
120
+
121
+ password_hash = hasher.hash_password(password)
122
+ user_store.add_user(username, password_hash)
123
+ print(f"用户 '{username}' 创建成功")
124
+
125
+
126
+ def _list_users(user_store: UserStore) -> None:
127
+ """列出用户"""
128
+ users = user_store.load_users()
129
+ if not users:
130
+ print("暂无用户")
131
+ return
132
+
133
+ print("\n用户列表:")
134
+ for username in users:
135
+ print(f" - {username}")
136
+
137
+
138
+ def _delete_user(user_store: UserStore, username: str = None) -> None:
139
+ """删除用户"""
140
+ if not username:
141
+ username = input("要删除的用户名: ").strip()
142
+
143
+ if not user_store.user_exists(username):
144
+ print(f"错误: 用户 '{username}' 不存在")
145
+ return
146
+
147
+ confirm = input(f"确认删除用户 '{username}'? (yes/no): ").strip().lower()
148
+ if confirm == 'yes':
149
+ user_store.delete_user(username)
150
+ print(f"用户 '{username}' 已删除")
151
+ else:
152
+ print("已取消")
153
+
154
+
155
+ def _change_password(user_store: UserStore, hasher: PasswordHasher,
156
+ username: str = None) -> None:
157
+ """修改密码"""
158
+ if not username:
159
+ username = input("用户名: ").strip()
160
+
161
+ user_data = user_store.get_user(username)
162
+ if not user_data:
163
+ print(f"错误: 用户 '{username}' 不存在")
164
+ return
165
+
166
+ old_password = getpass.getpass("原密码: ")
167
+ if not hasher.verify_password(old_password, user_data['password']):
168
+ print("错误: 原密码不正确")
169
+ return
170
+
171
+ new_password = getpass.getpass("新密码: ")
172
+ if len(new_password) < 6:
173
+ print("错误: 密码长度至少为6位")
174
+ return
175
+
176
+ confirm = getpass.getpass("确认新密码: ")
177
+ if new_password != confirm:
178
+ print("错误: 两次密码不一致")
179
+ return
180
+
181
+ user_store.update_password(username, hasher.hash_password(new_password))
182
+ print("密码修改成功")
183
+
184
+
185
+ if __name__ == "__main__":
186
+ main()
file_server/config.py ADDED
@@ -0,0 +1,44 @@
1
+ """配置模块"""
2
+ from dataclasses import dataclass
3
+ from pathlib import Path
4
+ from typing import Optional
5
+
6
+
7
+ @dataclass
8
+ class ServerConfig:
9
+ """服务器配置"""
10
+ # 服务配置
11
+ port: int = 8000
12
+ host: str = ""
13
+
14
+ # 路径配置
15
+ config_dir: Optional[Path] = None
16
+ static_dir: Optional[Path] = None
17
+ serve_dir: Optional[Path] = None
18
+
19
+ # 用户文件
20
+ users_file: str = "users.json"
21
+
22
+ # 会话配置
23
+ session_timeout: int = 3600 # 秒 (1小时)
24
+
25
+ # 密码哈希配置
26
+ bcrypt_rounds: int = 12
27
+
28
+ def get_users_file_path(self) -> Path:
29
+ """获取用户文件完整路径"""
30
+ if self.config_dir:
31
+ return self.config_dir / self.users_file
32
+ return Path(self.users_file)
33
+
34
+ @classmethod
35
+ def from_args(cls, port: int = 8000, directory: str = ".",
36
+ config_dir: Optional[str] = None,
37
+ static_dir: Optional[str] = None) -> "ServerConfig":
38
+ """从命令行参数创建配置"""
39
+ return cls(
40
+ port=port,
41
+ config_dir=Path(config_dir) if config_dir else None,
42
+ static_dir=Path(static_dir) if static_dir else None,
43
+ serve_dir=Path(directory) if directory else None,
44
+ )
@@ -0,0 +1,230 @@
1
+ """HTTP 请求处理器"""
2
+ import http.server
3
+ import logging
4
+ import os
5
+ import urllib.parse
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ from .auth import Authenticator, SessionManager
10
+ from .templates import render_login_page, render_file_list_page
11
+ from .config import ServerConfig
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class SecureHTTPRequestHandler(http.server.SimpleHTTPRequestHandler):
17
+ """安全文件服务器请求处理器"""
18
+
19
+ # 类级别注入依赖
20
+ authenticator: Optional[Authenticator] = None
21
+ session_manager: Optional[SessionManager] = None
22
+ config: Optional[ServerConfig] = None
23
+
24
+ def log_request_info(self, method: str) -> None:
25
+ """记录请求信息"""
26
+ client_ip = self.client_address[0]
27
+ client_port = self.client_address[1]
28
+ logger.info(f"[{method}] {client_ip}:{client_port} - {self.path}")
29
+
30
+ def get_current_username(self) -> Optional[str]:
31
+ """获取当前登录用户名"""
32
+ session_id = self._get_session_id()
33
+ if session_id and self.session_manager:
34
+ return self.session_manager.get_username(session_id)
35
+ return None
36
+
37
+ def is_authenticated(self) -> bool:
38
+ """检查是否已认证"""
39
+ session_id = self._get_session_id()
40
+ if session_id and self.session_manager:
41
+ valid, _ = self.session_manager.validate_session(session_id)
42
+ return valid
43
+ return False
44
+
45
+ def _get_session_id(self) -> Optional[str]:
46
+ """从 Cookie 获取 session_id"""
47
+ if 'Cookie' in self.headers:
48
+ cookies = self.headers['Cookie'].split(';')
49
+ for cookie in cookies:
50
+ if cookie.strip().startswith('session_id='):
51
+ return cookie.strip()[11:]
52
+ return None
53
+
54
+ def do_GET(self) -> None:
55
+ """处理 GET 请求"""
56
+ self.log_request_info('GET')
57
+
58
+ # 未认证用户只能访问登录页
59
+ if not self.is_authenticated():
60
+ if self.path in ('/login', '/do_login'):
61
+ self._handle_login_page()
62
+ else:
63
+ self._redirect_to_login()
64
+ return
65
+
66
+ # 已认证用户的路由
67
+ if self.path == '/logout':
68
+ self._handle_logout()
69
+ elif self.path.startswith('/download/'):
70
+ self._handle_download()
71
+ else:
72
+ super().do_GET()
73
+
74
+ def do_POST(self) -> None:
75
+ """处理 POST 请求"""
76
+ self.log_request_info('POST')
77
+
78
+ if self.path == '/do_login':
79
+ self._handle_login()
80
+ elif self.path == '/do_logout':
81
+ self._handle_logout()
82
+ else:
83
+ self.send_error(404, "Not Found")
84
+
85
+ def _redirect_to_login(self) -> None:
86
+ """重定向到登录页"""
87
+ logger.info(f"未认证用户访问 {self.path}, 重定向到登录页")
88
+ self.send_response(302)
89
+ self.send_header('Location', '/login')
90
+ self.end_headers()
91
+
92
+ def _handle_login_page(self, error_msg: str = "") -> None:
93
+ """显示登录页面"""
94
+ session_timeout = self.config.session_timeout if self.config else 3600
95
+ html = render_login_page(error_msg, session_timeout)
96
+ self._send_html_response(html)
97
+
98
+ def _handle_login(self) -> None:
99
+ """处理登录请求"""
100
+ # 解析表单数据
101
+ content_length = int(self.headers.get('Content-Length', 0))
102
+ post_data = self.rfile.read(content_length).decode('utf-8')
103
+ params = urllib.parse.parse_qs(post_data)
104
+
105
+ username = params.get('username', [''])[0].strip()
106
+ password = params.get('password', [''])[0]
107
+
108
+ logger.info(f"登录尝试 - 用户名: {username}")
109
+
110
+ # 认证
111
+ if self.authenticator:
112
+ success, result = self.authenticator.authenticate(username, password)
113
+ if success:
114
+ # 登录成功,result 是 session_id
115
+ self.send_response(302)
116
+ self.send_header('Location', '/')
117
+ self.send_header('Set-Cookie',
118
+ f'session_id={result}; Path=/; HttpOnly; SameSite=Strict')
119
+ self.end_headers()
120
+ return
121
+
122
+ # 登录失败
123
+ self._handle_login_page("用户名或密码错误")
124
+
125
+ def _handle_logout(self) -> None:
126
+ """处理登出"""
127
+ session_id = self._get_session_id()
128
+ username = None
129
+ if session_id and self.session_manager:
130
+ username = self.session_manager.destroy_session(session_id)
131
+
132
+ logger.info(f"用户登出: {username or '未知用户'}")
133
+
134
+ self.send_response(302)
135
+ self.send_header('Location', '/login')
136
+ self.send_header('Set-Cookie',
137
+ 'session_id=; Max-Age=0; Path=/; HttpOnly; SameSite=Strict')
138
+ self.end_headers()
139
+
140
+ def _handle_download(self) -> None:
141
+ """处理文件下载"""
142
+ username = self.get_current_username() or 'Unknown'
143
+
144
+ if not self.is_authenticated():
145
+ logger.warning("未认证用户尝试下载文件")
146
+ self.send_error(403, "Forbidden")
147
+ return
148
+
149
+ file_name = self.path[len('/download/'):].lstrip('/')
150
+ file_name = urllib.parse.unquote(file_name)
151
+ file_path = Path(self.directory) / file_name
152
+
153
+ if file_path.exists() and file_path.is_file():
154
+ try:
155
+ file_size = file_path.stat().st_size
156
+ logger.info(f"文件下载 - 用户: {username}, 文件: {file_name}")
157
+
158
+ # RFC 5987: 支持 UTF-8 编码的文件名
159
+ encoded_filename = urllib.parse.quote(file_path.name)
160
+ content_disposition = f"attachment; filename*=UTF-8''{encoded_filename}"
161
+
162
+ self.send_response(200)
163
+ self.send_header('Content-Type', 'application/octet-stream')
164
+ self.send_header('Content-Disposition', content_disposition)
165
+ self.send_header('Content-Length', str(file_size))
166
+ self.end_headers()
167
+
168
+ with open(file_path, 'rb') as f:
169
+ self.copyfile(f, self.wfile)
170
+ logger.info(f"文件下载完成 - {file_name}")
171
+
172
+ except Exception as e:
173
+ logger.error(f"下载文件出错 - 文件: {file_name}, 错误: {e}")
174
+ self.send_error(500, "Download error")
175
+ else:
176
+ logger.warning(f"文件不存在 - {file_path}")
177
+ self.send_error(404, "File not found")
178
+
179
+ def list_directory(self, path: str):
180
+ """重写目录列表方法"""
181
+ username = self.get_current_username() or 'Unknown'
182
+
183
+ if not self.is_authenticated():
184
+ logger.warning(f"未认证用户尝试访问目录列表: {path}")
185
+ self.send_error(403, "Forbidden")
186
+ return None
187
+
188
+ logger.info(f"目录列表访问 - 用户: {username}, 路径: {path}")
189
+
190
+ try:
191
+ entries = os.listdir(path)
192
+ except OSError as e:
193
+ logger.error(f"无法列出目录: {path}, 错误: {e}")
194
+ self.send_error(404, "No permission to list directory")
195
+ return None
196
+
197
+ # 排除隐藏文件和目录
198
+ files = [f for f in entries if not f.startswith('.')
199
+ and os.path.isfile(os.path.join(path, f))]
200
+
201
+ # 分离HTML文件和其他文件
202
+ html_files = []
203
+ other_files = []
204
+
205
+ for f in files:
206
+ file_path = os.path.join(path, f)
207
+ file_size = os.path.getsize(file_path)
208
+ if f.lower().endswith(('.html', '.htm')):
209
+ html_files.append((f, file_size))
210
+ else:
211
+ other_files.append((f, file_size))
212
+
213
+ # 渲染页面
214
+ html = render_file_list_page(path, username, html_files, other_files)
215
+
216
+ self.send_response(200)
217
+ self.send_header("Content-type", "text/html; charset=utf-8")
218
+ self.send_header("Content-Length", str(len(html.encode('utf-8'))))
219
+ self.end_headers()
220
+ self.wfile.write(html.encode('utf-8'))
221
+ return None
222
+
223
+ def _send_html_response(self, html: str) -> None:
224
+ """发送 HTML 响应"""
225
+ content = html.encode('utf-8')
226
+ self.send_response(200)
227
+ self.send_header('Content-type', 'text/html; charset=utf-8')
228
+ self.send_header('Content-Length', str(len(content)))
229
+ self.end_headers()
230
+ self.wfile.write(content)
file_server/server.py ADDED
@@ -0,0 +1,87 @@
1
+ """文件服务器主模块"""
2
+ import logging
3
+ import os
4
+ import platform
5
+ import socket
6
+ import socketserver
7
+ from pathlib import Path
8
+
9
+ from .auth import Authenticator, PasswordHasher, SessionManager, UserStore
10
+ from .config import ServerConfig
11
+ from .handlers import SecureHTTPRequestHandler
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ def run_server(config: ServerConfig) -> None:
17
+ """启动文件服务器"""
18
+ logger.info("=" * 60)
19
+ logger.info(f"启动服务器: port={config.port}")
20
+
21
+ # 初始化组件
22
+ user_store = UserStore(config.get_users_file_path())
23
+ hasher = PasswordHasher(rounds=config.bcrypt_rounds)
24
+ session_manager = SessionManager(timeout_seconds=config.session_timeout)
25
+ authenticator = Authenticator(user_store, hasher, session_manager)
26
+
27
+ # 检查用户文件
28
+ if not config.get_users_file_path().exists():
29
+ logger.warning(f"用户文件不存在: {config.get_users_file_path()}")
30
+ logger.info("请先运行 'python -m file_server user create' 创建用户")
31
+ return
32
+
33
+ # 确定服务目录
34
+ serve_dir = config.static_dir or config.serve_dir or Path.cwd()
35
+ if not serve_dir.exists():
36
+ logger.error(f"文件服务目录不存在: {serve_dir}")
37
+ return
38
+
39
+ # 配置请求处理器
40
+ SecureHTTPRequestHandler.authenticator = authenticator
41
+ SecureHTTPRequestHandler.session_manager = session_manager
42
+ SecureHTTPRequestHandler.config = config
43
+ SecureHTTPRequestHandler.directory = str(serve_dir)
44
+
45
+ # 打印启动信息
46
+ _print_startup_info(config, serve_dir)
47
+
48
+ # 切换工作目录
49
+ os.chdir(serve_dir)
50
+
51
+ # 启动服务器
52
+ with socketserver.TCPServer((config.host, config.port), SecureHTTPRequestHandler) as httpd:
53
+ try:
54
+ httpd.serve_forever()
55
+ except KeyboardInterrupt:
56
+ logger.info("服务器已停止")
57
+ finally:
58
+ httpd.server_close()
59
+
60
+
61
+ def _print_startup_info(config: ServerConfig, serve_dir: Path) -> None:
62
+ """打印启动信息"""
63
+ logger.info(f"文件服务目录: {serve_dir.absolute()}")
64
+ logger.info(f"配置文件目录: {config.config_dir or '当前目录'}")
65
+ logger.info(f"认证文件: {config.get_users_file_path()}")
66
+ logger.info(f"访问地址: http://localhost:{config.port}")
67
+
68
+ # 获取局域网 IP
69
+ try:
70
+ lan_ip = _get_lan_ip()
71
+ logger.info(f"局域网访问: http://{lan_ip}:{config.port}")
72
+ except Exception as e:
73
+ logger.warning(f"获取局域网IP失败: {e}")
74
+
75
+ logger.info("按 Ctrl+C 停止服务器")
76
+ logger.info("=" * 60)
77
+
78
+
79
+ def _get_lan_ip() -> str:
80
+ """获取局域网 IP"""
81
+ if platform.system() == "Windows":
82
+ hostname = socket.gethostname()
83
+ return socket.gethostbyname(hostname)
84
+ else:
85
+ with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
86
+ s.connect(("8.8.8.8", 80))
87
+ return s.getsockname()[0]
@@ -0,0 +1,359 @@
1
+ """HTML 模板"""
2
+ from datetime import datetime
3
+ from typing import List, Tuple
4
+ import urllib.parse
5
+
6
+
7
+ # CSS 样式常量
8
+ CSS_COMMON = """
9
+ * {
10
+ margin: 0;
11
+ padding: 0;
12
+ box-sizing: border-box;
13
+ }
14
+ """
15
+
16
+ CSS_GRADIENT_PRIMARY = "linear-gradient(135deg, #667eea 0%, #764ba2 100%)"
17
+
18
+
19
+ def render_login_page(error_msg: str = "", session_timeout: int = 3600) -> str:
20
+ """渲染登录页面"""
21
+ timeout_text = f"{session_timeout // 3600}小时" if session_timeout >= 3600 else f"{session_timeout // 60}分钟"
22
+ error_html = f'<div class="error">{error_msg}</div>' if error_msg else ""
23
+
24
+ return f"""
25
+ <!DOCTYPE html>
26
+ <html>
27
+ <head>
28
+ <meta charset="UTF-8">
29
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
30
+ <title>登录 - 文件服务器</title>
31
+ <style>
32
+ {CSS_COMMON}
33
+ body {{
34
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
35
+ background: {CSS_GRADIENT_PRIMARY};
36
+ min-height: 100vh;
37
+ display: flex;
38
+ justify-content: center;
39
+ align-items: center;
40
+ padding: 20px;
41
+ }}
42
+ .login-container {{
43
+ background: white;
44
+ border-radius: 15px;
45
+ box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
46
+ padding: 40px;
47
+ width: 100%;
48
+ max-width: 400px;
49
+ }}
50
+ .login-header {{
51
+ text-align: center;
52
+ margin-bottom: 30px;
53
+ }}
54
+ .login-header h1 {{
55
+ color: #333;
56
+ font-size: 28px;
57
+ margin-bottom: 10px;
58
+ }}
59
+ .login-header p {{
60
+ color: #666;
61
+ font-size: 14px;
62
+ }}
63
+ .form-group {{
64
+ margin-bottom: 20px;
65
+ }}
66
+ .form-group label {{
67
+ display: block;
68
+ margin-bottom: 8px;
69
+ color: #333;
70
+ font-weight: 600;
71
+ font-size: 14px;
72
+ }}
73
+ .form-group input {{
74
+ width: 100%;
75
+ padding: 12px 15px;
76
+ border: 2px solid #e0e0e0;
77
+ border-radius: 8px;
78
+ font-size: 14px;
79
+ transition: border-color 0.3s;
80
+ }}
81
+ .form-group input:focus {{
82
+ outline: none;
83
+ border-color: #667eea;
84
+ }}
85
+ .error {{
86
+ background: #fff3f3;
87
+ border: 1px solid #ff6b6b;
88
+ color: #d63031;
89
+ padding: 12px;
90
+ border-radius: 6px;
91
+ margin-bottom: 20px;
92
+ font-size: 14px;
93
+ }}
94
+ .btn-login {{
95
+ width: 100%;
96
+ padding: 14px;
97
+ background: {CSS_GRADIENT_PRIMARY};
98
+ color: white;
99
+ border: none;
100
+ border-radius: 8px;
101
+ font-size: 16px;
102
+ font-weight: 600;
103
+ cursor: pointer;
104
+ transition: transform 0.2s, box-shadow 0.2s;
105
+ }}
106
+ .btn-login:hover {{
107
+ transform: translateY(-2px);
108
+ box-shadow: 0 5px 20px rgba(102, 126, 234, 0.4);
109
+ }}
110
+ .footer {{
111
+ text-align: center;
112
+ margin-top: 20px;
113
+ color: #999;
114
+ font-size: 12px;
115
+ }}
116
+ </style>
117
+ </head>
118
+ <body>
119
+ <div class="login-container">
120
+ <div class="login-header">
121
+ <h1>文件服务器</h1>
122
+ <p>请输入用户名和密码登录</p>
123
+ </div>
124
+ {error_html}
125
+ <form method="POST" action="/do_login">
126
+ <div class="form-group">
127
+ <label for="username">用户名</label>
128
+ <input type="text" id="username" name="username" required autofocus>
129
+ </div>
130
+ <div class="form-group">
131
+ <label for="password">密码</label>
132
+ <input type="password" id="password" name="password" required>
133
+ </div>
134
+ <button type="submit" class="btn-login">登录</button>
135
+ </form>
136
+ <div class="footer">
137
+ <p>会话超时: {timeout_text}</p>
138
+ </div>
139
+ </div>
140
+ </body>
141
+ </html>
142
+ """
143
+
144
+
145
+ def format_size(size: int) -> str:
146
+ """格式化文件大小"""
147
+ if size < 1024:
148
+ return f"{size} B"
149
+ elif size < 1024 * 1024:
150
+ return f"{size / 1024:.1f} KB"
151
+ elif size < 1024 * 1024 * 1024:
152
+ return f"{size / (1024 * 1024):.1f} MB"
153
+ else:
154
+ return f"{size / (1024 * 1024 * 1024):.1f} GB"
155
+
156
+
157
+ def render_file_list_page(
158
+ path: str,
159
+ username: str,
160
+ html_files: List[Tuple[str, int]],
161
+ other_files: List[Tuple[str, int]]
162
+ ) -> str:
163
+ """渲染文件列表页面
164
+
165
+ Args:
166
+ path: 当前路径
167
+ username: 当前用户名
168
+ html_files: HTML文件列表 [(文件名, 大小), ...]
169
+ other_files: 其他文件列表 [(文件名, 大小), ...]
170
+ """
171
+ # 生成 HTML 文件列表
172
+ html_items = ""
173
+ for filename, size in html_files:
174
+ html_items += f"""
175
+ <li class="file-item html-file">
176
+ <a href="{urllib.parse.quote(filename)}" class="file-link">{filename}</a>
177
+ <span class="file-info">{format_size(size)}</span>
178
+ </li>
179
+ """
180
+ if not html_files:
181
+ html_items = '<li class="file-item">暂无HTML文件</li>'
182
+
183
+ # 生成其他文件列表
184
+ other_items = ""
185
+ for filename, size in other_files:
186
+ other_items += f"""
187
+ <li class="file-item download-file">
188
+ <a href="/download/{urllib.parse.quote(filename)}" class="file-link">{filename}</a>
189
+ <span class="file-info">{format_size(size)} | 点击下载</span>
190
+ </li>
191
+ """
192
+ if not other_files:
193
+ other_items = '<li class="file-item">暂无其他文件</li>'
194
+
195
+ return f"""
196
+ <!DOCTYPE html>
197
+ <html>
198
+ <head>
199
+ <meta charset="UTF-8">
200
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
201
+ <title>文件列表 - {path}</title>
202
+ <style>
203
+ {CSS_COMMON}
204
+ body {{
205
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
206
+ background: #f5f6fa;
207
+ padding: 20px;
208
+ }}
209
+ .header {{
210
+ background: {CSS_GRADIENT_PRIMARY};
211
+ color: white;
212
+ padding: 20px 30px;
213
+ border-radius: 10px;
214
+ margin-bottom: 30px;
215
+ box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
216
+ }}
217
+ .header h1 {{
218
+ font-size: 28px;
219
+ margin-bottom: 10px;
220
+ }}
221
+ .header .user-info {{
222
+ display: flex;
223
+ justify-content: space-between;
224
+ align-items: center;
225
+ font-size: 14px;
226
+ opacity: 0.9;
227
+ }}
228
+ .header .logout-btn {{
229
+ background: rgba(255, 255, 255, 0.2);
230
+ border: 1px solid rgba(255, 255, 255, 0.3);
231
+ color: white;
232
+ padding: 8px 20px;
233
+ border-radius: 20px;
234
+ text-decoration: none;
235
+ transition: all 0.3s;
236
+ }}
237
+ .header .logout-btn:hover {{
238
+ background: rgba(255, 255, 255, 0.3);
239
+ transform: translateY(-2px);
240
+ }}
241
+ .container {{
242
+ max-width: 1200px;
243
+ margin: 0 auto;
244
+ background: white;
245
+ border-radius: 10px;
246
+ padding: 30px;
247
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
248
+ }}
249
+ .section {{
250
+ margin-bottom: 30px;
251
+ }}
252
+ .section-title {{
253
+ font-size: 20px;
254
+ color: #333;
255
+ margin-bottom: 15px;
256
+ padding-bottom: 10px;
257
+ border-bottom: 2px solid #667eea;
258
+ }}
259
+ .file-list {{
260
+ list-style: none;
261
+ }}
262
+ .file-item {{
263
+ margin: 10px 0;
264
+ padding: 15px;
265
+ border: 1px solid #e0e0e0;
266
+ border-radius: 8px;
267
+ background: #f8f9fa;
268
+ transition: all 0.3s;
269
+ display: flex;
270
+ justify-content: space-between;
271
+ align-items: center;
272
+ }}
273
+ .file-item:hover {{
274
+ background: #e9ecef;
275
+ border-color: #667eea;
276
+ transform: translateX(5px);
277
+ }}
278
+ .file-item.html-file {{
279
+ background: #d4edda;
280
+ border-color: #c3e6cb;
281
+ }}
282
+ .file-item.html-file:hover {{
283
+ background: #c3e6cb;
284
+ }}
285
+ .file-item.download-file {{
286
+ background: #d1ecf1;
287
+ border-color: #bee5eb;
288
+ }}
289
+ .file-item.download-file:hover {{
290
+ background: #bee5eb;
291
+ }}
292
+ .file-link {{
293
+ text-decoration: none;
294
+ color: #667eea;
295
+ font-weight: 600;
296
+ font-size: 16px;
297
+ flex: 1;
298
+ }}
299
+ .file-link:hover {{
300
+ color: #4834d4;
301
+ }}
302
+ .file-info {{
303
+ color: #666;
304
+ font-size: 13px;
305
+ margin-left: 15px;
306
+ white-space: nowrap;
307
+ }}
308
+ .path-info {{
309
+ color: #666;
310
+ font-size: 14px;
311
+ margin-top: 20px;
312
+ padding: 10px;
313
+ background: #f8f9fa;
314
+ border-radius: 5px;
315
+ }}
316
+ @media (max-width: 768px) {{
317
+ .file-item {{
318
+ flex-direction: column;
319
+ align-items: flex-start;
320
+ }}
321
+ .file-info {{
322
+ margin-left: 0;
323
+ margin-top: 8px;
324
+ }}
325
+ }}
326
+ </style>
327
+ </head>
328
+ <body>
329
+ <div class="header">
330
+ <h1>文件列表</h1>
331
+ <div class="user-info">
332
+ <span>当前用户: {username}</span>
333
+ <a href="/logout" class="logout-btn">退出登录</a>
334
+ </div>
335
+ </div>
336
+
337
+ <div class="container">
338
+ <div class="section">
339
+ <div class="section-title">HTML文件</div>
340
+ <ul class="file-list">
341
+ {html_items}
342
+ </ul>
343
+ </div>
344
+
345
+ <div class="section">
346
+ <div class="section-title">其他文件 (点击下载)</div>
347
+ <ul class="file-list">
348
+ {other_items}
349
+ </ul>
350
+ </div>
351
+
352
+ <div class="path-info">
353
+ <strong>当前路径:</strong> {path}<br>
354
+ <strong>服务器时间:</strong> {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
355
+ </div>
356
+ </div>
357
+ </body>
358
+ </html>
359
+ """
@@ -0,0 +1,178 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyfileserv
3
+ Version: 0.2.2
4
+ Summary: 带登录认证的安全文件服务器
5
+ Project-URL: Homepage, https://github.com/yucheng/file-server
6
+ Project-URL: Repository, https://github.com/yucheng/file-server
7
+ Author-email: yucheng <yucheng@example.com>
8
+ License-Expression: MIT
9
+ Keywords: authentication,bcrypt,file-server,http
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: Web Environment
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
18
+ Requires-Python: >=3.13
19
+ Requires-Dist: bcrypt>=5.0.0
20
+ Provides-Extra: test
21
+ Requires-Dist: pytest>=7.0.0; extra == 'test'
22
+ Description-Content-Type: text/markdown
23
+
24
+ # File Server
25
+
26
+ 带登录认证的安全文件服务器。
27
+
28
+ ## 功能特性
29
+
30
+ - **用户认证系统** - 基于用户名/密码的登录认证,使用 bcrypt 加密存储密码
31
+ - **会话管理** - 基于 Cookie 的安全会话机制,1小时超时
32
+ - **文件浏览** - 目录列表功能,分开展示 HTML 文件和其他文件
33
+ - **文件下载** - 支持任意文件下载,显示文件大小
34
+ - **用户管理** - 创建、删除、修改用户密码
35
+
36
+ ## 安全特性
37
+
38
+ - bcrypt 密码加密(rounds=12)
39
+ - 安全会话 ID(secrets.token_hex)
40
+ - HttpOnly + SameSite Cookie
41
+ - 会话超时机制
42
+ - 访问控制(未认证用户重定向到登录页)
43
+
44
+ ## 安装
45
+
46
+ ```bash
47
+ pip install file-server
48
+ ```
49
+
50
+ 或使用 pipx 安装(推荐):
51
+
52
+ ```bash
53
+ pipx install file-server
54
+ ```
55
+
56
+ ## 使用方法
57
+
58
+ ### 1. 创建用户
59
+
60
+ ```bash
61
+ file-server user create
62
+ # 或指定用户名
63
+ file-server user create -u admin
64
+ ```
65
+
66
+ ### 2. 启动服务器
67
+
68
+ ```bash
69
+ file-server
70
+ # 或指定端口
71
+ file-server -p 9000
72
+ ```
73
+
74
+ ### 命令行参数
75
+
76
+ | 参数 | 说明 | 默认值 |
77
+ |------|------|--------|
78
+ | `-p, --port` | 服务器端口 | 8000 |
79
+ | `-d, --directory` | 文件服务目录 | 当前目录 |
80
+ | `-c, --config-dir` | 配置文件目录 | 脚本目录 |
81
+ | `-s, --static-dir` | 静态文件目录 | 脚本目录 |
82
+
83
+ ### 用户管理命令
84
+
85
+ ```bash
86
+ # 创建用户
87
+ file-server user create [-u 用户名]
88
+
89
+ # 列出用户
90
+ file-server user list
91
+
92
+ # 删除用户
93
+ file-server user delete [-u 用户名]
94
+
95
+ # 修改密码
96
+ file-server user passwd [-u 用户名]
97
+ ```
98
+
99
+ ### 示例
100
+
101
+ ```bash
102
+ # 在端口 9000 启动服务器
103
+ file-server -p 9000
104
+
105
+ # 指定文件服务目录
106
+ file-server -d /path/to/files
107
+
108
+ # 自定义配置和静态文件目录
109
+ file-server -c /etc/file-server -s /var/www/files
110
+ ```
111
+
112
+ ## 访问
113
+
114
+ 启动后访问 `http://localhost:8000` 或局域网 IP 地址。
115
+
116
+ ## 项目结构
117
+
118
+ ```
119
+ file-server/
120
+ ├── src/file_server/
121
+ │ ├── __init__.py # 包入口
122
+ │ ├── __main__.py # CLI 入口
123
+ │ ├── config.py # 配置模块
124
+ │ ├── auth.py # 认证模块
125
+ │ ├── templates.py # HTML 模板
126
+ │ ├── handlers.py # HTTP 处理器
127
+ │ ├── server.py # 服务器启动
128
+ │ └── cli.py # 命令行工具
129
+ ├── tests/
130
+ │ ├── test_auth.py # 认证模块测试
131
+ │ └── test_config.py # 配置模块测试
132
+ ├── users.json # 用户数据存储
133
+ ├── pyproject.toml # 项目配置
134
+ └── README.md # 文档
135
+ ```
136
+
137
+ ## 开发与发布
138
+
139
+ ### 安装开发依赖
140
+
141
+ ```bash
142
+ pip install -e ".[test]"
143
+ ```
144
+
145
+ ### 运行测试
146
+
147
+ ```bash
148
+ pytest tests/ -v
149
+ ```
150
+
151
+ ### 构建
152
+
153
+ ```bash
154
+ pip install build
155
+ python -m build
156
+ ```
157
+
158
+ ### 发布到 TestPyPI
159
+
160
+ ```bash
161
+ pip install twine
162
+ python -m twine upload --repository testpypi dist/*
163
+ ```
164
+
165
+ ### 发布到 PyPI
166
+
167
+ ```bash
168
+ python -m twine upload dist/*
169
+ ```
170
+
171
+ ## 要求
172
+
173
+ - Python >= 3.13
174
+ - bcrypt >= 5.0.0
175
+
176
+ ## 许可证
177
+
178
+ MIT License
@@ -0,0 +1,12 @@
1
+ file_server/__init__.py,sha256=IErFRAr_-MQs7M0BytLkYo3pw0tobbgtyNWfigIY_S4,367
2
+ file_server/__main__.py,sha256=MeEzcrQwjyCicqzsCHajIPZFQAbaMytOShsCgCyuno4,86
3
+ file_server/auth.py,sha256=uxn7fv8nkW0GGKhibCGWJME6sT06Lu60Ud7xybMizpA,5606
4
+ file_server/cli.py,sha256=kLt2NJztcNObcpW92cLIV-kgKgWMrixGFKy6tf1OW5Q,5714
5
+ file_server/config.py,sha256=VQ_f_UVBQWbiO0OJn9onhglPY28ejAZyoFtilu5MDB0,1236
6
+ file_server/handlers.py,sha256=qHK8swATnxu9gIi1kPD330DFtyvmnzICMFNksJWmtbE,8415
7
+ file_server/server.py,sha256=aMmS9osU5nSIw2RkuHSrFRHnHac4UrB4YG-1s-xMBbI,2906
8
+ file_server/templates.py,sha256=DF4NrIJUNXJv_JbIOvt8v0g1n-gqDGwAx1oYuwtCQTc,10376
9
+ pyfileserv-0.2.2.dist-info/METADATA,sha256=qxxbO7FaJikC8POHGymBJ-A9bk1ENn3WNuakFd_hPOE,3879
10
+ pyfileserv-0.2.2.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
11
+ pyfileserv-0.2.2.dist-info/entry_points.txt,sha256=qi7yHHBquvosz3c8saBJi28JiY9m4zi1G4xc9UwJmqU,58
12
+ pyfileserv-0.2.2.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ file-server = file_server.__main__:main