Skip to content
Merged

3.2.4 #989

Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,41 @@
# [Unreleased]

## Backend

### Added

- 新增 `Security` 配置模型,支持登录 IP 白名单、MCP IP 白名单和 Bearer Token 认证
- 新增登录端点 IP 白名单检查中间件 (`check_login_ip`)
- MCP 安全中间件升级为可配置模式:支持 CIDR 白名单 + Bearer Token 双重认证
- 认证端点支持 `Authorization: Bearer` 令牌绕过 Cookie 登录
- 配置 API `_sanitize_dict` 修复:仅对字符串值进行脱敏,避免误处理非字符串字段

- 新增番剧放送日手动设置 API (`PATCH /api/v1/bangumi/{id}/weekday`),支持锁定放送日防止日历刷新覆盖
- 数据库迁移 v9:`bangumi` 表新增 `weekday_locked` 列

### Changed

- 重构认证模块:提取 `_issue_token` 公共方法,消除 3 处重复的 JWT 签发逻辑
- `get_current_user` 简化为三级认证(DEV 绕过 → Bearer Token → Cookie JWT)
- `LocalNetworkMiddleware` 重命名为 `McpAccessMiddleware`,从硬编码 RFC 1918 改为读取配置

### Tests

- 新增 101 个单元测试覆盖安全、认证、配置、下载器和 MockDownloader 模块

## Frontend

### Added

- 新增日历拖拽排列功能:可将「未知」番剧拖入星期列,自动设置放送日并锁定
- 拖入后显示紫色图钉图标,鼠标悬停显示取消按钮
- 锁定的番剧在日历刷新时不会被覆盖
- 使用 vuedraggable 实现流畅拖拽动画
- 新增安全设置组件 (`config-security.vue`),支持在 WebUI 中配置 IP 白名单和 Token
- 前端 `Security` 类型定义和初始化配置

---

# [3.2.3] - 2026-02-23

## Backend
Expand Down
2 changes: 1 addition & 1 deletion backend/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "auto-bangumi"
version = "3.2.3"
version = "3.2.4"
description = "AutoBangumi - Automated anime download manager"
requires-python = ">=3.13"
dependencies = [
Expand Down
46 changes: 21 additions & 25 deletions backend/src/module/api/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from module.security.api import (
active_user,
auth_user,
check_login_ip,
get_current_user,
update_user_info,
)
Expand All @@ -18,42 +19,49 @@

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

_TOKEN_EXPIRY_DAYS = 1
_TOKEN_MAX_AGE = 86400

@router.post("/login", response_model=dict)

def _issue_token(username: str, response: Response) -> dict:
"""Create a JWT, set it as an HttpOnly cookie, and return the bearer payload."""
token = create_access_token(
data={"sub": username}, expires_delta=timedelta(days=_TOKEN_EXPIRY_DAYS)
)
response.set_cookie(key="token", value=token, httponly=True, max_age=_TOKEN_MAX_AGE)
return {"access_token": token, "token_type": "bearer"}


@router.post("/login", response_model=dict, dependencies=[Depends(check_login_ip)])
async def login(response: Response, form_data=Depends(OAuth2PasswordRequestForm)):
"""Authenticate with username/password and issue a session token."""
user = User(username=form_data.username, password=form_data.password)
resp = auth_user(user)
if resp.status:
token = create_access_token(
data={"sub": user.username}, expires_delta=timedelta(days=1)
)
response.set_cookie(key="token", value=token, httponly=True, max_age=86400)
return {"access_token": token, "token_type": "bearer"}
return _issue_token(user.username, response)
return u_response(resp)


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


@router.get(
"/logout", response_model=APIResponse, dependencies=[Depends(get_current_user)]
)
async def logout(response: Response, token: str = Cookie(None)):
"""Invalidate the session and clear the token cookie."""
payload = decode_token(token)
username = payload.get("sub") if payload else None
if username:
Expand All @@ -69,24 +77,12 @@ async def logout(response: Response, token: str = Cookie(None)):
async def update_user(
user_data: UserUpdate, response: Response, token: str = Cookie(None)
):
"""Update credentials for the current user and re-issue a fresh token."""
payload = decode_token(token)
old_user = payload.get("sub") if payload else None
if not old_user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Unauthorized"
)
if update_user_info(user_data, old_user):
token = create_access_token(
data={"sub": old_user}, expires_delta=timedelta(days=1)
)
response.set_cookie(
key="token",
value=token,
httponly=True,
max_age=86400,
)
return {
"access_token": token,
"token_type": "bearer",
"message": "update success",
}
return {**_issue_token(old_user, response), "message": "update success"}
42 changes: 42 additions & 0 deletions backend/src/module/api/bangumi.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ class OffsetSuggestionDetail(BaseModel):
confidence: Literal["high", "medium", "low"]


