Files

644 lines
22 KiB
Python
Raw Permalink 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.
#!/usr/bin/env python3
"""
为 database-operation 技能提供受控的 MoviePilot 数据库检查和显式写入入口。
"""
import argparse
import json
import os
import re
import sqlite3
import sys
from collections.abc import Iterator
from contextlib import contextmanager
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Optional
from sqlalchemy import create_engine, inspect, text
from sqlalchemy.engine import Connection, Engine
from sqlalchemy.exc import SQLAlchemyError
SCRIPT_PATH = Path(__file__).resolve()
def _find_project_root() -> Optional[Path]:
"""
从运行环境或目录标记定位 MoviePilot 程序目录。
"""
candidates = []
configured_root = os.environ.get("MOVIEPILOT_ROOT", "").strip()
if configured_root:
candidates.append(Path(configured_root).expanduser())
for base in (Path.cwd().resolve(), SCRIPT_PATH.parent):
candidates.extend((base, *base.parents))
for candidate in candidates:
if (candidate / "app" / "runtime" / "config.py").is_file():
return candidate
return None
PROJECT_ROOT = _find_project_root()
WRITE_STATEMENT_RE = re.compile(
r"^\s*(insert|update|delete|drop|alter|truncate|create|replace)\b",
re.IGNORECASE,
)
WRITE_KEYWORD_RE = re.compile(
r"\b(insert|update|delete|drop|alter|truncate|create|replace)\b",
re.IGNORECASE,
)
SELECT_STATEMENT_RE = re.compile(r"^\s*(select|with|explain)\b", re.IGNORECASE)
# 双引号、反引号与方括号在 SQLite/PostgreSQL 中都是标识符引用,可用于表名识别
_IDENTIFIER_QUOTES = frozenset({'"', "`", "["})
_STATEMENT_WORD_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
# 唯一按 SystemConfigKey 聚合全部实例级凭据的表,托管 API 默认脱敏、直查 SQL 不脱敏
_SECRET_TABLE_NAMES = frozenset({"systemconfig"})
@dataclass(frozen=True, slots=True)
class ArgumentSpec:
"""
单个动作参数的公开调用契约。
:param name: 动作调用中的精确参数名称
:param type: 公开模式使用的类型表达式,支持联合类型及数组
:param description: 供调用方理解参数用途、限制和单位的中文说明
:param required: 该参数是否必填,默认 False
:param default: 参数默认值;仅在明确声明默认值时向调用方公开
:param has_default: 是否将 default 写入公开契约,用于区分未设置和显式 None
"""
name: str
type: str
description: str
required: bool = False
default: Any = None
has_default: bool = False
def to_dict(self) -> dict[str, Any]:
"""
返回 MCP 模式生成器使用的公开参数契约。
"""
result = {
"name": self.name,
"type": self.type,
"description": self.description,
"required": self.required,
}
if self.has_default:
result["default"] = self.default
return result
@dataclass(frozen=True, slots=True)
class ActionSpec:
"""
单个允许执行动作的能力、影响范围及参数契约。
:param description: 公开动作的用途与操作边界说明
:param effect: 策略分类标识,例如 safe_read、reversible_write 或 destructive_write;保持机器可读原值
:param arguments: 按声明顺序排列的参数契约,默认无参数
:param argument_rules: 参数间的组合约束与安全要求,默认无附加规则
"""
description: str
effect: str
arguments: tuple[ArgumentSpec, ...] = ()
argument_rules: tuple[str, ...] = ()
@property
def required(self) -> tuple[str, ...]:
"""
返回此动作要求的必填参数名称。
"""
return tuple(argument.name for argument in self.arguments if argument.required)
def to_dict(self, name: str) -> dict[str, Any]:
"""
返回不包含实现细节的公开动作契约。
"""
return {
"action": name,
"description": self.description,
"effect": self.effect,
"providers": ["sqlite", "postgresql"],
"required_arguments": list(self.required),
"arguments": [argument.to_dict() for argument in self.arguments],
"argument_rules": list(self.argument_rules),
}
ACTIONS: dict[str, ActionSpec] = {
"tables": ActionSpec(
"列出已配置 MoviePilot 数据库中的所有可见表。",
"safe_read",
),
"schema": ActionSpec(
"显示指定数据库表的列及是否允许空值。",
"safe_read",
arguments=(
ArgumentSpec(
"table_name",
"string",
"tables 动作返回的精确数据库表名。",
required=True,
),
),
),
"query": ActionSpec(
"执行一条有结果数量限制的 SQL 查询,可显式启用写入模式与凭据表读取。",
"safe_read",
arguments=(
ArgumentSpec("sql", "string", "单条 SQL 语句,与 file 互斥。"),
ArgumentSpec("file", "string", "本地 SQL 文件路径,与 sql 互斥。"),
ArgumentSpec(
"limit",
"integer",
"为不含 LIMIT 的普通 SELECT 追加的返回行数上限。",
default=100,
has_default=True,
),
ArgumentSpec(
"write",
"boolean",
"是否允许 query 执行写入语句;只有获得明确授权时才可设为 true。",
default=False,
has_default=True,
),
ArgumentSpec(
"show_secrets",
"boolean",
"是否允许引用集中存放实例凭据的 systemconfig 表;"
"只有用户明确要求核对配置值本身时才可设为 true,普通统计不得开启。",
default=False,
has_default=True,
),
),
argument_rules=(
"sql 和 file 必须且只能提供一项。",
"仅允许 SELECT、WITH 和 EXPLAIN;write=true 时才允许写入语句。",
"引用 systemconfig 时必须显式提供 show_secrets=true。",
),
),
"write": ActionSpec(
"执行一条经明确授权的 SQL 数据写入或结构变更语句。",
"destructive_write",
arguments=(
ArgumentSpec("sql", "string", "单条数据写入或结构变更语句,与 file 互斥。"),
ArgumentSpec("file", "string", "本地 SQL 文件路径,与 sql 互斥。"),
ArgumentSpec(
"show_secrets",
"boolean",
"是否允许引用集中存放实例凭据的 systemconfig 表;"
"修复配置行等确需触碰该表的操作才设为 true。",
default=False,
has_default=True,
),
),
argument_rules=(
"sql 和 file 必须且只能提供一项。",
"拒绝在一次调用中执行多条 SQL 语句。",
"引用 systemconfig 时必须显式提供 show_secrets=true。",
),
),
}
def _ensure_project_import() -> None:
"""
确保脚本可以从任意工作目录导入 MoviePilot 项目模块。
"""
if PROJECT_ROOT is None:
raise RuntimeError(
"无法定位 MoviePilot 程序目录;请在程序目录执行,或设置 MOVIEPILOT_ROOT="
"<程序目录> 后重试。"
)
project_path = str(PROJECT_ROOT)
if project_path not in sys.path:
sys.path.insert(0, project_path)
def _load_settings() -> Any:
"""
读取 MoviePilot 运行配置。
"""
_ensure_project_import()
# 先定位项目并补充导入路径,再读取配置,保证脚本可从任意工作目录启动。
from app.runtime.config import settings # pylint: disable=import-outside-toplevel
return settings
def _sync_postgres_url(url: str) -> str:
"""
返回同步引擎可用的 PostgreSQL URL。
SQLAlchemy 对 `postgresql://` 默认使用 psycopg2,而 v3 镜像只内置了
psycopg 3(`psycopg`)。检测到 psycopg 3 时显式指定驱动,避免
`ModuleNotFoundError: No module named 'psycopg2'`;没有 psycopg 3 的环境
保持原行为,URL 已显式带驱动时也不做改动。
"""
if "+" in url.split("://", 1)[0]:
return url
try:
import psycopg # noqa: F401 pylint: disable=import-outside-toplevel,unused-import
except ImportError:
return url
return url.replace("postgresql://", "postgresql+psycopg://", 1)
def _build_engine() -> Engine:
"""
根据 MoviePilot 配置创建同步数据库引擎。
"""
settings = _load_settings()
if str(settings.DB_TYPE).lower() == "postgresql":
return create_engine(
_sync_postgres_url(settings.DB_POSTGRESQL_URL()),
pool_pre_ping=settings.DB_POOL_PRE_PING,
pool_recycle=settings.DB_POOL_RECYCLE,
)
return create_engine(
f"sqlite:///{settings.CONFIG_PATH}/user.db",
pool_pre_ping=settings.DB_POOL_PRE_PING,
pool_recycle=settings.DB_POOL_RECYCLE,
connect_args={"timeout": settings.DB_TIMEOUT},
)
def _normalize_sql(sql: str) -> str:
"""
去除 SQL 首尾空白及注释前唯一的语句终止符,保留字面值内容。
"""
sql = sql.strip()
code = _masked_sql(sql)
if code is not None and code.count(";") == 1 and code.rstrip().endswith(";"):
position = code.rfind(";")
return sql[:position] + sql[position + 1:]
return sql
def _quoted_end(sql: str, start: int, quote: str, *, escaped: bool = False) -> Optional[int]:
"""
寻找带成对转义引号的词法项末尾;仅 PostgreSQL E 字符串解析反斜杠。
"""
index = start + 1
closing = "]" if quote == "[" else quote
while index < len(sql):
if escaped and sql[index] == "\\":
index += 2
elif sql[index] == closing:
if quote != "[" and sql[index:index + 2] == closing * 2:
index += 2
else:
return index + 1
else:
index += 1
return None
def _block_comment_end(sql: str, start: int) -> Optional[int]:
"""
定位块注释末尾,同时支持 PostgreSQL 的嵌套注释。
"""
depth = 1
index = start + 2
while index < len(sql) and depth:
if sql[index:index + 2] == "/*":
depth += 1
index += 2
elif sql[index:index + 2] == "*/":
depth -= 1
index += 2
else:
index += 1
return index if depth == 0 else None
def _sql_literal_end(sql: str, index: int) -> Optional[int]:
"""
返回引号或美元引号项的结束位置;不是词法项时返回当前位置。
"""
char = sql[index]
if char in "'\"`[":
escaped = char == "'" and index > 0 and sql[index - 1] in "eE" and (
index < 2 or not (sql[index - 2].isalnum() or sql[index - 2] == "_")
)
return _quoted_end(sql, index, char, escaped=escaped)
if char == "$":
match = re.match(r"\$(?:[A-Za-z_][A-Za-z_0-9]*)?\$", sql[index:])
if match:
end = sql.find(match.group(), index + len(match.group()))
return end + len(match.group()) if end >= 0 else None
return index
def _masked_sql(sql: str, *, reveal_identifiers: bool = False) -> Optional[str]:
"""
用等长空白遮蔽字面值、标识符与注释,未闭合结构按无效 SQL 拒绝。
:param sql: 待分析的 SQL 文本
:param reveal_identifiers: 是否只遮蔽标识符定界符以保留表名本体,用于识别引号写法
"""
code = list(sql)
index = 0
while index < len(sql):
if sql[index:index + 2] == "--":
end = sql.find("\n", index)
end = len(sql) if end < 0 else end
elif sql[index:index + 2] == "/*":
end = _block_comment_end(sql, index)
else:
end = _sql_literal_end(sql, index)
if end is None:
return None
if end > index:
if reveal_identifiers and sql[index] in _IDENTIFIER_QUOTES:
code[index] = " "
code[end - 1] = " "
else:
code[index:end] = " " * (end - index)
index = end
else:
index += 1
return "".join(code)
def _secret_table_names(sql: str) -> set[str]:
"""
返回语句实际引用的凭据配置表名;引号包裹与模式限定写法都会被识别。
"""
code = _masked_sql(sql, reveal_identifiers=True)
if code is None:
return set()
return {word.lower() for word in _STATEMENT_WORD_RE.findall(code)} & _SECRET_TABLE_NAMES
def _contains_multiple_statements(sql: str) -> bool:
"""
判断 SQL 是否包含多语句分隔符。
"""
code = _masked_sql(sql)
return code is None or ";" in code.rstrip().removesuffix(";")
def _is_write_statement(sql: str) -> bool:
"""
判断 SQL 是否为写操作或结构变更操作。
"""
code = _masked_sql(sql)
return code is not None and bool(WRITE_STATEMENT_RE.match(code))
def _is_supported_statement(sql: str, allow_write: bool, *, show_secrets: bool = False) -> bool:
"""
判断 SQL 是否在当前权限模式下允许执行。
:param sql: 待校验的 SQL 语句
:param allow_write: 是否允许写操作或结构变更操作
:param show_secrets: 是否允许引用集中存放实例凭据的 systemconfig 表
"""
if _contains_multiple_statements(sql):
return False
code = _masked_sql(sql)
if code is None or not code.strip().strip(";"):
return False
if not show_secrets and _secret_table_names(sql):
return False
if allow_write:
return True
return bool(SELECT_STATEMENT_RE.match(code)) and not WRITE_KEYWORD_RE.search(code)
def _append_limit(sql: str, limit: int) -> str:
"""
为普通 SELECT 查询追加默认 LIMIT,避免输出过大。
"""
if limit <= 0:
return sql
lowered = (_masked_sql(sql) or "").lower()
if not lowered.lstrip().startswith("select"):
return sql
if re.search(r"\blimit\s+\d+\b", lowered):
return sql
return f"{sql}\nLIMIT {limit}"
def _row_to_dict(row: Any) -> dict[str, Any]:
"""
将 SQLAlchemy 行对象转为普通字典。
"""
return dict(row._mapping)
def _print_json(payload: Any) -> None:
"""
输出 JSON 结果。
"""
print(json.dumps(payload, ensure_ascii=False, indent=2, default=str))
def list_tables() -> int:
"""
列出当前数据库中的数据表。
"""
engine = _build_engine()
inspector = inspect(engine)
_print_json({"tables": sorted(inspector.get_table_names())})
return 0
def show_schema(table_name: str) -> int:
"""
显示指定数据表的字段结构。
"""
engine = _build_engine()
inspector = inspect(engine)
columns = [
{
"name": column.get("name"),
"type": str(column.get("type")),
"nullable": column.get("nullable"),
"default": column.get("default"),
"primary_key": bool(column.get("primary_key")),
}
for column in inspector.get_columns(table_name)
]
_print_json({"table": table_name, "columns": columns})
return 0
@contextmanager
def _protect_sqlite_configuration(connection: Connection, *, show_secrets: bool) -> Iterator[None]:
"""
在脚本独占的 SQLite 连接上按真实表访问阻断凭据读写,退出时解除授权回调。
SQLite 允许单引号表名及经视图读取;文本预检不能可靠识别这些关系。
PostgreSQL 的标识符仍由预检处理,不向其它数据库安装 SQLite 回调。
:param connection: 当前脚本事务独占的连接,不带其它调用方的授权回调
:param show_secrets: 明确授权时保持原有数据库访问能力
:raises ValueError: 未授权语句实际读写 systemconfig
"""
if show_secrets or getattr(getattr(connection, "dialect", None), "name", None) != "sqlite":
yield
return
driver = connection.connection.driver_connection
blocked: set[str] = set()
def authorize(action: int, first: str | None, second: str | None, _database: str, _source: str) -> int:
"""
按 SQLite 编译器回报的实际表名判断,覆盖查询、视图展开及数据或结构写入。
:param action: SQLite 编译器提供的访问操作编号
:param first: 多数访问操作的表名;ALTER TABLE 时为数据库名
:param second: 列名等附加信息;ALTER TABLE 时为表名
:param _database: SQLite 提供的数据库别名,本回调不按数据库名豁免
:param _source: 触发访问的视图或触发器名称,本回调仍检查底层表
:return: 未授权的凭据表访问返回 SQLITE_DENY,其余返回 SQLITE_OK
"""
table = second if action == sqlite3.SQLITE_ALTER_TABLE else first
protected_actions = {
sqlite3.SQLITE_READ, sqlite3.SQLITE_INSERT, sqlite3.SQLITE_UPDATE,
sqlite3.SQLITE_DELETE, sqlite3.SQLITE_DROP_TABLE, sqlite3.SQLITE_ALTER_TABLE,
}
if action in protected_actions and table and table.lower() in _SECRET_TABLE_NAMES:
blocked.add(table.lower())
return sqlite3.SQLITE_DENY
return sqlite3.SQLITE_OK
driver.set_authorizer(authorize)
try:
yield
except SQLAlchemyError as error:
if blocked:
raise ValueError(
f"statements referencing {', '.join(sorted(blocked))} require --show-secrets"
) from error
raise
finally:
driver.set_authorizer(None)
def run_query(sql: str, *, limit: int = 100, allow_write: bool = False, show_secrets: bool = False) -> int:
"""
执行 SQL 语句并输出 JSON 结果。
:param sql: 要执行的 SQL 语句
:param limit: 查询语句默认追加的最大行数
:param allow_write: 是否允许写操作或结构变更操作
:param show_secrets: 是否允许引用集中存放实例凭据的 systemconfig 表
:return: 进程退出码
"""
normalized_sql = _normalize_sql(sql)
if not normalized_sql:
print("Error: SQL is empty", file=sys.stderr)
return 1
if not _is_supported_statement(normalized_sql, allow_write, show_secrets=show_secrets):
blocked_tables = sorted(_secret_table_names(normalized_sql))
if blocked_tables:
print(
f"Error: statements referencing {', '.join(blocked_tables)} require --show-secrets",
file=sys.stderr,
)
else:
print("Error: write statements require --write", file=sys.stderr)
return 1
statement_sql = _append_limit(normalized_sql, limit)
engine = _build_engine()
try:
with engine.begin() as connection:
with _protect_sqlite_configuration(connection, show_secrets=show_secrets):
result = connection.execute(text(statement_sql))
if result.returns_rows:
rows = [_row_to_dict(row) for row in result.fetchall()]
_print_json({"rows": rows, "row_count": len(rows)})
else:
_print_json({"row_count": result.rowcount})
except (SQLAlchemyError, ValueError) as err:
print(f"Error: {err}", file=sys.stderr)
return 1
return 0
def _read_sql(sql: Optional[str], sql_file: Optional[str]) -> str:
"""
从参数或文件读取 SQL 文本。
"""
if sql:
return sql
if sql_file:
return Path(sql_file).read_text(encoding="utf-8")
if not sys.stdin.isatty():
return sys.stdin.read()
return ""
def _build_parser() -> argparse.ArgumentParser:
"""
构建命令行参数解析器。
"""
parser = argparse.ArgumentParser(description="MoviePilot 数据库操作工具")
subparsers = parser.add_subparsers(dest="command")
query_parser = subparsers.add_parser("query", help="执行一条 SQL 语句")
query_parser.add_argument("sql", nargs="?", help="SQL 语句")
query_parser.add_argument("--file", dest="sql_file", help="从文件读取 SQL")
query_parser.add_argument("--limit", type=int, default=100, help="SELECT 默认返回行数上限")
query_parser.add_argument("--write", action="store_true", help="允许写入语句")
query_parser.add_argument(
"--show-secrets",
action="store_true",
dest="show_secrets",
help="允许引用集中存放实例凭据的 systemconfig 表",
)
write_parser = subparsers.add_parser("write", help="执行一条写入语句")
write_parser.add_argument("sql", nargs="?", help="SQL 语句")
write_parser.add_argument("--file", dest="sql_file", help="从文件读取 SQL")
write_parser.add_argument(
"--show-secrets",
action="store_true",
dest="show_secrets",
help="允许引用集中存放实例凭据的 systemconfig 表",
)
subparsers.add_parser("tables", help="列出数据库表")
schema_parser = subparsers.add_parser("schema", help="显示表结构")
schema_parser.add_argument("table_name", help="数据库表名")
return parser
def main() -> int:
"""
执行命令行入口。
"""
parser = _build_parser()
args = parser.parse_args()
if args.command == "tables":
return list_tables()
if args.command == "schema":
return show_schema(args.table_name)
if args.command == "query":
sql = _read_sql(args.sql, args.sql_file)
return run_query(
sql,
limit=args.limit,
allow_write=args.write,
show_secrets=args.show_secrets,
)
if args.command == "write":
sql = _read_sql(args.sql, args.sql_file)
return run_query(sql, limit=0, allow_write=True, show_secrets=args.show_secrets)
parser.print_help()
return 0
if __name__ == "__main__":
sys.exit(main())