- 新增图像生成接口,支持试用、积分和自定义API Key模式 - 实现生成图片结果异步上传至MinIO存储,带重试机制 - 优化积分预扣除和异常退还逻辑,保障用户积分准确 - 添加获取生成历史记录接口,支持时间范围和分页 - 提供本地字典配置接口,支持模型、比例、提示模板和尺寸 - 实现图片批量上传接口,支持S3兼容对象存储 feat(admin): 增加管理员角色管理与权限分配接口 - 实现角色列表查询、角色创建、更新及删除功能 - 增加权限列表查询接口 - 实现用户角色分配接口,便于统一管理用户权限 - 增加系统字典增删查改接口,支持分类过滤和排序 - 权限控制全面覆盖管理接口,保证安全访问 feat(auth): 完善用户登录注册及权限相关接口与页面 - 实现手机号验证码发送及校验功能,保障注册安全 - 支持手机号注册、登录及退出接口,集成日志记录 - 增加修改密码功能,验证原密码后更新 - 提供动态导航菜单接口,基于权限展示不同菜单 - 实现管理界面路由及日志、角色、字典管理页面访问权限控制 - 添加系统日志查询接口,支持关键词和等级筛选 feat(app): 初始化Flask应用并配置蓝图与数据库 - 创建应用程序工厂,加载配置,初始化数据库和Redis客户端 - 注册认证、API及管理员蓝图,整合路由 - 根路由渲染主页模板 - 应用上下文中自动创建数据库表,保证运行环境准备完毕 feat(database): 提供数据库创建与迁移支持脚本 - 新增数据库创建脚本,支持自动检测是否已存在 - 添加数据库表初始化脚本,支持创建和删除所有表 - 实现RBAC权限初始化,包含基础权限和角色创建 - 新增字段手动修复脚本,添加用户API Key和积分字段 - 强制迁移脚本支持清理连接和修复表结构,初始化默认数据及角色分配 feat(config): 新增系统配置参数 - 配置数据库、Redis、Session和MinIO相关参数 - 添加AI接口地址及试用Key配置 - 集成阿里云短信服务配置及开发模式相关参数 feat(extensions): 初始化数据库、Redis和MinIO客户端 - 创建全局SQLAlchemy数据库实例和Redis客户端 - 配置基于boto3的MinIO兼容S3客户端 chore(logs): 添加示例系统日志文件 - 记录用户请求、验证码发送成功与失败的日志信息
203 lines
7.7 KiB
Python
203 lines
7.7 KiB
Python
from __future__ import annotations
|
|
|
|
import re
|
|
import typing as t
|
|
from dataclasses import dataclass
|
|
from dataclasses import field
|
|
|
|
from .converters import ValidationError
|
|
from .exceptions import NoMatch
|
|
from .exceptions import RequestAliasRedirect
|
|
from .exceptions import RequestPath
|
|
from .rules import Rule
|
|
from .rules import RulePart
|
|
|
|
|
|
class SlashRequired(Exception):
|
|
pass
|
|
|
|
|
|
@dataclass
|
|
class State:
|
|
"""A representation of a rule state.
|
|
|
|
This includes the *rules* that correspond to the state and the
|
|
possible *static* and *dynamic* transitions to the next state.
|
|
"""
|
|
|
|
dynamic: list[tuple[RulePart, State]] = field(default_factory=list)
|
|
rules: list[Rule] = field(default_factory=list)
|
|
static: dict[str, State] = field(default_factory=dict)
|
|
|
|
|
|
class StateMachineMatcher:
|
|
def __init__(self, merge_slashes: bool) -> None:
|
|
self._root = State()
|
|
self.merge_slashes = merge_slashes
|
|
|
|
def add(self, rule: Rule) -> None:
|
|
state = self._root
|
|
for part in rule._parts:
|
|
if part.static:
|
|
state.static.setdefault(part.content, State())
|
|
state = state.static[part.content]
|
|
else:
|
|
for test_part, new_state in state.dynamic:
|
|
if test_part == part:
|
|
state = new_state
|
|
break
|
|
else:
|
|
new_state = State()
|
|
state.dynamic.append((part, new_state))
|
|
state = new_state
|
|
state.rules.append(rule)
|
|
|
|
def update(self) -> None:
|
|
# For every state the dynamic transitions should be sorted by
|
|
# the weight of the transition
|
|
state = self._root
|
|
|
|
def _update_state(state: State) -> None:
|
|
state.dynamic.sort(key=lambda entry: entry[0].weight)
|
|
for new_state in state.static.values():
|
|
_update_state(new_state)
|
|
for _, new_state in state.dynamic:
|
|
_update_state(new_state)
|
|
|
|
_update_state(state)
|
|
|
|
def match(
|
|
self, domain: str, path: str, method: str, websocket: bool
|
|
) -> tuple[Rule, t.MutableMapping[str, t.Any]]:
|
|
# To match to a rule we need to start at the root state and
|
|
# try to follow the transitions until we find a match, or find
|
|
# there is no transition to follow.
|
|
|
|
have_match_for = set()
|
|
websocket_mismatch = False
|
|
|
|
def _match(
|
|
state: State, parts: list[str], values: list[str]
|
|
) -> tuple[Rule, list[str]] | None:
|
|
# This function is meant to be called recursively, and will attempt
|
|
# to match the head part to the state's transitions.
|
|
nonlocal have_match_for, websocket_mismatch
|
|
|
|
# The base case is when all parts have been matched via
|
|
# transitions. Hence if there is a rule with methods &
|
|
# websocket that work return it and the dynamic values
|
|
# extracted.
|
|
if parts == []:
|
|
for rule in state.rules:
|
|
if rule.methods is not None and method not in rule.methods:
|
|
have_match_for.update(rule.methods)
|
|
elif rule.websocket != websocket:
|
|
websocket_mismatch = True
|
|
else:
|
|
return rule, values
|
|
|
|
# Test if there is a match with this path with a
|
|
# trailing slash, if so raise an exception to report
|
|
# that matching is possible with an additional slash
|
|
if "" in state.static:
|
|
for rule in state.static[""].rules:
|
|
if websocket == rule.websocket and (
|
|
rule.methods is None or method in rule.methods
|
|
):
|
|
if rule.strict_slashes:
|
|
raise SlashRequired()
|
|
else:
|
|
return rule, values
|
|
return None
|
|
|
|
part = parts[0]
|
|
# To match this part try the static transitions first
|
|
if part in state.static:
|
|
rv = _match(state.static[part], parts[1:], values)
|
|
if rv is not None:
|
|
return rv
|
|
# No match via the static transitions, so try the dynamic
|
|
# ones.
|
|
for test_part, new_state in state.dynamic:
|
|
target = part
|
|
remaining = parts[1:]
|
|
# A final part indicates a transition that always
|
|
# consumes the remaining parts i.e. transitions to a
|
|
# final state.
|
|
if test_part.final:
|
|
target = "/".join(parts)
|
|
remaining = []
|
|
match = re.compile(test_part.content).match(target)
|
|
if match is not None:
|
|
if test_part.suffixed:
|
|
# If a part_isolating=False part has a slash suffix, remove the
|
|
# suffix from the match and check for the slash redirect next.
|
|
suffix = match.groups()[-1]
|
|
if suffix == "/":
|
|
remaining = [""]
|
|
|
|
converter_groups = sorted(
|
|
match.groupdict().items(), key=lambda entry: entry[0]
|
|
)
|
|
groups = [
|
|
value
|
|
for key, value in converter_groups
|
|
if key[:11] == "__werkzeug_"
|
|
]
|
|
rv = _match(new_state, remaining, values + groups)
|
|
if rv is not None:
|
|
return rv
|
|
|
|
# If there is no match and the only part left is a
|
|
# trailing slash ("") consider rules that aren't
|
|
# strict-slashes as these should match if there is a final
|
|
# slash part.
|
|
if parts == [""]:
|
|
for rule in state.rules:
|
|
if rule.strict_slashes:
|
|
continue
|
|
if rule.methods is not None and method not in rule.methods:
|
|
have_match_for.update(rule.methods)
|
|
elif rule.websocket != websocket:
|
|
websocket_mismatch = True
|
|
else:
|
|
return rule, values
|
|
|
|
return None
|
|
|
|
try:
|
|
rv = _match(self._root, [domain, *path.split("/")], [])
|
|
except SlashRequired:
|
|
raise RequestPath(f"{path}/") from None
|
|
|
|
if self.merge_slashes and rv is None:
|
|
# Try to match again, but with slashes merged
|
|
path = re.sub("/{2,}?", "/", path)
|
|
try:
|
|
rv = _match(self._root, [domain, *path.split("/")], [])
|
|
except SlashRequired:
|
|
raise RequestPath(f"{path}/") from None
|
|
if rv is None or rv[0].merge_slashes is False:
|
|
raise NoMatch(have_match_for, websocket_mismatch)
|
|
else:
|
|
raise RequestPath(f"{path}")
|
|
elif rv is not None:
|
|
rule, values = rv
|
|
|
|
result = {}
|
|
for name, value in zip(rule._converters.keys(), values):
|
|
try:
|
|
value = rule._converters[name].to_python(value)
|
|
except ValidationError:
|
|
raise NoMatch(have_match_for, websocket_mismatch) from None
|
|
result[str(name)] = value
|
|
if rule.defaults:
|
|
result.update(rule.defaults)
|
|
|
|
if rule.alias and rule.map.redirect_defaults:
|
|
raise RequestAliasRedirect(result, rule.endpoint)
|
|
|
|
return rule, result
|
|
|
|
raise NoMatch(have_match_for, websocket_mismatch)
|