class SetWeekdayRequest(BaseModel):
weekday: Optional[int] = None # 0-6 for Mon-Sun, None to reset


class DetectOffsetRequest(BaseModel):
"""Request body for detect-offset endpoint."""
title: str
Expand Down Expand Up @@ -339,3 +343,41 @@ async def get_needs_review():
"""Get all bangumi that need review for offset mismatch."""
with Database() as db:
return db.bangumi.get_needs_review()


@router.patch(
path="/{bangumi_id}/weekday",
response_model=APIResponse,
dependencies=[Depends(get_current_user)],
)
async def set_weekday(bangumi_id: int, request: SetWeekdayRequest):
"""Manually set the broadcast weekday for a bangumi."""
if request.weekday is not None and not (0 <= request.weekday <= 6):
return JSONResponse(
status_code=400,
content={
"status": False,
"msg_en": "Weekday must be 0-6 (Mon-Sun) or null.",
"msg_zh": "星期必须是 0-6(周一至周日)或空。",
},
)
with Database() as db:
success = db.bangumi.set_weekday(bangumi_id, request.weekday)
if success:
action = f"weekday {request.weekday}" if request.weekday is not None else "unknown"
return JSONResponse(
status_code=200,
content={
"status": True,
"msg_en": f"Set bangumi to {action}.",
"msg_zh": f"已设置放送日为 {action}。",
},
)
return JSONResponse(
status_code=404,
content={
"status": False,
"msg_en": f"Bangumi {bangumi_id} not found.",
"msg_zh": f"未找到番剧 {bangumi_id}。",
},
)
Comment on lines +348 to +383
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new set_weekday API endpoint (PATCH /bangumi/{id}/weekday) lacks automated test coverage. While 101 new tests were added to this PR, none specifically test this new endpoint's behavior including validation of weekday range (0-6), null handling, success/failure responses, and database state updates. Adding test coverage would help ensure this critical user-facing feature works correctly.

Copilot uses AI. Check for mistakes.
5 changes: 4 additions & 1 deletion backend/src/module/api/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,12 @@


def _sanitize_dict(d: dict) -> dict:
"""Recursively mask string values whose keys contain sensitive keywords."""
result = {}
for k, v in d.items():
if isinstance(v, dict):
result[k] = _sanitize_dict(v)
elif any(s in k.lower() for s in _SENSITIVE_KEYS):
elif isinstance(v, str) and any(s in k.lower() for s in _SENSITIVE_KEYS):
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The sanitization function now only masks string values, which is correct. However, there's a potential issue: the function checks if the value v is a string, but if a sensitive key has a non-string value (like a list of tokens), those values won't be masked. For the Security model, login_tokens and mcp_tokens are lists of strings, not strings themselves, so they will not be sanitized and will be exposed in the API response.

Copilot uses AI. Check for mistakes.
result[k] = "********"
else:
result[k] = v
Expand All @@ -27,13 +28,15 @@ def _sanitize_dict(d: dict) -> dict:

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


