高性能异步文件服务器 - 现代化Web文件管理解决方案
🚀 项目简介
这是一个基于Python aiohttp框架构建的高性能异步文件服务器,提供了完整的Web界面文件管理系统。无论您是需要个人文件存储、团队文件共享,还是作为项目文件管理工具,这个解决方案都能满足您的需求。
✨ 核心特性
🔐 安全认证系统
- 用户登录验证:内置用户认证机制,保护您的文件安全
- 会话管理:使用加密Cookie存储会话信息
- 权限控制:未登录用户只能浏览,登录后才能进行上传、删除等操作
📁 完整的文件管理功能
- 文件浏览:直观的文件夹导航和文件列表
- 多文件上传:支持批量上传,实时显示上传进度
- 文件下载:支持大文件分块下载,性能优异
- 文件夹管理:创建、删除文件夹
- 文件搜索:快速搜索文件名,支持子目录递归搜索
- 文件删除:安全的删除操作,支持文件和文件夹
🎯 技术亮点
- 异步高性能:基于aiohttp的异步处理,支持高并发
- 大文件支持:最大支持10GB文件上传
- 实时进度:上传过程显示速度、剩余时间等详细信息
- 响应式设计:适配电脑和移动设备
- 路径安全:完善的路径安全检查,防止目录遍历攻击
🛠️ 安装与部署
环境要求
- Python 3.7+
- 支持的操作系统:Windows、Linux、macOS
安装步骤
-
安装依赖
pip install aiohttp aiofiles aiohttp_session cryptography -
运行服务器
python file_server.py -
访问系统 打开浏览器访问:
http://localhost:8080
默认登录信息
- 用户名:
admin - 密码:
password
📖 使用指南
基本操作
-
登录系统
- 访问首页会自动跳转到登录页面
- 输入用户名和密码登录
-
文件浏览
- 使用面包屑导航在不同目录间切换
- 点击文件夹进入子目录
- 点击文件进行下载
-
文件上传
- 登录后显示上传区域
- 选择文件或拖拽文件到上传区域
- 实时查看上传进度和速度
-
搜索功能
- 在搜索框输入文件名关键词
- 支持在当前目录及子目录中搜索
高级功能
修改默认密码
# 生成新密码的SHA256哈希
echo -n "你的新密码" | sha256sum
# 在代码中替换users字典中的密码哈希
自定义配置
- 修改服务器地址和端口:在
FileServer初始化参数中设置 - 更改文件存储路径:修改
storage_path参数 - 添加新用户:在
users字典中添加新用户信息
🔧 配置说明
服务器配置
server = FileServer(
host='0.0.0.0', # 监听地址
port=8080, # 端口号
storage_path='/data' # 文件存储路径
)
安全配置
- 会话过期时间:默认7天
- 最大文件上传大小:10GB
- 路径安全检查:防止恶意路径遍历
💡 应用场景
🏠 个人使用
- 家庭文件共享服务器
- 个人云存储解决方案
- 照片和文档管理
👥 团队协作
- 小型团队文件共享
- 项目文档管理
- 临时文件交换平台
🔧 开发测试
- 测试文件服务器
- 本地开发环境文件管理
- 自动化脚本文件托管
🛡️ 安全建议
- 修改默认密码:首次使用后立即修改admin密码
- 使用HTTPS:在生产环境中配置SSL证书
- 网络隔离:仅在可信网络环境中使用
- 定期备份:重要文件定期备份
- 权限控制:根据需求添加不同的用户账号
🔄 维护与监控
日志查看
系统会自动记录操作日志,便于故障排查:
- 登录/退出记录
- 文件上传/下载记录
- 错误信息记录
性能优化
- 大文件使用分块传输,内存占用低
- 异步处理确保高并发性能
- 文件哈希校验保证数据完整性
🌟 优势总结
- 轻量级:纯Python实现,依赖简单
- 高性能:异步架构,支持大文件高速传输
- 易部署:一键启动,无需复杂配置
- 功能全面:覆盖常用文件操作需求
- 界面友好:现代化Web界面,操作直观
- 安全可靠:完善的认证和路径安全检查
这个高性能文件服务器是个人文件管理、团队协作和小型项目文件共享的理想选择。它的简洁设计和强大功能让文件管理变得简单高效!
代码分享:
#安装依赖:pip install aiohttp aiofiles aiohttp_session cryptography
#运行服务器:python file_server.py
#访问Web界面:打开浏览器访问 http://localhost:8080
#登录:用户名:admin 密码:password 可在下面简单的用户数据库中修改 用"echo -n 你的密码|sha256sum"生成密码的sha256
import aiohttp
from aiohttp import web
import aiofiles
import os
import hashlib
import json
import zipfile
import io
from datetime import datetime
from pathlib import Path
import asyncio
import aiohttp_session
from aiohttp_session.cookie_storage import EncryptedCookieStorage
import base64
from cryptography import fernet
import urllib.parse
import logging
# 配置日志
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# 简单的用户数据库(实际应用中应使用数据库)
users = {
"admin": {
"password": "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8", # "password"的SHA256
"role": "admin"
}
}
class FileServer:
def __init__(self, host='localhost', port=8080, storage_path='/data'):
self.host = host
self.port = port
self.storage_path = Path(storage_path).resolve()
self.storage_path.mkdir(exist_ok=True)
def is_safe_path(self, path):
"""检查路径是否安全,防止目录遍历攻击"""
try:
target_path = (self.storage_path / path).resolve()
return self.storage_path in target_path.parents or target_path == self.storage_path
except Exception:
return False
async def check_auth(self, request):
"""检查用户是否已登录"""
try:
session = await aiohttp_session.get_session(request)
return session.get('user')
except Exception as e:
logger.error(f"检查认证时出错: {e}")
return None
async def require_auth(self, request):
"""需要认证的装饰器函数"""
user = await self.check_auth(request)
if not user:
return web.json_response({'error': 'Authentication required'}, status=401)
return None
async def init(self):
"""初始化服务器"""
# 设置会话加密
fernet_key = fernet.Fernet.generate_key()
secret_key = base64.urlsafe_b64decode(fernet_key)
app = web.Application(client_max_size=1024**3 * 10) # 最大10GB文件上传
# 添加错误处理中间件
async def error_middleware(app, handler):
async def middleware_handler(request):
try:
response = await handler(request)
return response
except web.HTTPException as ex:
logger.error(f"HTTP错误: {ex}")
return web.json_response({'error': str(ex)}, status=ex.status)
except Exception as ex:
logger.error(f"服务器错误: {ex}")
return web.json_response({'error': 'Internal server error'}, status=500)
return middleware_handler
app.middlewares.append(error_middleware)
aiohttp_session.setup(app, EncryptedCookieStorage(secret_key))
app.router.add_routes([
web.get('/', self.index),
web.get('/login', self.login_page),
web.post('/api/login', self.login),
web.post('/api/logout', self.logout),
web.get('/api/files', self.list_files),
web.get('/api/search', self.search_files),
web.get('/api/download/{file_path:.+}', self.download_file),
web.post('/api/upload', self.upload_file),
web.post('/api/mkdir', self.create_directory),
web.delete('/api/delete/{file_path:.+}', self.delete_path),
web.static('/static', './static', follow_symlinks=True),
])
return app
async def index(self, request):
"""返回主页"""
try:
return web.FileResponse('./static/index.html')
except FileNotFoundError:
return web.Response(text="页面未找到", status=404)
async def login_page(self, request):
"""返回登录页面"""
try:
return web.FileResponse('./static/login.html')
except FileNotFoundError:
return web.Response(text="登录页面未找到", status=404)
async def login(self, request):
"""处理登录请求"""
try:
data = await request.json()
username = data.get('username')
password = data.get('password')
if not username or not password:
return web.json_response({'error': '用户名和密码不能为空'}, status=400)
# 验证用户
if username in users:
# 计算密码的SHA256哈希
password_hash = hashlib.sha256(password.encode()).hexdigest()
if users[username]['password'] == password_hash:
session = await aiohttp_session.get_session(request)
session['user'] = username
# 设置会话过期时间为7天
session.max_age = 7 * 24 * 3600
return web.json_response({'message': '登录成功'})
return web.json_response({'error': '用户名或密码错误'}, status=401)
except Exception as e:
logger.error(f"登录处理错误: {e}")
return web.json_response({'error': f'登录处理错误: {str(e)}'}, status=500)
async def logout(self, request):
"""处理登出请求"""
try:
session = await aiohttp_session.get_session(request)
session.pop('user', None)
return web.json_response({'message': '退出成功'})
except Exception as e:
logger.error(f"退出登录错误: {e}")
return web.json_response({'error': '退出登录失败'}, status=500)
async def list_files(self, request):
"""列出目录中的文件"""
try:
path = request.query.get('path', '')
# 安全检查
if not self.is_safe_path(path):
return web.json_response({'error': '无效路径'}, status=400)
target_path = self.storage_path / path
if not target_path.exists() or not target_path.is_dir():
return web.json_response({'error': '目录不存在'}, status=404)
files = []
for item in target_path.iterdir():
files.append({
'name': item.name,
'path': str(item.relative_to(self.storage_path)),
'is_dir': item.is_dir(),
'size': item.stat().st_size if item.is_file() else 0,
'modified': item.stat().st_mtime
})
# 按文件夹和文件分组并排序
files.sort(key=lambda x: (not x['is_dir'], x['name'].lower()))
# 检查用户是否已登录
user = await self.check_auth(request)
is_authenticated = user is not None
return web.json_response({
'files': files,
'current_path': path,
'is_authenticated': is_authenticated
})
except Exception as e:
logger.error(f"列出文件错误: {e}")
return web.json_response({'error': '获取文件列表失败'}, status=500)
async def search_files(self, request):
"""搜索文件"""
try:
query = request.query.get('q', '')
path = request.query.get('path', '')
if not query:
return web.json_response({'error': '搜索查询不能为空'}, status=400)
# 安全检查
if not self.is_safe_path(path):
return web.json_response({'error': '无效路径'}, status=400)
target_path = self.storage_path / path
if not target_path.exists() or not target_path.is_dir():
return web.json_response({'error': '目录不存在'}, status=404)
results = []
# 递归搜索文件
for root, dirs, files in os.walk(target_path):
for file in files:
if query.lower() in file.lower():
file_path = Path(root) / file
relative_path = str(file_path.relative_to(self.storage_path))
results.append({
'name': file,
'path': relative_path,
'is_dir': False,
'size': file_path.stat().st_size,
'modified': file_path.stat().st_mtime,
'parent_path': str(Path(root).relative_to(self.storage_path))
})
# 检查用户是否已登录
user = await self.check_auth(request)
is_authenticated = user is not None
return web.json_response({
'results': results,
'query': query,
'is_authenticated': is_authenticated
})
except Exception as e:
logger.error(f"搜索文件错误: {e}")
return web.json_response({'error': '搜索文件失败'}, status=500)
async def download_file(self, request):
"""下载文件"""
try:
file_path = request.match_info['file_path']
# URL解码路径
file_path = urllib.parse.unquote(file_path)
# 安全检查
if not self.is_safe_path(file_path):
return web.json_response({'error': '无效路径'}, status=400)
target_path = self.storage_path / file_path
if not target_path.exists() or not target_path.is_file():
return web.json_response({'error': '文件不存在'}, status=404)
# 使用分块传输以提高大文件下载性能
response = web.StreamResponse(
status=200,
reason='OK',
headers={
'Content-Type': 'application/octet-stream',
'Content-Disposition': f'attachment; filename="{target_path.name}"',
'Content-Length': str(target_path.stat().st_size),
}
)
await response.prepare(request)
# 使用异步文件读取分块发送数据
async with aiofiles.open(target_path, 'rb') as f:
chunk_size = 65536 # 64KB chunks
while True:
chunk = await f.read(chunk_size)
if not chunk:
break
await response.write(chunk)
return response
except Exception as e:
logger.error(f"下载文件错误: {e}")
return web.json_response({'error': '下载文件失败'}, status=500)
async def upload_file(self, request):
"""上传文件(支持多文件上传)"""
try:
# 检查认证
auth_error = await self.require_auth(request)
if auth_error:
return auth_error
# 获取上传路径
upload_path = request.headers.get('X-Upload-Path', '')
# URL解码路径
upload_path = urllib.parse.unquote(upload_path)
# 安全检查
if not self.is_safe_path(upload_path):
return web.json_response({'error': '无效路径'}, status=400)
target_dir = self.storage_path / upload_path
target_dir.mkdir(exist_ok=True, parents=True)
# 处理多部分表单数据
reader = await request.multipart()
results = []
# 处理所有上传的文件
while True:
part = await reader.next()
if part is None:
break
if part.name != 'files':
continue
# 获取文件名
filename = part.filename
if not filename:
continue
# 构建完整的目标路径
target_file_path = target_dir / filename
# 计算文件的MD5哈希用于校验
file_hash = hashlib.md5()
file_size = 0
# 异步写入文件
async with aiofiles.open(target_file_path, 'wb') as f:
while True:
chunk = await part.read_chunk(65536) # 64KB chunks
if not chunk:
break
await f.write(chunk)
file_size += len(chunk)
file_hash.update(chunk)
# 上传后校验:检查文件大小和完整性
uploaded_file_size = target_file_path.stat().st_size
# 如果文件大小不匹配,删除不完整的文件
if uploaded_file_size != file_size:
await asyncio.to_thread(os.remove, target_file_path)
results.append({
'name': filename,
'path': str(target_file_path.relative_to(self.storage_path)),
'size': file_size,
'uploaded_size': uploaded_file_size,
'status': 'failed',
'error': f'文件大小不匹配: 期望 {file_size} 字节, 实际 {uploaded_file_size} 字节'
})
logger.error(f"文件大小不匹配: {filename}, 期望: {file_size}, 实际: {uploaded_file_size}")
continue
# 可选:重新计算文件哈希进行完整性校验(对大文件可能影响性能)
try:
# 重新读取文件计算哈希
verify_hash = hashlib.md5()
async with aiofiles.open(target_file_path, 'rb') as f:
while True:
chunk = await f.read(65536)
if not chunk:
break
verify_hash.update(chunk)
if verify_hash.hexdigest() != file_hash.hexdigest():
await asyncio.to_thread(os.remove, target_file_path)
results.append({
'name': filename,
'path': str(target_file_path.relative_to(self.storage_path)),
'size': file_size,
'status': 'failed',
'error': '文件完整性校验失败'
})
logger.error(f"文件完整性校验失败: {filename}")
continue
except Exception as hash_error:
logger.warning(f"文件哈希校验失败,跳过: {hash_error}")
# 文件上传成功
results.append({
'name': filename,
'path': str(target_file_path.relative_to(self.storage_path)),
'size': file_size,
'hash': file_hash.hexdigest(),
'status': 'success'
})
logger.info(f"文件上传成功: {filename}, 大小: {file_size} 字节")
# 检查是否有失败的上传
failed_uploads = [r for r in results if r['status'] == 'failed']
if failed_uploads:
message = f"部分文件上传失败 ({len(failed_uploads)}/{len(results)})"
else:
message = f"文件上传成功 ({len(results)} 个文件)"
return web.json_response({
'message': message,
'results': results,
'success_count': len([r for r in results if r['status'] == 'success']),
'failed_count': len(failed_uploads)
})
except Exception as e:
logger.error(f"上传文件错误: {e}")
return web.json_response({'error': f'文件上传失败: {str(e)}'}, status=500)
async def create_directory(self, request):
"""创建目录"""
try:
# 检查认证
auth_error = await self.require_auth(request)
if auth_error:
return auth_error
data = await request.json()
dir_name = data.get('name', '')
parent_path = data.get('path', '')
# URL解码路径
parent_path = urllib.parse.unquote(parent_path)
# 安全检查
if not self.is_safe_path(parent_path):
return web.json_response({'error': '无效路径'}, status=400)
if not dir_name:
return web.json_response({'error': '文件夹名称不能为空'}, status=400)
target_path = self.storage_path / parent_path / dir_name
target_path.mkdir(exist_ok=True, parents=True)
return web.json_response({'message': '文件夹创建成功'})
except Exception as e:
logger.error(f"创建目录错误: {e}")
return web.json_response({'error': '创建文件夹失败'}, status=500)
async def delete_path(self, request):
"""删除文件或目录"""
try:
# 检查认证
auth_error = await self.require_auth(request)
if auth_error:
return auth_error
file_path = request.match_info['file_path']
# URL解码路径
file_path = urllib.parse.unquote(file_path)
# 安全检查
if not self.is_safe_path(file_path):
return web.json_response({'error': '无效路径'}, status=400)
target_path = self.storage_path / file_path
if not target_path.exists():
return web.json_response({'error': '路径不存在'}, status=404)
if target_path.is_file():
target_path.unlink()
else:
import shutil
shutil.rmtree(target_path)
return web.json_response({'message': '删除成功'})
except Exception as e:
logger.error(f"删除路径错误: {e}")
return web.json_response({'error': '删除失败'}, status=500)
async def run(self):
"""启动服务器"""
try:
app = await self.init()
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, self.host, self.port)
await site.start()
print(f"文件服务器运行在 http://{self.host}:{self.port}")
print("默认登录信息: admin/password")
# 保持服务器运行
await asyncio.Future() # 永久运行
except Exception as e:
logger.error(f"服务器启动错误: {e}")
print(f"服务器启动失败: {e}")
except KeyboardInterrupt:
print("服务器已停止")
# 创建静态文件目录和HTML界面
def setup_static_files():
try:
static_dir = Path('./static')
static_dir.mkdir(exist_ok=True)
# 创建登录页面
login_html = """
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>登录 - 文件服务器</title>
<style>
:root {
--primary-color: #4361ee;
--secondary-color: #3a0ca3;
--light-color: #f8f9fa;
--dark-color: #212529;
--gray-color: #6c757d;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.login-container {
background: white;
padding: 40px;
border-radius: 10px;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
width: 400px;
max-width: 90%;
}
h1 {
text-align: center;
margin-bottom: 30px;
color: var(--primary-color);
}
.form-group {
margin-bottom: 20px;
}
label {
display: block;
margin-bottom: 5px;
color: var(--dark-color);
font-weight: 500;
}
input[type="text"],
input[type="password"] {
width: 100%;
padding: 12px;
border: 1px solid #ddd;
border-radius: 5px;
font-size: 16px;
}
button {
width: 100%;
padding: 12px;
background-color: var(--primary-color);
color: white;
border: none;
border-radius: 5px;
font-size: 16px;
cursor: pointer;
transition: background-color 0.2s;
}
button:hover {
background-color: var(--secondary-color);
}
.error {
color: #e74c3c;
margin-top: 10px;
text-align: center;
}
.success {
color: #27ae60;
margin-top: 10px;
text-align: center;
}
</style>
</head>
<body>
<div class="login-container">
<h1>文件服务器登录</h1>
<form id="loginForm">
<div class="form-group">
<label for="username">用户名</label>
<input type="text" id="username" name="username" required>
</div>
<div class="form-group">
<label for="password">密码</label>
<input type="password" id="password" name="password" required>
</div>
<button type="submit">登录</button>
<div id="message" class="error"></div>
</form>
<div style="margin-top: 20px; text-align: center; font-size: 14px; color: #666;">
默认账号: admin, 密码: password
</div>
</div>
<script>
document.getElementById('loginForm').addEventListener('submit', async function(e) {
e.preventDefault();
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
const messageEl = document.getElementById('message');
try {
const response = await fetch('/api/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
username: username,
password: password
})
});
const data = await response.json();
if (response.ok) {
messageEl.textContent = '登录成功,正在跳转...';
messageEl.className = 'success';
setTimeout(() => {
window.location.href = '/';
}, 1000);
} else {
messageEl.textContent = data.error || '登录失败';
messageEl.className = 'error';
}
} catch (error) {
messageEl.textContent = '网络错误: ' + error.message;
messageEl.className = 'error';
}
});
</script>
</body>
</html>
"""
with open(static_dir / 'login.html', 'w', encoding='utf-8') as f:
f.write(login_html)
# 创建主页面
index_html = """
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>高性能文件服务器</title>
<style>
:root {
--primary-color: #4361ee;
--secondary-color: #3a0ca3;
--accent-color: #f72585;
--light-color: #f8f9fa;
--dark-color: #212529;
--success-color: #4cc9f0;
--warning-color: #ffba08;
--danger-color: #f94144;
--gray-color: #6c757d;
--border-radius: 8px;
--box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: #f5f7ff;
color: var(--dark-color);
line-height: 1.6;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
padding: 20px;
background: white;
border-radius: var(--border-radius);
box-shadow: var(--box-shadow);
}
h1 {
color: var(--primary-color);
font-size: 28px;
}
.user-info {
display: flex;
align-items: center;
gap: 15px;
}
.header-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
button {
padding: 10px 15px;
border: none;
border-radius: var(--border-radius);
cursor: pointer;
font-weight: 500;
transition: all 0.2s ease;
}
.btn-primary {
background-color: var(--primary-color);
color: white;
}
.btn-primary:hover {
background-color: var(--secondary-color);
}
.btn-success {
background-color: var(--success-color);
color: white;
}
.btn-danger {
background-color: var(--danger-color);
color: white;
}
.btn-warning {
background-color: var(--warning-color);
color: white;
}
.search-section {
background: white;
padding: 20px;
border-radius: var(--border-radius);
box-shadow: var(--box-shadow);
margin-bottom: 30px;
}
.search-section h2 {
margin-bottom: 15px;
color: var(--primary-color);
}
.search-form {
display: flex;
gap: 10px;
margin-top: 15px;
flex-wrap: wrap;
}
input[type="text"] {
flex: 1;
padding: 10px;
border: 1px solid #ddd;
border-radius: var(--border-radius);
min-width: 200px;
}
.upload-section {
background: white;
padding: 20px;
border-radius: var(--border-radius);
box-shadow: var(--box-shadow);
margin-bottom: 30px;
}
.upload-section h2 {
margin-bottom: 15px;
color: var(--primary-color);
}
.upload-form {
display: flex;
gap: 10px;
margin-top: 15px;
flex-wrap: wrap;
}
.progress-details {
display: flex;
justify-content: space-between;
margin-top: 5px;
font-size: 12px;
color: #666;
}
.progress-details span {
flex: 1;
}
.file-size {
font-size: 12px;
color: #999;
margin-left: 5px;
}
.progress-percent {
font-weight: bold;
color: var(--primary-color);
}
.progress-speed {
text-align: center;
}
.progress-time {
text-align: right;
}
@media (max-width: 768px) {
.progress-details {
flex-direction: column;
gap: 2px;
}
.progress-details span {
text-align: left;
}
}
input[type="file"] {
flex: 1;
padding: 10px;
border: 1px solid #ddd;
border-radius: var(--border-radius);
min-width: 200px;
}
.breadcrumb {
display: flex;
align-items: center;
margin-bottom: 20px;
background: white;
padding: 15px 20px;
border-radius: var(--border-radius);
box-shadow: var(--box-shadow);
flex-wrap: wrap;
}
.breadcrumb a {
color: var(--primary-color);
text-decoration: none;
cursor: pointer;
}
.breadcrumb span {
margin: 0 5px;
color: var(--gray-color);
}
.file-list {
background: white;
border-radius: var(--border-radius);
box-shadow: var(--box-shadow);
overflow: hidden;
}
.file-header {
display: grid;
grid-template-columns: 3fr 1fr 1fr 1fr;
padding: 15px 20px;
background-color: var(--primary-color);
color: white;
font-weight: 500;
}
.file-item {
display: grid;
grid-template-columns: 3fr 1fr 1fr 1fr;
padding: 15px 20px;
border-bottom: 1px solid #eee;
align-items: center;
}
.file-item:last-child {
border-bottom: none;
}
.file-item:hover {
background-color: #f8f9fa;
}
.file-name {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
}
.file-icon {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.folder-icon {
color: var(--warning-color);
}
.file-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.file-action-btn {
padding: 5px 10px;
font-size: 12px;
}
.empty-folder {
padding: 40px;
text-align: center;
color: var(--gray-color);
}
.upload-progress {
margin-top: 20px;
}
.progress-item {
display: flex;
align-items: center;
margin-bottom: 10px;
padding: 10px;
background-color: #f8f9fa;
border-radius: var(--border-radius);
}
.progress-info {
flex: 1;
margin-right: 15px;
}
.progress-filename {
font-weight: 500;
margin-bottom: 5px;
}
.progress-bar-container {
height: 8px;
background-color: #e9ecef;
border-radius: 4px;
overflow: hidden;
}
.progress-bar {
height: 100%;
background-color: var(--primary-color);
width: 0%;
transition: width 0.3s;
}
.progress-status {
min-width: 80px;
text-align: right;
font-size: 14px;
color: var(--gray-color);
}
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background-color: white;
padding: 25px;
border-radius: var(--border-radius);
width: 400px;
max-width: 90%;
}
.modal h3 {
margin-bottom: 15px;
color: var(--primary-color);
}
.modal-input {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: var(--border-radius);
margin-bottom: 15px;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.login-required {
opacity: 0.6;
pointer-events: none;
}
.error-message {
color: var(--danger-color);
padding: 10px;
background-color: #ffeaea;
border-radius: var(--border-radius);
margin: 10px 0;
text-align: center;
}
.success-message {
color: var(--success-color);
padding: 10px;
background-color: #e8f5e8;
border-radius: var(--border-radius);
margin: 10px 0;
text-align: center;
}
.search-results {
margin-top: 20px;
background: white;
border-radius: var(--border-radius);
box-shadow: var(--box-shadow);
padding: 20px;
}
.search-results h3 {
margin-bottom: 15px;
color: var(--primary-color);
}
.search-result-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px;
border-bottom: 1px solid #eee;
}
.search-result-item:last-child {
border-bottom: none;
}
.search-result-path {
color: var(--gray-color);
font-size: 14px;
}
@media (max-width: 768px) {
.file-header, .file-item {
grid-template-columns: 2fr 1fr 1fr;
}
.file-header div:nth-child(3),
.file-item div:nth-child(3) {
display: none;
}
header {
flex-direction: column;
gap: 15px;
align-items: flex-start;
}
.upload-form, .search-form {
flex-direction: column;
}
.header-actions {
flex-direction: column;
width: 100%;
}
.header-actions button {
width: 100%;
margin-bottom: 5px;
}
}
</style>
</head>
<body>
<div class="container">
<header>
<h1>📁 高性能文件服务器</h1>
<div class="user-info">
<span id="userGreeting">未登录</span>
<div class="header-actions">
<button class="btn-primary" id="createFolderBtn">新建文件夹</button>
<button class="btn-success" onclick="refresh()">刷新</button>
<button class="btn-warning" id="loginBtn" style="display: none;" onclick="window.location.href='/login'">登录</button>
<button class="btn-danger" id="logoutBtn" style="display: none;" onclick="logout()">退出</button>
</div>
</div>
</header>
<div id="errorMessage" class="error-message" style="display: none;"></div>
<div id="successMessage" class="success-message" style="display: none;"></div>
<section class="search-section">
<h2>搜索文件</h2>
<div class="search-form">
<input type="text" id="searchInput" placeholder="输入文件名进行搜索">
<button class="btn-primary" onclick="searchFiles()">搜索</button>
</div>
<div id="searchResults" class="search-results" style="display: none;"></div>
</section>
<section class="upload-section" id="uploadSection" style="display: none;">
<h2>上传文件</h2>
<div class="upload-form">
<input type="file" id="fileInput" multiple>
<button class="btn-primary" onclick="uploadFiles()">上传文件</button>
</div>
<div class="upload-progress" id="uploadProgress" style="display: none;">
<h3>上传进度</h3>
<div id="progressList"></div>
</div>
</section>
<div class="breadcrumb" id="breadcrumb">
<a onclick="navigateToRoot()">根目录</a>
</div>
<section class="file-list">
<div class="file-header">
<div>名称</div>
<div>大小</div>
<div>修改日期</div>
<div>操作</div>
</div>
<div id="fileListContainer"></div>
</section>
</div>
<!-- 创建文件夹模态框 -->
<div class="modal" id="createFolderModal">
<div class="modal-content">
<h3>创建新文件夹</h3>
<input type="text" id="folderNameInput" class="modal-input" placeholder="输入文件夹名称">
<div class="modal-actions">
<button class="btn-warning" onclick="hideCreateFolderModal()">取消</button>
<button class="btn-primary" onclick="createFolder()">创建</button>
</div>
</div>
</div>
<script>
let currentPath = '';
let uploadsInProgress = 0;
let isSearching = false;
let searchResults = [];
// 显示错误消息
function showError(message) {
const errorEl = document.getElementById('errorMessage');
errorEl.textContent = message;
errorEl.style.display = 'block';
setTimeout(() => {
errorEl.style.display = 'none';
}, 5000);
}
// 显示成功消息
function showSuccess(message) {
const successEl = document.getElementById('successMessage');
successEl.textContent = message;
successEl.style.display = 'block';
setTimeout(() => {
successEl.style.display = 'none';
}, 3000);
}
// 页面加载时检查登录状态并获取文件列表
window.onload = function() {
checkAuth();
listFiles();
};
// 检查认证状态
async function checkAuth() {
try {
const response = await fetch('/api/files');
if (response.status === 401) {
// 未登录
document.getElementById('userGreeting').textContent = '未登录';
document.getElementById('loginBtn').style.display = 'block';
document.getElementById('logoutBtn').style.display = 'none';
document.getElementById('createFolderBtn').classList.add('login-required');
document.getElementById('uploadSection').style.display = 'none';
return;
}
const data = await response.json();
if (data.is_authenticated) {
// 已登录
document.getElementById('userGreeting').textContent = '已登录';
document.getElementById('loginBtn').style.display = 'none';
document.getElementById('logoutBtn').style.display = 'block';
document.getElementById('createFolderBtn').classList.remove('login-required');
document.getElementById('uploadSection').style.display = 'block';
} else {
// 未登录
document.getElementById('userGreeting').textContent = '未登录';
document.getElementById('loginBtn').style.display = 'block';
document.getElementById('logoutBtn').style.display = 'none';
document.getElementById('createFolderBtn').classList.add('login-required');
document.getElementById('uploadSection').style.display = 'none';
}
} catch (error) {
console.error('检查认证状态失败:', error);
showError('检查登录状态失败: ' + error.message);
}
}
// 退出登录
async function logout() {
try {
const response = await fetch('/api/logout', { method: 'POST' });
if (response.ok) {
alert('已退出登录');
checkAuth();
listFiles();
} else {
const data = await response.json();
showError('退出登录失败: ' + (data.error || '未知错误'));
}
} catch (error) {
showError('退出登录失败: ' + error.message);
}
}
// 获取文件列表
async function listFiles() {
try {
isSearching = false;
document.getElementById('searchResults').style.display = 'none';
const response = await fetch(`/api/files?path=${encodeURIComponent(currentPath)}`);
if (response.status === 401) {
checkAuth();
return;
}
if (!response.ok) {
const data = await response.json();
showError('获取文件列表失败: ' + (data.error || '未知错误'));
return;
}
const data = await response.json();
updateBreadcrumb(data.current_path);
renderFileList(data.files);
} catch (error) {
showError('获取文件列表失败: ' + error.message);
}
}
// 更新面包屑导航
// 更新面包屑导航 - 使用onclick属性
function updateBreadcrumb(path) {
const breadcrumb = document.getElementById('breadcrumb');
breadcrumb.innerHTML = '<a onclick="navigateToRoot()">根目录</a>';
if (path) {
const parts = path.split('/').filter(part => part !== '');
let accumulatedPath = '';
parts.forEach((part, index) => {
accumulatedPath += '/' + part;
const span = document.createElement('span');
span.innerHTML = ' / ';
breadcrumb.appendChild(span);
const link = document.createElement('a');
link.textContent = decodeURIComponent(part);
link.href = 'javascript:void(0)'; // 防止链接跳转
// 使用onclick属性
link.setAttribute('onclick', `navigate('${accumulatedPath.substring(1)}')`);
breadcrumb.appendChild(link);
});
}
}
// 渲染文件列表
function renderFileList(files) {
const container = document.getElementById('fileListContainer');
if (files.length === 0) {
container.innerHTML = '<div class="empty-folder">此文件夹为空</div>';
return;
}
let html = '';
files.forEach(file => {
const isDir = file.is_dir;
const size = isDir ? '-' : formatFileSize(file.size);
const modified = formatDate(file.modified);
html += `
<div class="file-item">
<div class="file-name" onclick="${isDir ? `navigate('${file.path}')` : `downloadFile('${file.path}')`}">
<div class="file-icon ${isDir ? 'folder-icon' : ''}">
${isDir ? '📁' : '📄'}
</div>
${file.name}
</div>
<div>${size}</div>
<div>${modified}</div>
<div class="file-actions">
${!isDir ? `<button class="btn-primary file-action-btn" onclick="downloadFile('${file.path}')">下载</button>` : ''}
<button class="btn-danger file-action-btn" onclick="deleteItem('${file.path}', ${isDir})">删除</button>
</div>
</div>
`;
});
container.innerHTML = html;
}
// 格式化文件大小
function formatFileSize(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
// 格式化日期
function formatDate(timestamp) {
const date = new Date(timestamp * 1000);
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString();
}
// 导航到子目录
function navigate(path) {
currentPath = path;
listFiles();
}
// 导航到根目录
function navigateToRoot() {
currentPath = '';
listFiles();
}
// 搜索文件
async function searchFiles() {
const query = document.getElementById('searchInput').value.trim();
if (!query) {
showError('请输入搜索关键词');
return;
}
try {
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}&path=${encodeURIComponent(currentPath)}`);
if (response.status === 401) {
checkAuth();
return;
}
if (!response.ok) {
const data = await response.json();
showError('搜索失败: ' + (data.error || '未知错误'));
return;
}
const data = await response.json();
searchResults = data.results;
isSearching = true;
// 显示搜索结果
const searchResultsEl = document.getElementById('searchResults');
searchResultsEl.style.display = 'block';
if (searchResults.length === 0) {
searchResultsEl.innerHTML = '<p>没有找到匹配的文件</p>';
return;
}
let html = `<h3>搜索 "${data.query}" 的结果 (${searchResults.length} 个文件)</h3>`;
searchResults.forEach(file => {
html += `
<div class="search-result-item">
<div>
<div>${file.name}</div>
<div class="search-result-path">${file.parent_path || '根目录'}</div>
</div>
<div class="file-actions">
<button class="btn-primary file-action-btn" onclick="navigateToFile('${file.path}')">查看</button>
<button class="btn-primary file-action-btn" onclick="downloadFile('${file.path}')">下载</button>
</div>
</div>
`;
});
searchResultsEl.innerHTML = html;
} catch (error) {
showError('搜索错误: ' + error.message);
}
}
// 导航到文件所在目录
function navigateToFile(filePath) {
const pathParts = filePath.split('/');
if (pathParts.length > 1) {
// 获取文件所在目录
const directoryPath = pathParts.slice(0, -1).join('/');
currentPath = directoryPath;
listFiles();
} else {
// 文件在根目录
navigateToRoot();
}
}
// 显示上传进度
function showUploadProgress(files) {
const progressContainer = document.getElementById('uploadProgress');
const progressList = document.getElementById('progressList');
progressContainer.style.display = 'block';
progressList.innerHTML = '';
files.forEach(file => {
const progressItem = document.createElement('div');
progressItem.className = 'progress-item';
progressItem.id = `progress-${file.name}`;
progressItem.innerHTML = `
<div class="progress-info">
<div class="progress-filename">${file.name} <span class="file-size">(${formatFileSize(file.size)})</span></div>
<div class="progress-bar-container">
<div class="progress-bar" style="width: 0%"></div>
</div>
<div class="progress-details">
<span class="progress-percent">0%</span>
<span class="progress-speed">速度: 计算中...</span>
<span class="progress-time">剩余时间: 计算中...</span>
</div>
</div>
<div class="progress-status">等待中</div>
`;
progressList.appendChild(progressItem);
});
uploadsInProgress += files.length;
}
// 更新上传进度
function updateUploadProgress(filename, percent, status, speed, timeRemaining) {
const progressItem = document.getElementById(`progress-${filename}`);
if (progressItem) {
const progressBar = progressItem.querySelector('.progress-bar');
const progressStatus = progressItem.querySelector('.progress-status');
const progressPercent = progressItem.querySelector('.progress-percent');
const progressSpeed = progressItem.querySelector('.progress-speed');
const progressTime = progressItem.querySelector('.progress-time');
progressBar.style.width = `${percent}%`;
progressPercent.textContent = `${Math.round(percent)}%`;
progressStatus.textContent = status;
if (speed !== undefined) {
progressSpeed.textContent = `速度: ${formatSpeed(speed)}`;
}
if (timeRemaining !== undefined) {
if (timeRemaining === Infinity || timeRemaining < 0) {
progressTime.textContent = '剩余时间: 计算中...';
} else {
progressTime.textContent = `剩余时间: ${formatTime(timeRemaining)}`;
}
}
if (percent === 100 || status === '失败' || status === '错误') {
uploadsInProgress--;
if (uploadsInProgress === 0) {
setTimeout(hideUploadProgress, 2000);
}
}
}
}
// 格式化速度显示
function formatSpeed(bytesPerSecond) {
if (bytesPerSecond === 0) return '0 B/s';
const k = 1024;
const sizes = ['B/s', 'KB/s', 'MB/s', 'GB/s'];
const i = Math.floor(Math.log(bytesPerSecond) / Math.log(k));
return parseFloat((bytesPerSecond / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
// 格式化时间显示
function formatTime(seconds) {
if (seconds < 60) {
return `${Math.ceil(seconds)}秒`;
} else if (seconds < 3600) {
const minutes = Math.floor(seconds / 60);
const secs = Math.ceil(seconds % 60);
return `${minutes}分${secs}秒`;
} else {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
return `${hours}时${minutes}分`;
}
}
// 上传多个文件(使用XMLHttpRequest以获取进度)
function uploadFiles() {
const fileInput = document.getElementById('fileInput');
const files = Array.from(fileInput.files);
if (files.length === 0) {
showError('请选择文件');
return;
}
// 显示上传进度
showUploadProgress(files);
// 为每个文件创建上传任务
files.forEach(file => {
uploadSingleFile(file);
});
}
// 上传单个文件
// 上传单个文件
function uploadSingleFile(file) {
const formData = new FormData();
formData.append('files', file);
const xhr = new XMLHttpRequest();
// 跟踪上传进度
let startTime = Date.now();
let lastLoaded = 0;
let lastTime = startTime;
const totalSize = file.size; // 使用文件的size属性
xhr.upload.addEventListener('progress', function(event) {
if (event.lengthComputable) {
const percent = (event.loaded / event.total) * 100;
const currentTime = Date.now();
const timeDiff = (currentTime - lastTime) / 1000; // 转换为秒
if (timeDiff > 0.5) { // 每0.5秒更新一次速度
const loadedDiff = event.loaded - lastLoaded;
const speed = loadedDiff / timeDiff; // 字节/秒
// 计算剩余时间
const remainingBytes = event.total - event.loaded;
const timeRemaining = speed > 0 ? remainingBytes / speed : Infinity;
updateUploadProgress(file.name, percent, '上传中', speed, timeRemaining);
lastLoaded = event.loaded;
lastTime = currentTime;
} else {
updateUploadProgress(file.name, percent, '上传中');
}
} else {
// 如果无法获取总大小,使用文件大小作为替代
const percent = totalSize > 0 ? (event.loaded / totalSize) * 100 : 0;
updateUploadProgress(file.name, percent, '上传中');
}
});
xhr.addEventListener('load', function() {
if (xhr.status === 200) {
try {
const response = JSON.parse(xhr.responseText);
if (response.success_count > 0) {
updateUploadProgress(file.name, 100, '完成', 0, 0);
showSuccess(`上传成功: ${response.success_count}/${response.results.length} 个文件`);
} else {
updateUploadProgress(file.name, 100, '失败', 0, 0);
showError('上传失败: ' + (response.results[0]?.error || '未知错误'));
}
} catch (e) {
updateUploadProgress(file.name, 100, '完成', 0, 0);
}
// 检查是否所有文件都上传完成
setTimeout(() => {
const allProgressItems = document.querySelectorAll('.progress-item');
const allComplete = Array.from(allProgressItems).every(item => {
const status = item.querySelector('.progress-status').textContent;
return status === '完成' || status === '失败' || status === '错误';
});
if (allComplete) {
hideUploadProgress();
listFiles();
fileInput.value = ''; // 清空文件选择
}
}, 1000);
} else {
let errorMsg = '上传失败';
try {
const data = JSON.parse(xhr.responseText);
errorMsg = data.error || errorMsg;
} catch (e) {}
updateUploadProgress(file.name, 0, errorMsg, 0, 0);
showError(errorMsg);
}
});
xhr.addEventListener('error', function() {
updateUploadProgress(file.name, 0, '网络错误', 0, 0);
showError('网络错误,上传失败');
});
xhr.open('POST', '/api/upload');
xhr.setRequestHeader('X-Upload-Path', encodeURIComponent(currentPath));
xhr.send(formData);
}
// 隐藏上传进度
function hideUploadProgress() {
const progressContainer = document.getElementById('uploadProgress');
progressContainer.style.display = 'none';
listFiles(); // 关键修改:上传完成后自动刷新当前目录
}
// 下载文件
function downloadFile(filePath) {
window.open(`/api/download/${encodeURIComponent(filePath)}`, '_blank');
}
// 显示创建文件夹模态框
function showCreateFolderModal() {
// 检查是否已登录
if (document.getElementById('createFolderBtn').classList.contains('login-required')) {
showError('请先登录');
window.location.href = '/login';
return;
}
document.getElementById('createFolderModal').style.display = 'flex';
document.getElementById('folderNameInput').value = '';
document.getElementById('folderNameInput').focus();
}
// 隐藏创建文件夹模态框
function hideCreateFolderModal() {
document.getElementById('createFolderModal').style.display = 'none';
}
// 创建文件夹
async function createFolder() {
const name = document.getElementById('folderNameInput').value.trim();
if (!name) {
showError('请输入文件夹名称');
return;
}
try {
const response = await fetch('/api/mkdir', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
name: name,
path: encodeURIComponent(currentPath)
})
});
if (response.status === 401) {
showError('请先登录');
window.location.href = '/login';
return;
}
if (!response.ok) {
const data = await response.json();
showError('创建文件夹失败: ' + (data.error || '未知错误'));
return;
}
hideCreateFolderModal();
listFiles();
showSuccess('文件夹创建成功');
} catch (error) {
showError('创建错误: ' + error.message);
}
}
// 删除文件或文件夹
async function deleteItem(path, isDir) {
// 检查是否已登录
if (document.getElementById('createFolderBtn').classList.contains('login-required')) {
showError('请先登录');
window.location.href = '/login';
return;
}
if (!confirm(`确定要删除${isDir ? '文件夹' : '文件'} "${path.split('/').pop()}" 吗?`)) return;
try {
const response = await fetch(`/api/delete/${encodeURIComponent(path)}`, {
method: 'DELETE'
});
if (response.status === 401) {
showError('请先登录');
window.location.href = '/login';
return;
}
if (!response.ok) {
const data = await response.json();
showError('删除失败: ' + (data.error || '未知错误'));
return;
}
listFiles();
showSuccess('删除成功');
} catch (error) {
showError('删除错误: ' + error.message);
}
}
// 刷新文件列表
function refresh() {
listFiles();
}
// 全局点击事件,点击模态框外部关闭模态框
window.onclick = function(event) {
const modal = document.getElementById('createFolderModal');
if (event.target === modal) {
hideCreateFolderModal();
}
};
// 支持键盘事件
document.addEventListener('keydown', function(event) {
if (event.key === 'Escape') {
hideCreateFolderModal();
}
// 按回车键搜索
if (event.key === 'Enter' && document.getElementById('searchInput') === document.activeElement) {
searchFiles();
}
});
// 绑定创建文件夹按钮事件
document.getElementById('createFolderBtn').addEventListener('click', showCreateFolderModal);
</script>
</body>
</html>
"""
with open(static_dir / 'index.html', 'w', encoding='utf-8') as f:
f.write(index_html)
except Exception as e:
logger.error(f"创建静态文件错误: {e}")
print(f"创建静态文件失败: {e}")
async def main():
# 设置静态文件
setup_static_files()
# 启动服务器
server = FileServer(host='0.0.0.0', port=8080)
await server.run()
if __name__ == '__main__':
import asyncio
asyncio.run(main())