Skip to content

Commit e1b90c9

Browse files
authored
3.2.4
3.2.4
2 parents ba61194 + ded24b1 commit e1b90c9

50 files changed

Lines changed: 3317 additions & 404 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,46 @@
1+
# [Unreleased]
2+
3+
## Backend
4+
5+
### Added
6+
7+
- 新增 `Security` 配置模型,支持登录 IP 白名单、MCP IP 白名单和 Bearer Token 认证
8+
- 新增登录端点 IP 白名单检查中间件 (`check_login_ip`)
9+
- MCP 安全中间件升级为可配置模式:支持 CIDR 白名单 + Bearer Token 双重认证
10+
- 认证端点支持 `Authorization: Bearer` 令牌绕过 Cookie 登录
11+
- 配置 API `_sanitize_dict` 修复:仅对字符串值进行脱敏,避免误处理非字符串字段
12+
13+
- 新增番剧放送日手动设置 API (`PATCH /api/v1/bangumi/{id}/weekday`),支持锁定放送日防止日历刷新覆盖
14+
- 数据库迁移 v9:`bangumi` 表新增 `weekday_locked`
15+
16+
### Fixed
17+
18+
- 修复 qBittorrent 下载器 SSL 连接问题:解耦 HTTPS 协议选择与证书验证,自签名证书不再导致连接失败 (#923)
19+
- 修复 `torrents_rename_file` 重命名验证循环中 `continue` 应为 `break` 的逻辑错误
20+
21+
### Changed
22+
23+
- 重构认证模块:提取 `_issue_token` 公共方法,消除 3 处重复的 JWT 签发逻辑
24+
- `get_current_user` 简化为三级认证(DEV 绕过 → Bearer Token → Cookie JWT)
25+
- `LocalNetworkMiddleware` 重命名为 `McpAccessMiddleware`,从硬编码 RFC 1918 改为读取配置
26+
27+
### Tests
28+
29+
- 新增 101 个单元测试覆盖安全、认证、配置、下载器和 MockDownloader 模块
30+
31+
## Frontend
32+
33+
### Added
34+
35+
- 新增日历拖拽排列功能:可将「未知」番剧拖入星期列,自动设置放送日并锁定
36+
- 拖入后显示紫色图钉图标,鼠标悬停显示取消按钮
37+
- 锁定的番剧在日历刷新时不会被覆盖
38+
- 使用 vuedraggable 实现流畅拖拽动画
39+
- 新增安全设置组件 (`config-security.vue`),支持在 WebUI 中配置 IP 白名单和 Token
40+
- 前端 `Security` 类型定义和初始化配置
41+
42+
---
43+
144
# [3.2.3] - 2026-02-23
245

346
## Backend

backend/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "auto-bangumi"
3-
version = "3.2.3"
3+
version = "3.2.4"
44
description = "AutoBangumi - Automated anime download manager"
55
requires-python = ">=3.13"
66
dependencies = [

backend/src/module/api/auth.py

Lines changed: 21 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from module.security.api import (
1010
active_user,
1111
auth_user,
12+
check_login_ip,
1213
get_current_user,
1314
update_user_info,
1415
)
@@ -18,42 +19,49 @@
1819

1920
router = APIRouter(prefix="/auth", tags=["auth"])
2021

22+
_TOKEN_EXPIRY_DAYS = 1
23+
_TOKEN_MAX_AGE = 86400
2124

22-
@router.post("/login", response_model=dict)
25+
26+
def _issue_token(username: str, response: Response) -> dict:
27+
"""Create a JWT, set it as an HttpOnly cookie, and return the bearer payload."""
28+
token = create_access_token(
29+
data={"sub": username}, expires_delta=timedelta(days=_TOKEN_EXPIRY_DAYS)
30+
)
31+
response.set_cookie(key="token", value=token, httponly=True, max_age=_TOKEN_MAX_AGE)
32+
return {"access_token": token, "token_type": "bearer"}
33+
34+
35+
@router.post("/login", response_model=dict, dependencies=[Depends(check_login_ip)])
2336
async def login(response: Response, form_data=Depends(OAuth2PasswordRequestForm)):
37+
"""Authenticate with username/password and issue a session token."""
2438
user = User(username=form_data.username, password=form_data.password)
2539
resp = auth_user(user)
2640
if resp.status:
27-
token = create_access_token(
28-
data={"sub": user.username}, expires_delta=timedelta(days=1)
29-
)
30-
response.set_cookie(key="token", value=token, httponly=True, max_age=86400)
31-
return {"access_token": token, "token_type": "bearer"}
41+
return _issue_token(user.username, response)
3242
return u_response(resp)
3343

3444

3545
@router.get(
3646
"/refresh_token", response_model=dict, dependencies=[Depends(get_current_user)]
3747
)
3848
async def refresh(response: Response, token: str = Cookie(None)):
49+
"""Refresh the current session token and update the active-user timestamp."""
3950
payload = decode_token(token)
4051
username = payload.get("sub") if payload else None
4152
if not username:
4253
raise HTTPException(
4354
status_code=status.HTTP_401_UNAUTHORIZED, detail="Unauthorized"
4455
)
4556
active_user[username] = datetime.now()
46-
new_token = create_access_token(
47-
data={"sub": username}, expires_delta=timedelta(days=1)
48-
)
49-
response.set_cookie(key="token", value=new_token, httponly=True, max_age=86400)
50-
return {"access_token": new_token, "token_type": "bearer"}
57+
return _issue_token(username, response)
5158

5259

5360
@router.get(
5461
"/logout", response_model=APIResponse, dependencies=[Depends(get_current_user)]
5562
)
5663
async def logout(response: Response, token: str = Cookie(None)):
64+
"""Invalidate the session and clear the token cookie."""
5765
payload = decode_token(token)
5866
username = payload.get("sub") if payload else None
5967
if username:
@@ -69,24 +77,12 @@ async def logout(response: Response, token: str = Cookie(None)):
6977
async def update_user(
7078
user_data: UserUpdate, response: Response, token: str = Cookie(None)
7179
):
80+
"""Update credentials for the current user and re-issue a fresh token."""
7281
payload = decode_token(token)
7382
old_user = payload.get("sub") if payload else None
7483
if not old_user:
7584
raise HTTPException(
7685
status_code=status.HTTP_401_UNAUTHORIZED, detail="Unauthorized"
7786
)
7887
if update_user_info(user_data, old_user):
79-
token = create_access_token(
80-
data={"sub": old_user}, expires_delta=timedelta(days=1)
81-
)
82-
response.set_cookie(
83-
key="token",
84-
value=token,
85-
httponly=True,
86-
max_age=86400,
87-
)
88-
return {
89-
"access_token": token,
90-
"token_type": "bearer",
91-
"message": "update success",
92-
}
88+
return {**_issue_token(old_user, response), "message": "update success"}

backend/src/module/api/bangumi.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ class OffsetSuggestionDetail(BaseModel):
4141
confidence: Literal["high", "medium", "low"]
4242

4343

44+
class SetWeekdayRequest(BaseModel):
45+
weekday: Optional[int] = None # 0-6 for Mon-Sun, None to reset
46+
47+
4448
class DetectOffsetRequest(BaseModel):
4549
"""Request body for detect-offset endpoint."""
4650
title: str
@@ -339,3 +343,41 @@ async def get_needs_review():
339343
"""Get all bangumi that need review for offset mismatch."""
340344
with Database() as db:
341345
return db.bangumi.get_needs_review()
346+
347+
348+
@router.patch(
349+
path="/{bangumi_id}/weekday",
350+
response_model=APIResponse,
351+
dependencies=[Depends(get_current_user)],
352+
)
353+
async def set_weekday(bangumi_id: int, request: SetWeekdayRequest):
354+
"""Manually set the broadcast weekday for a bangumi."""
355+
if request.weekday is not None and not (0 <= request.weekday <= 6):
356+
return JSONResponse(
357+
status_code=400,
358+
content={
359+
"status": False,
360+
"msg_en": "Weekday must be 0-6 (Mon-Sun) or null.",
361+
"msg_zh": "星期必须是 0-6(周一至周日)或空。",
362+
},
363+
)
364+
with Database() as db:
365+
success = db.bangumi.set_weekday(bangumi_id, request.weekday)
366+
if success:
367+
action = f"weekday {request.weekday}" if request.weekday is not None else "unknown"
368+
return JSONResponse(
369+
status_code=200,
370+
content={
371+
"status": True,
372+
"msg_en": f"Set bangumi to {action}.",
373+
"msg_zh": f"已设置放送日为 {action}。",
374+
},
375+
)
376+
return JSONResponse(
377+
status_code=404,
378+
content={
379+
"status": False,
380+
"msg_en": f"Bangumi {bangumi_id} not found.",
381+
"msg_zh": f"未找到番剧 {bangumi_id}。",
382+
},
383+
)

backend/src/module/api/config.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,12 @@
1414

1515

1616
def _sanitize_dict(d: dict) -> dict:
17+
"""Recursively mask string values whose keys contain sensitive keywords."""
1718
result = {}
1819
for k, v in d.items():
1920
if isinstance(v, dict):
2021
result[k] = _sanitize_dict(v)
21-
elif any(s in k.lower() for s in _SENSITIVE_KEYS):
22+
elif isinstance(v, str) and any(s in k.lower() for s in _SENSITIVE_KEYS):
2223
result[k] = "********"
2324
else:
2425
result[k] = v
@@ -27,13 +28,15 @@ def _sanitize_dict(d: dict) -> dict:
2728

2829
@router.get("/get", dependencies=[Depends(get_current_user)])
2930
async def get_config():
31+
"""Return the current configuration with sensitive fields masked."""
3032
return _sanitize_dict(settings.dict())
3133

3234

3335
@router.patch(
3436
"/update", response_model=APIResponse, dependencies=[Depends(get_current_user)]
3537
)
3638
async def update_config(config: Config):
39+
"""Persist and reload configuration from the supplied payload."""
3740
try:
3841
settings.save(config_dict=config.dict())
3942
settings.load()

backend/src/module/conf/config.py

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
from module.models.config import Config
99

10-
from .const import ENV_TO_ATTR
10+
from .const import DEFAULT_SETTINGS, ENV_TO_ATTR
1111

1212
logger = logging.getLogger(__name__)
1313
CONFIG_ROOT = Path("config")
@@ -27,6 +27,15 @@
2727

2828

2929
class Settings(Config):
30+
"""Runtime configuration singleton.
31+
32+
On construction, loads from ``CONFIG_PATH`` if the file exists (and
33+
immediately re-saves to apply any migrations), otherwise bootstraps
34+
defaults from environment variables via ``init()``.
35+
36+
Use ``settings`` module-level instance rather than instantiating directly.
37+
"""
38+
3039
def __init__(self):
3140
super().__init__()
3241
if CONFIG_PATH.exists():
@@ -36,6 +45,7 @@ def __init__(self):
3645
self.init()
3746

3847
def load(self):
48+
"""Load and validate configuration from ``CONFIG_PATH``, applying migrations."""
3949
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
4050
config = json.load(f)
4151
config = self._migrate_old_config(config)
@@ -65,20 +75,27 @@ def _migrate_old_config(config: dict) -> dict:
6575
for key in ("type", "custom_url", "token", "enable_tmdb"):
6676
rss_parser.pop(key, None)
6777

78+
# Add security section if missing (preserves local-network MCP default)
79+
if "security" not in config:
80+
config["security"] = DEFAULT_SETTINGS["security"]
81+
6882
return config
6983

7084
def save(self, config_dict: dict | None = None):
85+
"""Write configuration to ``CONFIG_PATH``. Uses current state when no dict supplied."""
7186
if not config_dict:
7287
config_dict = self.model_dump()
7388
with open(CONFIG_PATH, "w", encoding="utf-8") as f:
7489
json.dump(config_dict, f, indent=4, ensure_ascii=False)
7590

7691
def init(self):
92+
"""Bootstrap a new config file from ``.env`` and environment variables."""
7793
load_dotenv(".env")
7894
self.__load_from_env()
7995
self.save()
8096

8197
def __load_from_env(self):
98+
"""Apply ``ENV_TO_ATTR`` mappings from the process environment to the config dict."""
8299
config_dict = self.model_dump()
83100
for key, section in ENV_TO_ATTR.items():
84101
for env, attr in section.items():
@@ -97,12 +114,11 @@ def __load_from_env(self):
97114
logger.info("Config loaded from env")
98115

99116
@staticmethod
100-
def __val_from_env(env: str, attr: tuple):
117+
def __val_from_env(env: str, attr: tuple | str):
118+
"""Return the environment variable value, applying the converter when attr is a tuple."""
101119
if isinstance(attr, tuple):
102-
conv_func = attr[1]
103-
return conv_func(os.environ[env])
104-
else:
105-
return os.environ[env]
120+
return attr[1](os.environ[env])
121+
return os.environ[env]
106122

107123
@property
108124
def group_rules(self):

backend/src/module/conf/const.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
# -*- encoding: utf-8 -*-
2+
# DEFAULT_SETTINGS: factory defaults written to config.json on first run.
3+
# ENV_TO_ATTR: maps AB_* environment variables to Config model attribute paths.
4+
# Values are either a string attr name, a (attr_name, converter) tuple, or a
5+
# list of such tuples when a single env var sets multiple attributes.
26
DEFAULT_SETTINGS = {
37
"program": {
48
"rss_time": 900,
@@ -46,6 +50,20 @@
4650
"model": "gpt-3.5-turbo",
4751
"deployment_id": "",
4852
},
53+
"security": {
54+
"login_whitelist": [],
55+
"login_tokens": [],
56+
"mcp_whitelist": [
57+
"127.0.0.0/8",
58+
"10.0.0.0/8",
59+
"172.16.0.0/12",
60+
"192.168.0.0/16",
61+
"::1/128",
62+
"fe80::/10",
63+
"fc00::/7",
64+
],
65+
"mcp_tokens": [],
66+
},
4967
}
5068

5169

@@ -99,8 +117,11 @@
99117

100118

101119
class BCOLORS:
120+
"""ANSI colour helpers for terminal output."""
121+
102122
@staticmethod
103123
def _(color: str, *args: str) -> str:
124+
"""Wrap *args* in the given ANSI colour code and reset at the end."""
104125
strings = [str(s) for s in args]
105126
return f"{color}{', '.join(strings)}{BCOLORS.ENDC}"
106127

0 commit comments

Comments
 (0)