Files
zTools2/app/config.py
zikai fffba79022 feat: 文件浏览页 + 共享白板 + 白板管理页(含补登记分片上传/隧道历史改动)
本次提交包含两批改动(7月2日遗留未入库 + 本次新功能),分述如下:

【补登记:7月2日已上线但未提交的功能】
- 分片上传:chunk_upload_controller/service/dao + UploadSession model/schema,
  支持 4MiB 分片、乱序、断点续传、去重、幂等 complete;后台 reaper 清理过期会话。
- 反向隧道:tunnel_controller/service/dao + TunnelSession model/schema,
  SSH remote forward 经 /api/userPort/{userName} 反代到 user 本地服务。
- 上传页:views/upload_html.py(拖拽/多文件/分片/断点续传 UI)。
- config.py:StorageConfig.chunk_session_dir/ttl、TunnelConfig;
  requirements.txt 加 httpx;start.sh 清理 .work/ 残留;
  schema.sql 加 upload_session/tunnel_session 表;sftp_server 承载隧道转发。

【本次新功能】
- 文件浏览页:GET /files(Basic Auth 同 docs)+ /api/admin/files(list/get/download/DELETE)。
  硬删除(DB 行 + 磁盘文件),删除后列表不再显示。前端 static/file_browser.*。
- 共享白板:GET /whiteboard/{id}(公开,不存在则新建)+ WS /ws/whiteboard/{id}。
  MySQL 持久化(whiteboard 表),Canvas 实时同步,心跳 3s/5 次失活移除,
  清空/复制按钮,移动端兼容。WhiteboardHub 管理 {board_id: set[Connection]},
  disconnect 幂等 + 空 set 清理防泄漏,broadcast 失败连接自动移除。
- 白板管理页:GET /whiteboard-admin(Basic Auth)+ /api/admin/whiteboards(list/DELETE)。
  删除时 hub.close_board 踢出在线连接。
- 清理:合并 UploadService.get_out_with_disk_path(下载/删除复用,消除重复 DB 读),
  移除无用 resolve_disk_path。
- config.py:WhiteboardConfig(heartbeat/threshold/board_id 长度/list_limit);
  schema.sql 加 whiteboard 表;README 补新接口与心跳/内存说明。
- 验证:tests/manual_whiteboard_hub.py / _ws.py / _kick.py 全部通过。
2026-07-21 14:28:13 +00:00

147 lines
4.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""运行时配置:所有参数与凭据均从项目根目录的 config.yaml 读取。"""
from __future__ import annotations
import os
from functools import lru_cache
from pathlib import Path
import yaml
from pydantic import BaseModel, Field, field_validator
PROJECT_ROOT = Path(__file__).resolve().parent.parent
DEFAULT_CONFIG_PATH = PROJECT_ROOT / "config.yaml"
class ServerConfig(BaseModel):
host: str = "127.0.0.1"
port: int = 6867
workers: int = 1
class DatabaseConfig(BaseModel):
host: str = "127.0.0.1"
port: int = 3306
user: str = "zikai_filesvc"
password: str = ""
database: str = "zikai_filesvc"
pool_size: int = 5
pool_recycle: int = 1800
class StorageConfig(BaseModel):
upload_dir: str = "./uploads"
chunk_bytes: int = 1024 * 1024
sha256_on_upload: bool = True
# 分片上传会话的暂存目录(相对 upload_dir完整文件拼接在此完成
chunk_session_dir: str = "./.work"
# 分片上传会话的存活秒数:超过该时长无活动的 pending 会话由后台 reaper 清理
chunk_session_ttl_seconds: int = 300
class SftpUser(BaseModel):
username: str
password_hash: str = "" # bcrypt
public_key: str | None = None # 可选的内联 OpenSSH 公钥
class SftpConfig(BaseModel):
enabled: bool = True
host: str = "0.0.0.0"
port: int = 2022
host_key_path: str = "./keys/ssh_host_ed25519_key"
authorized_keys_path: str = "./keys/authorized_keys"
users: list[SftpUser] = Field(default_factory=list)
class DocsConfig(BaseModel):
"""/docs、/redoc、/openapi.json 的 Basic Auth 凭据(明文)。"""
enabled: bool = True
username: str = "admin"
password: str = "" # 留空 → /docs 返回 503避免误暴露
realm: str = "zikai docs"
@field_validator("password", mode="before")
@classmethod
def _coerce(cls, v):
# 兼容 YAML 把纯数字密码解析成 int 的情况。
return "" if v is None else str(v)
class TunnelUser(BaseModel):
"""一个反向隧道用户:凭据 + 固定的隧道端口与本地端口。"""
username: str
password_hash: str = "" # bcrypt与 SFTP 用户同款
# 该 user 在 server 侧绑定的隧道端口SSH remote forward 的 listen port
tunnel_port: int = 0
# 该 user 要暴露的本地服务端口(仅用于记录,实际转发由 user 端完成)
local_port: int = 0
class TunnelConfig(BaseModel):
"""反向隧道总开关与用户列表。"""
enabled: bool = False
users: list[TunnelUser] = Field(default_factory=list)
def find_user(self, username: str) -> TunnelUser | None:
return next((u for u in self.users if u.username == username), None)
class WhiteboardConfig(BaseModel):
"""共享白板配置。
白板本身无鉴权(任何人凭 /whiteboard/{id} 即可访问并实时协作);
管理页(/whiteboard-admin、/api/admin/whiteboards走 docs 同款 Basic Auth。
心跳按 heartbeat_interval_seconds 发送,连续丢失 heartbeat_miss_threshold 次即判失活。
"""
heartbeat_interval_seconds: int = 3
heartbeat_miss_threshold: int = 5
# board_id 合法字符集与长度上限,防路径/注入
max_board_id_length: int = 64
# 列表/管理页分页默认值
list_limit: int = 100
class Settings(BaseModel):
server: ServerConfig = ServerConfig()
database: DatabaseConfig = DatabaseConfig()
storage: StorageConfig = StorageConfig()
sftp: SftpConfig = SftpConfig()
docs: DocsConfig = DocsConfig()
tunnel: TunnelConfig = TunnelConfig()
whiteboard: WhiteboardConfig = WhiteboardConfig()
def db_url(self) -> str:
c = self.database
return (
f"mysql+pymysql://{c.user}:{c.password}@{c.host}:{c.port}/{c.database}"
"?charset=utf8mb4"
)
def resolved_upload_dir(self) -> Path:
return (PROJECT_ROOT / self.storage.upload_dir).resolve()
def _load_yaml(path: Path) -> dict:
if not path.exists():
raise FileNotFoundError(
f"未找到配置文件 {path};请先 cp config.example.yaml config.yaml 并填值。"
)
return yaml.safe_load(path.read_text(encoding="utf-8")) or {}
@lru_cache(maxsize=1)
def get_settings() -> Settings:
path = Path(os.getenv("CONFIG_PATH", str(DEFAULT_CONFIG_PATH)))
return Settings.model_validate(_load_yaml(path))
def reload_settings() -> Settings:
"""清缓存并重新读取,供脚本与测试使用。"""
get_settings.cache_clear()
return get_settings()