@router.patch(
"/update", response_model=APIResponse, dependencies=[Depends(get_current_user)]
)
async def update_config(config: Config):
"""Persist and reload configuration from the supplied payload."""
try:
settings.save(config_dict=config.dict())
settings.load()
Expand Down
28 changes: 22 additions & 6 deletions backend/src/module/conf/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

from module.models.config import Config

from .const import ENV_TO_ATTR
from .const import DEFAULT_SETTINGS, ENV_TO_ATTR

logger = logging.getLogger(__name__)
CONFIG_ROOT = Path("config")
Expand All @@ -27,6 +27,15 @@


class Settings(Config):
"""Runtime configuration singleton.

On construction, loads from ``CONFIG_PATH`` if the file exists (and
immediately re-saves to apply any migrations), otherwise bootstraps
defaults from environment variables via ``init()``.

Use ``settings`` module-level instance rather than instantiating directly.
"""

def __init__(self):
super().__init__()
if CONFIG_PATH.exists():
Expand All @@ -36,6 +45,7 @@ def __init__(self):
self.init()

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

# Add security section if missing (preserves local-network MCP default)
if "security" not in config:
config["security"] = DEFAULT_SETTINGS["security"]

return config

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

def init(self):
"""Bootstrap a new config file from ``.env`` and environment variables."""
load_dotenv(".env")
self.__load_from_env()
self.save()

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

@staticmethod
def __val_from_env(env: str, attr: tuple):
def __val_from_env(env: str, attr: tuple | str):
"""Return the environment variable value, applying the converter when attr is a tuple."""
if isinstance(attr, tuple):
conv_func = attr[1]
return conv_func(os.environ[env])
else:
return os.environ[env]
return attr[1](os.environ[env])
return os.environ[env]

@property
def group_rules(self):
Expand Down
21 changes: 21 additions & 0 deletions backend/src/module/conf/const.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
# -*- encoding: utf-8 -*-
# DEFAULT_SETTINGS: factory defaults written to config.json on first run.
# ENV_TO_ATTR: maps AB_* environment variables to Config model attribute paths.
# Values are either a string attr name, a (attr_name, converter) tuple, or a
# list of such tuples when a single env var sets multiple attributes.
DEFAULT_SETTINGS = {
"program": {
"rss_time": 900,
Expand Down Expand Up @@ -46,6 +50,20 @@
"model": "gpt-3.5-turbo",
"deployment_id": "",
},
"security": {
"login_whitelist": [],
"login_tokens": [],
"mcp_whitelist": [
"127.0.0.0/8",
"10.0.0.0/8",
"172.16.0.0/12",
"192.168.0.0/16",
"::1/128",
"fe80::/10",
"fc00::/7",
],
"mcp_tokens": [],
},
}


Expand Down Expand Up @@ -99,8 +117,11 @@


class BCOLORS:
"""ANSI colour helpers for terminal output."""

@staticmethod
def _(color: str, *args: str) -> str:
"""Wrap *args* in the given ANSI colour code and reset at the end."""
strings = [str(s) for s in args]
return f"{color}{', '.join(strings)}{BCOLORS.ENDC}"

Expand Down
22 changes: 22 additions & 0 deletions backend/src/module/database/bangumi.py
Original file line number Diff line number Diff line change
Expand Up @@ -657,3 +657,25 @@ def clear_needs_review(self, _id: int) -> bool:
_invalidate_bangumi_cache()
logger.debug("[Database] Cleared needs_review for bangumi id %s", _id)
return True

def set_weekday(self, _id: int, weekday: int | None) -> bool:
"""Set air_weekday and weekday_locked for manual calendar assignment."""
bangumi = self.session.get(Bangumi, _id)
if not bangumi:
return False
if weekday is not None:
bangumi.air_weekday = weekday
bangumi.weekday_locked = True
else:
bangumi.air_weekday = None
bangumi.weekday_locked = False
self.session.add(bangumi)
self.session.commit()
_invalidate_bangumi_cache()
logger.debug(
"[Database] Set weekday=%s, locked=%s for bangumi id %s",
weekday,
bangumi.weekday_locked,
_id,
)
return True
Loading