lesscode-flask 0.2.62__tar.gz → 0.2.89__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/PKG-INFO +1 -1
- {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/__init__.py +15 -1
- {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/app.py +72 -3
- {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/db/__init__.py +9 -4
- {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/log/access_log_handler.py +8 -1
- {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/model/access_log.py +3 -0
- {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/setting/__init__.py +24 -0
- {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/setup/__init__.py +298 -8
- {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/utils/limit/req_count/count_limiter_handler.py +16 -16
- lesscode_flask-0.2.89/lesscode_flask/utils/sign/__init__.py +3 -0
- lesscode_flask-0.2.89/lesscode_flask/utils/sign/signature.py +160 -0
- {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/utils/swagger/swagger_util.py +113 -5
- lesscode_flask-0.2.89/lesscode_flask/utils/task/__init__.py +0 -0
- {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask.egg-info/PKG-INFO +1 -1
- {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask.egg-info/SOURCES.txt +3 -0
- {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/README.md +0 -0
- {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/db/datasource.py +0 -0
- {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/db/executor.py +0 -0
- {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/export_data/__init__.py +0 -0
- {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/export_data/data_download_handler.py +0 -0
- {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/model/base_model.py +0 -0
- {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/model/parameterized_query.py +0 -0
- {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/model/resource_param_template.py +0 -0
- {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/model/response_result.py +0 -0
- {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/model/user.py +0 -0
- {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/model/user_limit_policy.py +0 -0
- {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/service/access_log_service.py +0 -0
- {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/service/base_service.py +0 -0
- {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/service/resource_param_template_service.py +0 -0
- {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/signals.py +0 -0
- {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/static/swagger.py +0 -0
- {lesscode_flask-0.2.62/lesscode_flask/utils/decorator → lesscode_flask-0.2.89/lesscode_flask/utils}/__init__.py +0 -0
- {lesscode_flask-0.2.62/lesscode_flask/utils/task → lesscode_flask-0.2.89/lesscode_flask/utils/decorator}/__init__.py +0 -0
- {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/utils/decorator/cache.py +0 -0
- {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/utils/decorator/sql_injection.py +0 -0
- {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/utils/decorator/swagger.py +0 -0
- {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/utils/dify_utils.py +0 -0
- {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/utils/file/file_exporter.py +0 -0
- {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/utils/file/file_utils.py +0 -0
- {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/utils/fs_util.py +0 -0
- {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/utils/helpers.py +0 -0
- {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/utils/json/NotSortJSONProvider.py +0 -0
- {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/utils/limit/__init__.py +0 -0
- {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/utils/limit/consecutive/consecutive_limiter_handler.py +0 -0
- {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/utils/limit/consecutive/redis_consecutive_limiter.py +0 -0
- {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/utils/limit/limit_util.py +0 -0
- {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/utils/limit/req/rate_limiter_handler.py +0 -0
- {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/utils/limit/req/redis_rate_limiter.py +0 -0
- {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/utils/limit/req_count/redis_count_limiter.py +0 -0
- {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/utils/oss/__init__.py +0 -0
- {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/utils/oss/aliyun_oss.py +0 -0
- {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/utils/oss/ks3_oss.py +0 -0
- {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/utils/oss/minio_oss.py +0 -0
- {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/utils/redis/redis_helper.py +0 -0
- {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/utils/request/request.py +0 -0
- {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/utils/swagger/swagger_template.py +0 -0
- {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/utils/task/task_helper.py +0 -0
- {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/utils/thread/thread_utils.py +0 -0
- {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/wsgi.py +0 -0
- {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask.egg-info/dependency_links.txt +0 -0
- {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask.egg-info/requires.txt +0 -0
- {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask.egg-info/top_level.txt +0 -0
- {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/redash/query_runner/__init__.py +0 -0
- {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/redash/query_runner/clickhouse.py +0 -0
- {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/redash/query_runner/elasticsearch.py +0 -0
- {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/redash/query_runner/kingbase.py +0 -0
- {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/redash/query_runner/mysql.py +0 -0
- {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/redash/query_runner/pg.py +0 -0
- {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/redash/settings/__init__.py +0 -0
- {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/redash/settings/helpers.py +0 -0
- {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/redash/utils/__init__.py +0 -0
- {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/redash/utils/requests_session.py +0 -0
- {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/setup.cfg +0 -0
- {lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/setup.py +0 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
__version__ = "0.2.
|
|
1
|
+
__version__ = "0.2.89"
|
|
2
2
|
|
|
3
3
|
import functools
|
|
4
4
|
import logging
|
|
@@ -16,6 +16,20 @@ class SQ_Blueprint(Blueprint):
|
|
|
16
16
|
if not kwargs.get("import_name"):
|
|
17
17
|
kwargs["import_name"] = __name__
|
|
18
18
|
super().__init__(name=name, url_prefix=url_prefix, **kwargs)
|
|
19
|
+
# 记录蓝图内显式注册的“路径 + 方法”,供初始化阶段做冲突过滤。
|
|
20
|
+
self._route_method_keys = set()
|
|
21
|
+
|
|
22
|
+
def add_url_rule(self, rule, endpoint=None, view_func=None, provide_automatic_options=None, **options):
|
|
23
|
+
methods = options.get("methods")
|
|
24
|
+
if methods is None:
|
|
25
|
+
methods = ["GET"]
|
|
26
|
+
elif isinstance(methods, str):
|
|
27
|
+
methods = [methods]
|
|
28
|
+
methods = [str(item).upper() for item in methods if item]
|
|
29
|
+
for method in methods:
|
|
30
|
+
self._route_method_keys.add(f"{rule}|{method}")
|
|
31
|
+
return super().add_url_rule(rule, endpoint=endpoint, view_func=view_func,
|
|
32
|
+
provide_automatic_options=provide_automatic_options, **options)
|
|
19
33
|
|
|
20
34
|
def decorator_handler(
|
|
21
35
|
self,
|
|
@@ -18,7 +18,7 @@ from werkzeug.middleware.proxy_fix import ProxyFix
|
|
|
18
18
|
from lesscode_flask.model.response_result import ResponseResult
|
|
19
19
|
from lesscode_flask.setup import setup_blueprint, setup_logging, setup_query_runner, setup_swagger, setup_sql_alchemy, \
|
|
20
20
|
setup_redis, setup_login_manager, setup_resource_register, setup_data_download, setup_scheduler, \
|
|
21
|
-
setup_common_resource, setup_task
|
|
21
|
+
setup_common_resource, setup_task, setup_api_blueprint, prepare_api_blueprint_routes
|
|
22
22
|
from lesscode_flask.signals import app_runed
|
|
23
23
|
from lesscode_flask.utils.decorator.sql_injection import contains_sql_injection
|
|
24
24
|
from lesscode_flask.utils.helpers import inject_args, generate_uuid, app_config
|
|
@@ -31,6 +31,7 @@ from lesscode_flask.utils.limit.req.rate_limiter_handler import RateLimitHandler
|
|
|
31
31
|
from lesscode_flask.utils.limit.req_count.count_limiter_handler import CountLimitHandler
|
|
32
32
|
from lesscode_flask.utils.limit.req_count.redis_count_limiter import RedisCountLimiter
|
|
33
33
|
from lesscode_flask.utils.redis.redis_helper import RedisHelper
|
|
34
|
+
from lesscode_flask.utils.sign import verify_request_signature
|
|
34
35
|
from lesscode_flask.utils.swagger.swagger_util import generate_openapi_spec
|
|
35
36
|
|
|
36
37
|
# import collections.abc as cabc
|
|
@@ -74,6 +75,7 @@ class Lesscoder(Flask):
|
|
|
74
75
|
self.consecutiveAccessLimiter = RedisConsecutiveAccessLimiter(self.config.get("REDIS_LIMIT_KEY", "redis"))
|
|
75
76
|
|
|
76
77
|
def preprocess_request(self) -> ft.ResponseReturnValue | None:
|
|
78
|
+
verify_request_signature(self)
|
|
77
79
|
v = super(Lesscoder, self).preprocess_request()
|
|
78
80
|
user_limit_policy = None
|
|
79
81
|
if hasattr(self, 'countLimiter') and self.countLimiter:
|
|
@@ -164,7 +166,9 @@ class Lesscoder(Flask):
|
|
|
164
166
|
params_dict.update(view_args)
|
|
165
167
|
SQL_INJECTION_ENABLE = self.config.get("SQL_INJECTION_ENABLE", False)
|
|
166
168
|
if SQL_INJECTION_ENABLE:
|
|
167
|
-
|
|
169
|
+
sql_injection_white_list = self.config.get("SQL_INJECTION_WHITE_LIST", []) or []
|
|
170
|
+
is_sql_injection_white_path = any(req.path.startswith(path) for path in sql_injection_white_list)
|
|
171
|
+
if not is_sql_injection_white_path and contains_sql_injection(params_dict):
|
|
168
172
|
ResponseResult.fail("参数包含非法字符,请调整后重试!", status_code="403", http_code="403")
|
|
169
173
|
# 调用处理函数执行请求处理
|
|
170
174
|
result = self.ensure_sync(func)(**params_dict)
|
|
@@ -183,15 +187,80 @@ class Lesscoder(Flask):
|
|
|
183
187
|
return result
|
|
184
188
|
|
|
185
189
|
def setup(self):
|
|
190
|
+
def _collect_blueprint_route_method_keys(blueprint_map):
|
|
191
|
+
# 从项目内已定义的蓝图中提取“完整路径 + 方法”,用于后续排除同名自动接口。
|
|
192
|
+
route_method_keys = set()
|
|
193
|
+
for blueprint in blueprint_map.values():
|
|
194
|
+
prefix = (getattr(blueprint, "url_prefix", "") or "").rstrip("/")
|
|
195
|
+
# 优先使用 SQ_Blueprint 记录的路由元数据,避免依赖 Flask 闭包结构。
|
|
196
|
+
blueprint_keys = getattr(blueprint, "_route_method_keys", None)
|
|
197
|
+
if isinstance(blueprint_keys, set) and blueprint_keys:
|
|
198
|
+
for key in blueprint_keys:
|
|
199
|
+
if "|" not in key:
|
|
200
|
+
continue
|
|
201
|
+
route, method = key.rsplit("|", 1)
|
|
202
|
+
full_route = f"{prefix}{route}" if prefix else route
|
|
203
|
+
if not full_route.startswith("/"):
|
|
204
|
+
full_route = f"/{full_route}"
|
|
205
|
+
full_route = full_route.replace("//", "/")
|
|
206
|
+
route_method_keys.add(f"{full_route}|{str(method).upper()}")
|
|
207
|
+
continue
|
|
208
|
+
|
|
209
|
+
# 兜底方案:兼容普通 Blueprint 或未记录元数据的蓝图。
|
|
210
|
+
deferred_functions = getattr(blueprint, "deferred_functions", []) or []
|
|
211
|
+
for deferred in deferred_functions:
|
|
212
|
+
closure = getattr(deferred, "__closure__", None) or []
|
|
213
|
+
route = None
|
|
214
|
+
methods = None
|
|
215
|
+
for cell in closure:
|
|
216
|
+
value = cell.cell_contents
|
|
217
|
+
if isinstance(value, str) and value.startswith("/"):
|
|
218
|
+
route = value
|
|
219
|
+
elif isinstance(value, dict) and isinstance(value.get("methods"), (list, tuple, set)):
|
|
220
|
+
methods = [str(item).upper() for item in value.get("methods") if item]
|
|
221
|
+
if not route:
|
|
222
|
+
continue
|
|
223
|
+
full_route = f"{prefix}{route}" if prefix else route
|
|
224
|
+
if not full_route.startswith("/"):
|
|
225
|
+
full_route = f"/{full_route}"
|
|
226
|
+
full_route = full_route.replace("//", "/")
|
|
227
|
+
methods = methods or ["GET"]
|
|
228
|
+
for method in methods:
|
|
229
|
+
route_method_keys.add(f"{full_route}|{method}")
|
|
230
|
+
return route_method_keys
|
|
231
|
+
|
|
186
232
|
setup_logging(self)
|
|
233
|
+
# 1) 先从能力平台准备自动蓝图路由计划(仅组织数据,不立即注册)。
|
|
234
|
+
api_capability_server, api_route_prefix, api_route_list = prepare_api_blueprint_routes(self)
|
|
235
|
+
# 1) 先收集项目代码中的蓝图定义。
|
|
187
236
|
blueprint_map = setup_blueprint(self)
|
|
237
|
+
# 2) 用项目接口签名作为排除条件,确保同路径同方法时由项目接口覆盖自动接口。
|
|
238
|
+
project_route_method_keys = _collect_blueprint_route_method_keys(blueprint_map)
|
|
239
|
+
auto_blueprints = setup_api_blueprint(
|
|
240
|
+
self,
|
|
241
|
+
exclude_route_method_keys=project_route_method_keys,
|
|
242
|
+
existing_blueprint_names=set(blueprint_map.keys()),
|
|
243
|
+
route_list=api_route_list,
|
|
244
|
+
capability_server=api_capability_server,
|
|
245
|
+
route_prefix=api_route_prefix
|
|
246
|
+
)
|
|
188
247
|
setup_query_runner()
|
|
189
248
|
setup_swagger(self)
|
|
190
249
|
setup_sql_alchemy(self)
|
|
191
250
|
setup_redis(self)
|
|
192
251
|
setup_login_manager(self)
|
|
252
|
+
# 3) 先注册自动蓝图,再注册项目蓝图;同时自动蓝图已做冲突过滤,保证项目实现优先。
|
|
253
|
+
if auto_blueprints:
|
|
254
|
+
for auto_blueprint in auto_blueprints:
|
|
255
|
+
self.register_blueprint(
|
|
256
|
+
auto_blueprint,
|
|
257
|
+
name=getattr(auto_blueprint, "_registration_name", auto_blueprint.name)
|
|
258
|
+
)
|
|
193
259
|
for blueprint_name, blueprint in blueprint_map.items():
|
|
194
|
-
self.register_blueprint(
|
|
260
|
+
self.register_blueprint(
|
|
261
|
+
blueprint,
|
|
262
|
+
name=getattr(blueprint, "_registration_name", blueprint_name)
|
|
263
|
+
)
|
|
195
264
|
setup_common_resource(self)
|
|
196
265
|
setup_resource_register(self)
|
|
197
266
|
setup_data_download(self)
|
|
@@ -26,13 +26,13 @@ def execute_query(
|
|
|
26
26
|
query_text = query.query
|
|
27
27
|
# query_text = query_runner.apply_auto_limit(query.text, should_apply_auto_limit)
|
|
28
28
|
start_time = time.perf_counter()
|
|
29
|
-
data =
|
|
29
|
+
data = QueryExecutor(
|
|
30
30
|
query_text,
|
|
31
31
|
query_runner
|
|
32
32
|
).run()
|
|
33
33
|
end_time = time.perf_counter()
|
|
34
34
|
elapsed_time = end_time - start_time
|
|
35
|
-
logging.
|
|
35
|
+
logging.info(f"query_text:\n{query_text}\nquery_time: {elapsed_time:.6f}s")
|
|
36
36
|
return data
|
|
37
37
|
except Exception as e:
|
|
38
38
|
# models.db.session.rollback()
|
|
@@ -56,13 +56,18 @@ def execute(query_text, parameters, query_runner, should_apply_auto_limit=True):
|
|
|
56
56
|
query.apply(parameters)
|
|
57
57
|
query_text = query.query
|
|
58
58
|
# query_text = query_runner.apply_auto_limit(query.text, should_apply_auto_limit)
|
|
59
|
-
|
|
60
|
-
|
|
59
|
+
start_time = time.perf_counter()
|
|
60
|
+
data = QueryExecutor(
|
|
61
61
|
query_text,
|
|
62
62
|
query_runner
|
|
63
63
|
).exec()
|
|
64
|
+
end_time = time.perf_counter()
|
|
65
|
+
elapsed_time = end_time - start_time
|
|
66
|
+
logging.info(f"execute_text:\n{query_text}\nexecute_time: {elapsed_time:.6f}s")
|
|
67
|
+
return data
|
|
64
68
|
except Exception as e:
|
|
65
69
|
# models.db.session.rollback()
|
|
70
|
+
logging.error("error_execute_text:\n{}".format(query_text))
|
|
66
71
|
raise e
|
|
67
72
|
|
|
68
73
|
|
|
@@ -45,6 +45,12 @@ class AccessLogHandler(Handler):
|
|
|
45
45
|
app_key = request.headers.get("App-Key")
|
|
46
46
|
url = request.path
|
|
47
47
|
url_info_key = f"upms:url_info:{url}"
|
|
48
|
+
# 签名
|
|
49
|
+
signature = request.headers.get("signature") or request.headers.get('Signature')
|
|
50
|
+
# 随机数
|
|
51
|
+
nonce = request.headers.get("nonce") or request.headers.get('Nonce')
|
|
52
|
+
# 时间戳
|
|
53
|
+
timestamp = request.headers.get("timestamp") or request.headers.get('Timestamp')
|
|
48
54
|
|
|
49
55
|
resource_id = "-"
|
|
50
56
|
resource_label = url
|
|
@@ -66,7 +72,8 @@ class AccessLogHandler(Handler):
|
|
|
66
72
|
obj_id=current_user.id, type=current_user.type, client_id=client_id,
|
|
67
73
|
resource_id=resource_id, location=location, sub=current_user.sub,
|
|
68
74
|
resource_label=resource_label, url=url, referrer=referrer, client_ip=client_ip,
|
|
69
|
-
user_agent=user_agent_string, token=token, app_key=app_key,
|
|
75
|
+
user_agent=user_agent_string, token=token, app_key=app_key, signature=signature,
|
|
76
|
+
nonce=nonce, timestamp=timestamp, start_time=start_time,
|
|
70
77
|
end_time=end_time, duration=end_time - start_time, status_code=status_code,
|
|
71
78
|
params=params)
|
|
72
79
|
|
|
@@ -24,6 +24,9 @@ class AccessLog(BaseModel):
|
|
|
24
24
|
user_agent = Column(String(512), comment='客户端')
|
|
25
25
|
token = Column(String(64), comment='token')
|
|
26
26
|
app_key = Column(String(64), comment='请求的app_key')
|
|
27
|
+
signature = Column(String(64), comment='签名')
|
|
28
|
+
nonce = Column(String(64), comment='签名随机数')
|
|
29
|
+
timestamp = Column(String(64), comment='签名时间戳')
|
|
27
30
|
params = Column(JSONEncodedDict)
|
|
28
31
|
start_time = Column(DOUBLE, comment='开始时间')
|
|
29
32
|
end_time = Column(DOUBLE, comment='结束时间')
|
|
@@ -90,6 +90,22 @@ class BaseConfig:
|
|
|
90
90
|
AUTH_DEFAULT_ACCESS = 0
|
|
91
91
|
# 启用生成刷新token
|
|
92
92
|
OAUTH2_REFRESH_TOKEN_GENERATOR = True
|
|
93
|
+
# 请求签名校验开关(在token校验前执行)
|
|
94
|
+
SIGN_ENABLE: bool = False
|
|
95
|
+
# 固定签名密钥,建议在业务配置中覆盖
|
|
96
|
+
SIGN_SECRET: str = ""
|
|
97
|
+
# 签名有效时间窗口(秒)
|
|
98
|
+
SIGN_WINDOW_SEC: int = 300
|
|
99
|
+
# 防重放校验开关
|
|
100
|
+
SIGN_NONCE_ENABLE: bool = True
|
|
101
|
+
# nonce缓存时长(秒)
|
|
102
|
+
SIGN_NONCE_TTL_SEC: int = 300
|
|
103
|
+
# 签名白名单路径前缀
|
|
104
|
+
SIGN_WHITE_LIST: list = [SWAGGER_URL, SWAGGER_API_URL, f"{ROUTE_PREFIX}/oauth/token", f"{ROUTE_PREFIX}/oauth/captcha"]
|
|
105
|
+
# 签名IP白名单(支持单IP或CIDR,命中后跳过签名校验)
|
|
106
|
+
SIGN_IP_WHITE_LIST: list = []
|
|
107
|
+
# 忽略签名校验的方法
|
|
108
|
+
SIGN_IGNORE_METHODS: list = ["OPTIONS"]
|
|
93
109
|
# 是否启用限流频率验证
|
|
94
110
|
RATE_LIMIT_ENABLE: bool = False
|
|
95
111
|
|
|
@@ -132,6 +148,8 @@ class BaseConfig:
|
|
|
132
148
|
FS_OAM_SERVICE_URL = "https://oa.shangqi.com.cn"
|
|
133
149
|
# sql 注入验证器开关
|
|
134
150
|
SQL_INJECTION_ENABLE = False
|
|
151
|
+
# sql 注入检测白名单,命中路径前缀时跳过检测
|
|
152
|
+
SQL_INJECTION_WHITE_LIST: list = []
|
|
135
153
|
#
|
|
136
154
|
#
|
|
137
155
|
# # 外网地址
|
|
@@ -141,6 +159,9 @@ class BaseConfig:
|
|
|
141
159
|
#
|
|
142
160
|
# 数据服务
|
|
143
161
|
CAPABILITY_PLATFORM_SERVER: str = "http://127.0.0.1:8976"
|
|
162
|
+
# 转调能力平台时附加的自定义请求头(key/value 由业务配置覆盖)
|
|
163
|
+
DATA_SOURCE_KEY: str = "Data-Source-Id"
|
|
164
|
+
DATA_SOURCE_VALUE: str = ""
|
|
144
165
|
# # 权限服务地址
|
|
145
166
|
# OAUTH_SERVER: str = ""
|
|
146
167
|
# # 后端管理地址
|
|
@@ -165,6 +186,9 @@ class BaseConfig:
|
|
|
165
186
|
REGISTER_ENABLE = False
|
|
166
187
|
REGISTER_SERVER = "http://127.0.0.1:8976"
|
|
167
188
|
|
|
189
|
+
# 通过固定 API 拉取地址并自动注册蓝图(默认关闭)
|
|
190
|
+
AUTO_BLUEPRINT_ENABLE = False
|
|
191
|
+
|
|
168
192
|
# 本地环境
|
|
169
193
|
ENV = "local"
|
|
170
194
|
# 是否代理能力平台的公共接口
|
|
@@ -73,7 +73,7 @@ def setup_logging(app):
|
|
|
73
73
|
logging.getLogger().addHandler(access_log_handler)
|
|
74
74
|
|
|
75
75
|
|
|
76
|
-
def setup_blueprint(app, path=None, pkg_name="handlers", blueprint_map=
|
|
76
|
+
def setup_blueprint(app, path=None, pkg_name="handlers", blueprint_map=None):
|
|
77
77
|
import os
|
|
78
78
|
from flask import Blueprint
|
|
79
79
|
import inspect
|
|
@@ -83,6 +83,17 @@ def setup_blueprint(app, path=None, pkg_name="handlers", blueprint_map={}):
|
|
|
83
83
|
:param path: 项目内Handler的文件路径
|
|
84
84
|
:param pkg_name: 引入模块前缀
|
|
85
85
|
"""
|
|
86
|
+
if blueprint_map is None:
|
|
87
|
+
blueprint_map = {}
|
|
88
|
+
seen_blueprint_ids = {id(blueprint) for blueprint in blueprint_map.values()}
|
|
89
|
+
|
|
90
|
+
def _alloc_blueprint_name(blueprint_name):
|
|
91
|
+
if blueprint_name not in blueprint_map:
|
|
92
|
+
return blueprint_name
|
|
93
|
+
index = 2
|
|
94
|
+
while f"{blueprint_name}-{index}" in blueprint_map:
|
|
95
|
+
index += 1
|
|
96
|
+
return f"{blueprint_name}-{index}"
|
|
86
97
|
if path is None:
|
|
87
98
|
# 项目内Handler的文件路径,使用当前工作目录作为根
|
|
88
99
|
path = os.path.join(os.getcwd(), pkg_name)
|
|
@@ -102,17 +113,296 @@ def setup_blueprint(app, path=None, pkg_name="handlers", blueprint_map={}):
|
|
|
102
113
|
for name, obj in inspect.getmembers(module):
|
|
103
114
|
# 找到Blueprint 的属性进行注册
|
|
104
115
|
if isinstance(obj, Blueprint):
|
|
105
|
-
#
|
|
106
|
-
|
|
107
|
-
if blueprint_name in blueprint_map:
|
|
116
|
+
# 同一个蓝图对象可能以多个变量名暴露,按对象身份去重,避免重复注册。
|
|
117
|
+
if id(obj) in seen_blueprint_ids:
|
|
108
118
|
continue
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
119
|
+
# 如果有配置统一前缀则作为蓝图路径的统一前缀
|
|
120
|
+
blueprint_name = _alloc_blueprint_name(obj.name)
|
|
121
|
+
# 为蓝图保存唯一注册名,真正注册时通过 register_blueprint(name=...) 指定。
|
|
122
|
+
setattr(obj, "_registration_name", blueprint_name)
|
|
123
|
+
if hasattr(obj, "url_prefix") and app.config.get("ROUTE_PREFIX", ""):
|
|
124
|
+
obj.url_prefix = f'{app.config.get("ROUTE_PREFIX")}{obj.url_prefix}'
|
|
125
|
+
blueprint_map[blueprint_name] = obj
|
|
126
|
+
seen_blueprint_ids.add(id(obj))
|
|
113
127
|
return blueprint_map
|
|
114
128
|
|
|
115
129
|
|
|
130
|
+
def _normalize_route(raw_route):
|
|
131
|
+
if not raw_route:
|
|
132
|
+
return None
|
|
133
|
+
route = str(raw_route).strip()
|
|
134
|
+
if not route.startswith("/"):
|
|
135
|
+
route = f"/{route}"
|
|
136
|
+
return route.replace("{", "<").replace("}", ">")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _normalize_route_prefix(route_prefix):
|
|
140
|
+
route_prefix = (route_prefix or "").strip()
|
|
141
|
+
if route_prefix and not route_prefix.startswith("/"):
|
|
142
|
+
route_prefix = f"/{route_prefix}"
|
|
143
|
+
if route_prefix.endswith("/") and route_prefix != "/":
|
|
144
|
+
route_prefix = route_prefix.rstrip("/")
|
|
145
|
+
return route_prefix
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _map_local_route(route, route_prefix):
|
|
149
|
+
if not route_prefix:
|
|
150
|
+
return route
|
|
151
|
+
if route == "/icp":
|
|
152
|
+
return route_prefix
|
|
153
|
+
if route.startswith("/icp/"):
|
|
154
|
+
return f"{route_prefix}{route[4:]}"
|
|
155
|
+
return route
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _build_target_url(capability_server, route):
|
|
159
|
+
return f"{capability_server}/{route.lstrip('/')}"
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _normalize_methods(raw_methods, supported_methods):
|
|
163
|
+
if isinstance(raw_methods, str):
|
|
164
|
+
methods = [raw_methods.upper()]
|
|
165
|
+
elif isinstance(raw_methods, list):
|
|
166
|
+
methods = [str(item).upper() for item in raw_methods]
|
|
167
|
+
else:
|
|
168
|
+
methods = ["POST"]
|
|
169
|
+
return [item for item in methods if item in supported_methods] or ["POST"]
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _build_proxy_view(app, target_url, title, description=None, dynamic_params=None):
|
|
173
|
+
# 生成一个代理视图:把当前请求透明转发到能力平台目标地址。
|
|
174
|
+
def _proxy_view(**path_params):
|
|
175
|
+
url = target_url
|
|
176
|
+
for key, value in path_params.items():
|
|
177
|
+
value = str(value)
|
|
178
|
+
url = url.replace(f"<{key}>", value).replace(f"{{{key}}}", value)
|
|
179
|
+
|
|
180
|
+
headers = {}
|
|
181
|
+
for key, value in request.headers:
|
|
182
|
+
if key.lower() not in {"host", "content-length"}:
|
|
183
|
+
headers[key] = value
|
|
184
|
+
# 转调时按配置补充自定义请求头,用于能力平台鉴权/路由。
|
|
185
|
+
data_source_key = app.config.get("DATA_SOURCE_KEY")
|
|
186
|
+
data_source_value = app.config.get("DATA_SOURCE_VALUE")
|
|
187
|
+
if data_source_key and data_source_value is not None:
|
|
188
|
+
headers[str(data_source_key)] = str(data_source_value)
|
|
189
|
+
|
|
190
|
+
resp = requests.request(
|
|
191
|
+
method=request.method,
|
|
192
|
+
url=url,
|
|
193
|
+
params=request.args.to_dict(flat=False),
|
|
194
|
+
data=request.get_data(),
|
|
195
|
+
headers=headers,
|
|
196
|
+
timeout=30,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
excluded_headers = {"content-encoding", "content-length", "transfer-encoding", "connection"}
|
|
200
|
+
response_headers = [(k, v) for k, v in resp.headers.items() if k.lower() not in excluded_headers]
|
|
201
|
+
return Response(resp.content, status=resp.status_code, headers=response_headers)
|
|
202
|
+
|
|
203
|
+
_proxy_view._title = title
|
|
204
|
+
if description is not None:
|
|
205
|
+
_proxy_view.__doc__ = str(description)
|
|
206
|
+
_proxy_view._dynamic_params = list(dynamic_params or [])
|
|
207
|
+
_proxy_view.__name__ = f"auto_proxy_{uuid.uuid1().hex}"
|
|
208
|
+
return _proxy_view
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _fetch_api_tree_data(app, source_url):
|
|
212
|
+
try:
|
|
213
|
+
source_payload = {}
|
|
214
|
+
source_client_id = app.config.get("CLIENT_ID")
|
|
215
|
+
if source_client_id is not None:
|
|
216
|
+
source_payload["client_id"] = source_client_id
|
|
217
|
+
source_resp = requests.request(method="POST", url=source_url, json=source_payload, timeout=10)
|
|
218
|
+
source_resp.raise_for_status()
|
|
219
|
+
source_data = source_resp.json()
|
|
220
|
+
except Exception as e:
|
|
221
|
+
app.logger.exception("动态蓝图拉取失败,source_url=%s,error=%s", source_url, str(e))
|
|
222
|
+
return None, set(), {}
|
|
223
|
+
|
|
224
|
+
data_payload = source_data.get("data", {}) if isinstance(source_data, dict) else {}
|
|
225
|
+
if not isinstance(data_payload, dict):
|
|
226
|
+
app.logger.warning("动态蓝图数据格式错误:data 不是 dict")
|
|
227
|
+
return None, set(), {}
|
|
228
|
+
if str(source_data.get("status", "")) not in {"00000", "0"}:
|
|
229
|
+
app.logger.warning("动态蓝图接口返回失败:status=%s, message=%s",
|
|
230
|
+
source_data.get("status"), source_data.get("message"))
|
|
231
|
+
return None, set(), {}
|
|
232
|
+
|
|
233
|
+
tree_data = data_payload.get("tree", [])
|
|
234
|
+
selected_ids = data_payload.get("selected", [])
|
|
235
|
+
resource_params = data_payload.get("resource_params", {}) or {}
|
|
236
|
+
if not isinstance(tree_data, list):
|
|
237
|
+
app.logger.warning("动态蓝图数据格式错误:data.tree 不是 list")
|
|
238
|
+
return None, set(), {}
|
|
239
|
+
if not isinstance(selected_ids, list):
|
|
240
|
+
selected_ids = []
|
|
241
|
+
if not isinstance(resource_params, dict):
|
|
242
|
+
resource_params = {}
|
|
243
|
+
return tree_data, {str(item) for item in selected_ids if item}, resource_params
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _flatten_tree_by_parent(node_list, permission_ids, resource_params=None, ancestors=None, result=None):
|
|
247
|
+
# 从树结构中提取最后一级接口,并按“根节点到父节点路径”分组。
|
|
248
|
+
if result is None:
|
|
249
|
+
result = []
|
|
250
|
+
if ancestors is None:
|
|
251
|
+
ancestors = []
|
|
252
|
+
for node in node_list:
|
|
253
|
+
if not isinstance(node, dict):
|
|
254
|
+
continue
|
|
255
|
+
node_id = str(node.get("id", "")).strip()
|
|
256
|
+
node_label = node.get("label") or node.get("title") or node.get("name") or "动态接口"
|
|
257
|
+
children = node.get("children", [])
|
|
258
|
+
has_children = isinstance(children, list) and len(children) > 0
|
|
259
|
+
url = node.get("url")
|
|
260
|
+
method = node.get("method") or node.get("request_method") or "POST"
|
|
261
|
+
title = node_label
|
|
262
|
+
description = node.get("description") or node.get("desc") or node.get("remark") or ""
|
|
263
|
+
|
|
264
|
+
# 仅将“有权限且为叶子节点”的接口加入结果,蓝图名由 ancestors 路径拼接而成。
|
|
265
|
+
if url and node_id and node_id in permission_ids and not has_children:
|
|
266
|
+
if ancestors:
|
|
267
|
+
group_label = "-".join([str(item).strip() for item in ancestors if str(item).strip()])
|
|
268
|
+
else:
|
|
269
|
+
group_label = "动态接口"
|
|
270
|
+
group_id = " > ".join([str(item).strip() for item in ancestors if str(item).strip()]) or "root"
|
|
271
|
+
result.append({
|
|
272
|
+
"resource_id": node_id,
|
|
273
|
+
"group_id": group_id,
|
|
274
|
+
"group_label": group_label or "动态接口",
|
|
275
|
+
"url": url,
|
|
276
|
+
"method": method,
|
|
277
|
+
"title": title,
|
|
278
|
+
"description": description,
|
|
279
|
+
"dynamic_params": list(resource_params.get(node_id, []) if isinstance(resource_params, dict) else [])
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
if has_children:
|
|
283
|
+
next_ancestors = list(ancestors)
|
|
284
|
+
if node_label:
|
|
285
|
+
next_ancestors.append(node_label)
|
|
286
|
+
_flatten_tree_by_parent(children, permission_ids, resource_params=resource_params,
|
|
287
|
+
ancestors=next_ancestors, result=result)
|
|
288
|
+
return result
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _group_routes(route_list):
|
|
292
|
+
grouped_routes = {}
|
|
293
|
+
for item in route_list:
|
|
294
|
+
grouped_routes.setdefault(item.get("group_id"), {
|
|
295
|
+
"label": item.get("group_label") or "动态接口",
|
|
296
|
+
"routes": []
|
|
297
|
+
})
|
|
298
|
+
grouped_routes[item.get("group_id")]["routes"].append(item)
|
|
299
|
+
return grouped_routes
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _alloc_blueprint_name(group_label, used_blueprint_names):
|
|
303
|
+
# 如果蓝图名重复,则自动追加 -2、-3...
|
|
304
|
+
base_name = str(group_label or "动态接口").strip() or "动态接口"
|
|
305
|
+
if base_name not in used_blueprint_names:
|
|
306
|
+
used_blueprint_names.add(base_name)
|
|
307
|
+
return base_name
|
|
308
|
+
index = 2
|
|
309
|
+
while f"{base_name}-{index}" in used_blueprint_names:
|
|
310
|
+
index += 1
|
|
311
|
+
unique_name = f"{base_name}-{index}"
|
|
312
|
+
used_blueprint_names.add(unique_name)
|
|
313
|
+
return unique_name
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def prepare_api_blueprint_routes(app):
|
|
317
|
+
if not app.config.get("AUTO_BLUEPRINT_ENABLE", False):
|
|
318
|
+
return None, None, []
|
|
319
|
+
|
|
320
|
+
capability_server = app.config.get("CAPABILITY_PLATFORM_SERVER", "").rstrip("/")
|
|
321
|
+
if not capability_server:
|
|
322
|
+
app.logger.warning("AUTO_BLUEPRINT_ENABLE=True 但未配置 CAPABILITY_PLATFORM_SERVER,跳过动态蓝图注册")
|
|
323
|
+
return None, None, []
|
|
324
|
+
|
|
325
|
+
source_url = f"{capability_server}/icp/authPermission/client_resource_param_tree"
|
|
326
|
+
route_prefix = _normalize_route_prefix(app.config.get("ROUTE_PREFIX", ""))
|
|
327
|
+
tree_data, selected_id_set, resource_params = _fetch_api_tree_data(app, source_url)
|
|
328
|
+
if not tree_data:
|
|
329
|
+
return capability_server, route_prefix, []
|
|
330
|
+
|
|
331
|
+
route_list = _flatten_tree_by_parent(tree_data, selected_id_set, resource_params=resource_params)
|
|
332
|
+
if not route_list:
|
|
333
|
+
app.logger.warning("动态蓝图注册完成,但没有提取到可用路由")
|
|
334
|
+
return capability_server, route_prefix, route_list
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def setup_api_blueprint(app, exclude_route_method_keys=None, existing_blueprint_names=None,
|
|
338
|
+
route_list=None, capability_server=None, route_prefix=None):
|
|
339
|
+
"""
|
|
340
|
+
调用固定 API 拉取地址列表并动态创建蓝图。
|
|
341
|
+
固定请求地址:{CAPABILITY_PLATFORM_SERVER}/icp/authPermission/client_resource_param_tree
|
|
342
|
+
"""
|
|
343
|
+
if route_list is None:
|
|
344
|
+
capability_server, route_prefix, route_list = prepare_api_blueprint_routes(app)
|
|
345
|
+
route_prefix = _normalize_route_prefix(route_prefix)
|
|
346
|
+
if not capability_server:
|
|
347
|
+
return []
|
|
348
|
+
if not route_list:
|
|
349
|
+
return []
|
|
350
|
+
|
|
351
|
+
exclude_route_method_keys = set(exclude_route_method_keys or [])
|
|
352
|
+
used_blueprint_names = set(existing_blueprint_names or [])
|
|
353
|
+
used_blueprint_names.update(set(app.blueprints.keys()))
|
|
354
|
+
registered_route_method_keys = set()
|
|
355
|
+
supported_methods = {"GET", "POST", "PUT", "PATCH", "DELETE"}
|
|
356
|
+
grouped_routes = _group_routes(route_list)
|
|
357
|
+
|
|
358
|
+
auto_blueprints = []
|
|
359
|
+
total_route_count = 0
|
|
360
|
+
for group_id, group_info in grouped_routes.items():
|
|
361
|
+
bp_name = _alloc_blueprint_name(group_info.get("label"), used_blueprint_names)
|
|
362
|
+
dynamic_bp = SQ_Blueprint(bp_name, url_prefix="")
|
|
363
|
+
group_route_count = 0
|
|
364
|
+
|
|
365
|
+
for index, item in enumerate(group_info.get("routes", [])):
|
|
366
|
+
remote_route = _normalize_route(item.get("url"))
|
|
367
|
+
if not remote_route:
|
|
368
|
+
continue
|
|
369
|
+
local_route = _map_local_route(remote_route, route_prefix)
|
|
370
|
+
target_url = item.get("target_url") or _build_target_url(capability_server, remote_route)
|
|
371
|
+
if not target_url:
|
|
372
|
+
app.logger.warning("动态蓝图未找到目标地址,route=%s,group=%s", remote_route, group_id)
|
|
373
|
+
continue
|
|
374
|
+
|
|
375
|
+
methods = _normalize_methods(item.get("method", "POST"), supported_methods)
|
|
376
|
+
available_methods = []
|
|
377
|
+
for method in methods:
|
|
378
|
+
route_method_key = f"{local_route}|{method}"
|
|
379
|
+
if route_method_key in exclude_route_method_keys:
|
|
380
|
+
continue
|
|
381
|
+
if route_method_key in registered_route_method_keys:
|
|
382
|
+
continue
|
|
383
|
+
available_methods.append(method)
|
|
384
|
+
registered_route_method_keys.add(route_method_key)
|
|
385
|
+
if not available_methods:
|
|
386
|
+
continue
|
|
387
|
+
|
|
388
|
+
title = item.get("title") or f"{bp_name}-{index + 1}"
|
|
389
|
+
endpoint = f"auto_dynamic_{group_id}_{index}_{'_'.join(available_methods).lower()}"
|
|
390
|
+
description = item.get("description") or title
|
|
391
|
+
view_func = _build_proxy_view(app, target_url=target_url, title=title,
|
|
392
|
+
description=description, dynamic_params=item.get("dynamic_params"))
|
|
393
|
+
dynamic_bp.add_url_rule(local_route, endpoint=endpoint, view_func=view_func, methods=available_methods)
|
|
394
|
+
group_route_count += 1
|
|
395
|
+
total_route_count += 1
|
|
396
|
+
|
|
397
|
+
if group_route_count > 0:
|
|
398
|
+
auto_blueprints.append(dynamic_bp)
|
|
399
|
+
app.logger.info("动态蓝图注册成功:%s,路由数量=%s", bp_name, group_route_count)
|
|
400
|
+
|
|
401
|
+
if total_route_count == 0:
|
|
402
|
+
app.logger.warning("动态蓝图注册完成,但没有可用路由")
|
|
403
|
+
return auto_blueprints
|
|
404
|
+
|
|
405
|
+
|
|
116
406
|
def setup_query_runner():
|
|
117
407
|
"""
|
|
118
408
|
注入数据查询执行器
|
|
@@ -39,20 +39,20 @@ class CountLimitHandler:
|
|
|
39
39
|
logout_url = f"{fs_oam_service_url}/icp/oauth/logout_token?token={token}"
|
|
40
40
|
|
|
41
41
|
# 发送 GET 请求到 lock_account_url
|
|
42
|
-
try:
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
except requests.RequestException as e:
|
|
48
|
-
|
|
42
|
+
# try:
|
|
43
|
+
# lock_response = requests.get(lock_account_url, timeout=30)
|
|
44
|
+
# logger.info(
|
|
45
|
+
# "Lock account request sent, status: %s", lock_response.status_code
|
|
46
|
+
# )
|
|
47
|
+
# except requests.RequestException as e:
|
|
48
|
+
# logger.error("Failed to send lock account request: %s", str(e))
|
|
49
49
|
|
|
50
|
-
# 发送 GET 请求到 logout_url
|
|
51
|
-
try:
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
except requests.RequestException as e:
|
|
55
|
-
|
|
50
|
+
# # 发送 GET 请求到 logout_url
|
|
51
|
+
# try:
|
|
52
|
+
# logout_response = requests.get(logout_url, timeout=30)
|
|
53
|
+
# logger.info("Logout request sent, status: %s", logout_response.status_code)
|
|
54
|
+
# except requests.RequestException as e:
|
|
55
|
+
# logger.error("Failed to send logout request: %s", str(e))
|
|
56
56
|
limit_fs_webhook_url = current_app.config.get("LIMIT_FS_WEBHOOK_URL")
|
|
57
57
|
# 如果配置了飞书 webhook URL,则发送告警通知
|
|
58
58
|
if limit_fs_webhook_url:
|
|
@@ -69,9 +69,9 @@ class CountLimitHandler:
|
|
|
69
69
|
content.append({"tag": "text", "text": f"资源地址:{request.path}\n"})
|
|
70
70
|
|
|
71
71
|
content.append({"tag": "text", "text": "运维处理:"})
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
72
|
+
content.append({"tag": "a", "text": "强制下线", "href": f"{logout_url}"})
|
|
73
|
+
content.append({"tag": "a", "text": " 禁止登录 ",
|
|
74
|
+
"href": f"{lock_account_url}"})
|
|
75
75
|
ban_ip_url = f"{fs_oam_service_url}/icp/accessLog/ban_ip?ip={request.remote_addr}"
|
|
76
76
|
content.append({"tag": "a", "text": "封禁IP ", "href": f"{ban_ip_url}"})
|
|
77
77
|
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import hmac
|
|
3
|
+
import ipaddress
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
from flask import request
|
|
7
|
+
|
|
8
|
+
from lesscode_flask.model.response_result import ResponseResult
|
|
9
|
+
from lesscode_flask.utils.redis.redis_helper import RedisHelper
|
|
10
|
+
|
|
11
|
+
SIGN_ERROR_MESSAGE = "未知错误"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _body_sha256() -> str:
|
|
15
|
+
# 对原始请求体做哈希,保证不同 content-type 下签名输入一致。
|
|
16
|
+
body_bytes = request.get_data(cache=True) or b""
|
|
17
|
+
return hashlib.sha256(body_bytes).hexdigest()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _is_upload_request() -> bool:
|
|
21
|
+
# 上传文件请求:multipart/form-data 或存在文件字段。
|
|
22
|
+
content_type = (request.content_type or "").lower()
|
|
23
|
+
return content_type.startswith("multipart/form-data") or bool(request.files)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _canonical_sign_content(timestamp: str, nonce: str) -> str:
|
|
27
|
+
# 规范签名串格式:
|
|
28
|
+
# METHOD \n PATH \n QUERY_STRING \n BODY_SHA256 \n TIMESTAMP \n NONCE
|
|
29
|
+
method = request.method.upper()
|
|
30
|
+
path = request.path
|
|
31
|
+
query_string = (request.query_string or b"").decode("utf-8")
|
|
32
|
+
if _is_upload_request():
|
|
33
|
+
# 上传请求忽略body参与签名时,固定使用空串哈希。
|
|
34
|
+
body_hash = hashlib.sha256(b"").hexdigest()
|
|
35
|
+
else:
|
|
36
|
+
body_hash = _body_sha256()
|
|
37
|
+
return f"{method}\n{path}\n{query_string}\n{body_hash}\n{timestamp}\n{nonce}"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _in_sign_white_list(white_list: list) -> bool:
|
|
41
|
+
# 白名单匹配规则:只要请求路径以前缀命中就跳过签名校验。
|
|
42
|
+
if not white_list:
|
|
43
|
+
return False
|
|
44
|
+
request_path = request.path or ""
|
|
45
|
+
for white_item in white_list:
|
|
46
|
+
if white_item and request_path.startswith(white_item):
|
|
47
|
+
return True
|
|
48
|
+
return False
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _request_client_ip() -> str:
|
|
52
|
+
# 优先读取公司网关透传IP,再回退到常见代理头和直连IP。
|
|
53
|
+
remote_addr_header = request.headers.get("remote-addr", "").strip()
|
|
54
|
+
if remote_addr_header:
|
|
55
|
+
return remote_addr_header
|
|
56
|
+
|
|
57
|
+
x_forwarded_for = request.headers.get("X-Forwarded-For", "")
|
|
58
|
+
if x_forwarded_for:
|
|
59
|
+
client_ip = x_forwarded_for.split(",")[0].strip()
|
|
60
|
+
if client_ip:
|
|
61
|
+
return client_ip
|
|
62
|
+
x_real_ip = request.headers.get("X-Real-IP", "").strip()
|
|
63
|
+
if x_real_ip:
|
|
64
|
+
return x_real_ip
|
|
65
|
+
return (request.remote_addr or "").strip()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _in_sign_ip_white_list(ip_white_list: list) -> bool:
|
|
69
|
+
# IP白名单支持单IP和CIDR网段,例如:["10.0.0.1", "10.10.0.0/16"]。
|
|
70
|
+
if not ip_white_list:
|
|
71
|
+
return False
|
|
72
|
+
client_ip = _request_client_ip()
|
|
73
|
+
if not client_ip:
|
|
74
|
+
return False
|
|
75
|
+
try:
|
|
76
|
+
client_ip_obj = ipaddress.ip_address(client_ip)
|
|
77
|
+
except ValueError:
|
|
78
|
+
return False
|
|
79
|
+
|
|
80
|
+
for item in ip_white_list:
|
|
81
|
+
white_item = (item or "").strip()
|
|
82
|
+
if not white_item:
|
|
83
|
+
continue
|
|
84
|
+
try:
|
|
85
|
+
if "/" in white_item:
|
|
86
|
+
if client_ip_obj in ipaddress.ip_network(white_item, strict=False):
|
|
87
|
+
return True
|
|
88
|
+
elif client_ip_obj == ipaddress.ip_address(white_item):
|
|
89
|
+
return True
|
|
90
|
+
except ValueError:
|
|
91
|
+
# 非法配置项直接跳过,避免影响其它合法项匹配。
|
|
92
|
+
continue
|
|
93
|
+
return False
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def verify_request_signature(app):
|
|
97
|
+
# 签名校验在 token/权限校验前执行。
|
|
98
|
+
# 关闭开关时,直接放行,不影响现有接口。
|
|
99
|
+
if not app.config.get("SIGN_ENABLE", False):
|
|
100
|
+
return
|
|
101
|
+
# 公司内部IP可走白名单,命中则跳过签名校验。
|
|
102
|
+
if _in_sign_ip_white_list(app.config.get("SIGN_IP_WHITE_LIST", [])):
|
|
103
|
+
return
|
|
104
|
+
# 预检请求通常不带业务签名,默认忽略。
|
|
105
|
+
if request.method in app.config.get("SIGN_IGNORE_METHODS", ["OPTIONS"]):
|
|
106
|
+
return
|
|
107
|
+
# 白名单接口不做签名校验,例如登录、swagger等入口。
|
|
108
|
+
if _in_sign_white_list(app.config.get("SIGN_WHITE_LIST", [])):
|
|
109
|
+
return
|
|
110
|
+
|
|
111
|
+
# 读取签名相关请求头,固定使用无前缀命名。
|
|
112
|
+
timestamp = request.headers.get("Timestamp", "")
|
|
113
|
+
nonce = request.headers.get("Nonce", "")
|
|
114
|
+
signature = request.headers.get("Signature", "")
|
|
115
|
+
sign_secret = app.config.get("SIGN_SECRET", "")
|
|
116
|
+
|
|
117
|
+
# 任何签名基础参数缺失都统一返回“未知错误”,避免向外暴露细节。
|
|
118
|
+
if not timestamp or not nonce or not signature:
|
|
119
|
+
ResponseResult.fail(message=SIGN_ERROR_MESSAGE, status_code="401", http_code="401")
|
|
120
|
+
# 服务端密钥未配置时也拦截,防止“空密钥”误放行。
|
|
121
|
+
if not sign_secret:
|
|
122
|
+
ResponseResult.fail(message=SIGN_ERROR_MESSAGE, status_code="401", http_code="401")
|
|
123
|
+
|
|
124
|
+
# 时间戳必须是整数秒。
|
|
125
|
+
try:
|
|
126
|
+
ts = int(timestamp)
|
|
127
|
+
except (TypeError, ValueError):
|
|
128
|
+
ResponseResult.fail(message=SIGN_ERROR_MESSAGE, status_code="401", http_code="401")
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
# 时间窗校验,防止旧请求被重放。
|
|
132
|
+
sign_window_sec = int(app.config.get("SIGN_WINDOW_SEC", 300))
|
|
133
|
+
now_ts = int(time.time())
|
|
134
|
+
if abs(now_ts - ts) > sign_window_sec:
|
|
135
|
+
ResponseResult.fail(message=SIGN_ERROR_MESSAGE, status_code="401", http_code="401")
|
|
136
|
+
|
|
137
|
+
if app.config.get("SIGN_NONCE_ENABLE", True):
|
|
138
|
+
# 防重放:同一个 nonce 在 TTL 窗口内只能使用一次。
|
|
139
|
+
nonce_key = f"sign:nonce:{nonce}"
|
|
140
|
+
nonce_ttl = int(app.config.get("SIGN_NONCE_TTL_SEC", sign_window_sec))
|
|
141
|
+
try:
|
|
142
|
+
ok = RedisHelper(app.config.get("REDIS_LIMIT_KEY", "redis")).sync_set(
|
|
143
|
+
nonce_key, "1", ex=nonce_ttl, nx=True
|
|
144
|
+
)
|
|
145
|
+
if not ok:
|
|
146
|
+
ResponseResult.fail(message=SIGN_ERROR_MESSAGE, status_code="401", http_code="401")
|
|
147
|
+
except Exception:
|
|
148
|
+
# Redis 异常按失败处理,避免在防重放失效时放过请求。
|
|
149
|
+
ResponseResult.fail(message=SIGN_ERROR_MESSAGE, status_code="401", http_code="401")
|
|
150
|
+
|
|
151
|
+
# 按统一规则组装签名串并计算服务端签名。
|
|
152
|
+
sign_content = _canonical_sign_content(timestamp=timestamp, nonce=nonce)
|
|
153
|
+
expected = hmac.new(
|
|
154
|
+
sign_secret.encode("utf-8"),
|
|
155
|
+
sign_content.encode("utf-8"),
|
|
156
|
+
hashlib.sha256,
|
|
157
|
+
).hexdigest()
|
|
158
|
+
# 常量时间比较,降低时序侧信道风险。
|
|
159
|
+
if not hmac.compare_digest(expected, signature):
|
|
160
|
+
ResponseResult.fail(message=SIGN_ERROR_MESSAGE, status_code="401", http_code="401")
|
{lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/utils/swagger/swagger_util.py
RENAMED
|
@@ -4,6 +4,94 @@ from lesscode_flask.utils.swagger.swagger_template import get_response_template,
|
|
|
4
4
|
split_doc, get_param_template
|
|
5
5
|
|
|
6
6
|
|
|
7
|
+
def _get_dynamic_params(view_func):
|
|
8
|
+
dynamic_params = getattr(view_func, "_dynamic_params", None)
|
|
9
|
+
if isinstance(dynamic_params, list):
|
|
10
|
+
return dynamic_params
|
|
11
|
+
return []
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _map_param_type(param_type):
|
|
15
|
+
param_type = str(param_type or "").lower()
|
|
16
|
+
if param_type in {"int", "integer"}:
|
|
17
|
+
return "integer"
|
|
18
|
+
if param_type in {"float", "number", "double"}:
|
|
19
|
+
return "number"
|
|
20
|
+
if param_type in {"bool", "boolean"}:
|
|
21
|
+
return "boolean"
|
|
22
|
+
if param_type in {"dict", "object"}:
|
|
23
|
+
return "object"
|
|
24
|
+
if param_type in {"list", "array"}:
|
|
25
|
+
return "array"
|
|
26
|
+
return "string"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _build_dynamic_schema(param_type):
|
|
30
|
+
schema_type = _map_param_type(param_type)
|
|
31
|
+
if schema_type == "array":
|
|
32
|
+
return {"type": "array", "items": {"type": "string"}}
|
|
33
|
+
return {"type": schema_type}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _build_dynamic_body(view_func, not_allow_list=None):
|
|
37
|
+
dynamic_params = _get_dynamic_params(view_func)
|
|
38
|
+
if not dynamic_params:
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
not_allow_list = set(not_allow_list or [])
|
|
42
|
+
body = {}
|
|
43
|
+
required = []
|
|
44
|
+
request_type = "application/json"
|
|
45
|
+
for param in dynamic_params:
|
|
46
|
+
param_name = param.get("param_name")
|
|
47
|
+
if not param_name or param_name in not_allow_list:
|
|
48
|
+
continue
|
|
49
|
+
position = str(param.get("param_position") or "").lower()
|
|
50
|
+
if position == "path":
|
|
51
|
+
continue
|
|
52
|
+
if position in {"query", "header"}:
|
|
53
|
+
continue
|
|
54
|
+
|
|
55
|
+
schema = _build_dynamic_schema(param.get("param_type"))
|
|
56
|
+
body[param_name] = {
|
|
57
|
+
**schema,
|
|
58
|
+
"description": param.get("param_description") or param.get("param_ch_name") or f"Path parameter {param_name}",
|
|
59
|
+
"example": get_sample_data(param.get("param_type"))
|
|
60
|
+
}
|
|
61
|
+
required.append(param_name)
|
|
62
|
+
|
|
63
|
+
if not body:
|
|
64
|
+
return None
|
|
65
|
+
return get_request_body(body, required, request_type)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _build_dynamic_query_params(rule, view_func):
|
|
69
|
+
dynamic_params = _get_dynamic_params(view_func)
|
|
70
|
+
if not dynamic_params:
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
parameters = []
|
|
74
|
+
path_arg_set = set(rule.arguments)
|
|
75
|
+
for param in dynamic_params:
|
|
76
|
+
param_name = param.get("param_name")
|
|
77
|
+
if not param_name or param_name in path_arg_set:
|
|
78
|
+
continue
|
|
79
|
+
position = str(param.get("param_position") or "").lower()
|
|
80
|
+
if position == "header":
|
|
81
|
+
continue
|
|
82
|
+
if position == "path":
|
|
83
|
+
continue
|
|
84
|
+
|
|
85
|
+
parameters.append({
|
|
86
|
+
"name": param_name,
|
|
87
|
+
"in": "query",
|
|
88
|
+
"description": param.get("param_description") or param.get("param_ch_name") or f"Path parameter {param_name}",
|
|
89
|
+
"required": False,
|
|
90
|
+
"schema": _build_dynamic_schema(param.get("param_type"))
|
|
91
|
+
})
|
|
92
|
+
return parameters
|
|
93
|
+
|
|
94
|
+
|
|
7
95
|
def generate_openapi_spec(app, is_read_template=False):
|
|
8
96
|
paths = {}
|
|
9
97
|
if is_read_template:
|
|
@@ -100,11 +188,15 @@ def replace_symbol(path):
|
|
|
100
188
|
def get_sample_data(type, param=None, param_template_dict=None):
|
|
101
189
|
if param_template_dict and param_template_dict.get(param.name):
|
|
102
190
|
return param_template_dict.get(param.name, {}).get("param_sample")
|
|
103
|
-
elif type
|
|
191
|
+
elif type in {"dict", "object"}:
|
|
104
192
|
return {"sample": "sample"}
|
|
105
|
-
elif type
|
|
193
|
+
elif type in {"list", "array"}:
|
|
106
194
|
return ["sample"]
|
|
107
|
-
elif type
|
|
195
|
+
elif type in {"int", "integer"}:
|
|
196
|
+
return 0
|
|
197
|
+
elif type in {"bool", "boolean"}:
|
|
198
|
+
return False
|
|
199
|
+
elif type in {"float", "number", "double"}:
|
|
108
200
|
return 0
|
|
109
201
|
else:
|
|
110
202
|
return ""
|
|
@@ -129,6 +221,9 @@ def get_params_type(param, param_template_dict=None):
|
|
|
129
221
|
def extract_post_body(view_func, not_allow_list=None, param_desc_dict=None, param_template_dict=None):
|
|
130
222
|
if param_desc_dict is None:
|
|
131
223
|
param_desc_dict = {}
|
|
224
|
+
dynamic_body = _build_dynamic_body(view_func, not_allow_list=not_allow_list)
|
|
225
|
+
if dynamic_body is not None:
|
|
226
|
+
return dynamic_body
|
|
132
227
|
body = {}
|
|
133
228
|
# 提取查询参数和表单参数
|
|
134
229
|
sig = inspect.signature(view_func)
|
|
@@ -144,6 +239,9 @@ def extract_post_body(view_func, not_allow_list=None, param_desc_dict=None, para
|
|
|
144
239
|
|
|
145
240
|
required = []
|
|
146
241
|
for arg, param in sig.parameters.items():
|
|
242
|
+
# Skip variadic args like **path_params used by dynamic proxy views.
|
|
243
|
+
if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
|
|
244
|
+
continue
|
|
147
245
|
param_type = get_params_type(param, param_template_dict)
|
|
148
246
|
param_info = {
|
|
149
247
|
"type": param_type,
|
|
@@ -173,15 +271,19 @@ def extract_post_body(view_func, not_allow_list=None, param_desc_dict=None, para
|
|
|
173
271
|
def extract_path_parameters(rule, view_func, param_desc_dict=None, param_template_dict=None):
|
|
174
272
|
if param_desc_dict is None:
|
|
175
273
|
param_desc_dict = {}
|
|
274
|
+
dynamic_param_map = {
|
|
275
|
+
str(item.get("param_name")): item for item in _get_dynamic_params(view_func) if item.get("param_name")
|
|
276
|
+
}
|
|
176
277
|
parameters = []
|
|
177
278
|
# 提取路径参数
|
|
178
279
|
for arg in rule.arguments:
|
|
280
|
+
dynamic_param = dynamic_param_map.get(arg, {})
|
|
179
281
|
parameters.append({
|
|
180
282
|
"name": arg,
|
|
181
283
|
"in": "path",
|
|
182
284
|
"required": True,
|
|
183
|
-
"description": get_param_desc(arg, param_desc_dict, param_template_dict),
|
|
184
|
-
"schema": {
|
|
285
|
+
"description": dynamic_param.get("param_description") or get_param_desc(arg, param_desc_dict, param_template_dict),
|
|
286
|
+
"schema": _build_dynamic_schema(dynamic_param.get("param_type")) if dynamic_param else {
|
|
185
287
|
"type": "integer" if "int" in str(view_func.__annotations__.get(arg, '')) else "string"
|
|
186
288
|
}
|
|
187
289
|
})
|
|
@@ -199,10 +301,16 @@ def get_param_desc(arg, param_desc_dict, param_template_dict):
|
|
|
199
301
|
def extract_get_parameters(rule, view_func, param_desc_dict=None, param_template_dict=None):
|
|
200
302
|
if param_desc_dict is None:
|
|
201
303
|
param_desc_dict = {}
|
|
304
|
+
dynamic_parameters = _build_dynamic_query_params(rule, view_func)
|
|
305
|
+
if dynamic_parameters is not None:
|
|
306
|
+
return dynamic_parameters
|
|
202
307
|
parameters = []
|
|
203
308
|
# 提取查询参数和表单参数
|
|
204
309
|
sig = inspect.signature(view_func)
|
|
205
310
|
for arg, param in sig.parameters.items():
|
|
311
|
+
# Skip variadic args like **path_params used by dynamic proxy views.
|
|
312
|
+
if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
|
|
313
|
+
continue
|
|
206
314
|
if arg not in rule.arguments:
|
|
207
315
|
param_info = {
|
|
208
316
|
"name": arg,
|
|
File without changes
|
|
@@ -28,6 +28,7 @@ lesscode_flask/service/resource_param_template_service.py
|
|
|
28
28
|
lesscode_flask/setting/__init__.py
|
|
29
29
|
lesscode_flask/setup/__init__.py
|
|
30
30
|
lesscode_flask/static/swagger.py
|
|
31
|
+
lesscode_flask/utils/__init__.py
|
|
31
32
|
lesscode_flask/utils/dify_utils.py
|
|
32
33
|
lesscode_flask/utils/fs_util.py
|
|
33
34
|
lesscode_flask/utils/helpers.py
|
|
@@ -52,6 +53,8 @@ lesscode_flask/utils/oss/ks3_oss.py
|
|
|
52
53
|
lesscode_flask/utils/oss/minio_oss.py
|
|
53
54
|
lesscode_flask/utils/redis/redis_helper.py
|
|
54
55
|
lesscode_flask/utils/request/request.py
|
|
56
|
+
lesscode_flask/utils/sign/__init__.py
|
|
57
|
+
lesscode_flask/utils/sign/signature.py
|
|
55
58
|
lesscode_flask/utils/swagger/swagger_template.py
|
|
56
59
|
lesscode_flask/utils/swagger/swagger_util.py
|
|
57
60
|
lesscode_flask/utils/task/__init__.py
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/export_data/data_download_handler.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/model/resource_param_template.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/service/access_log_service.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/utils/decorator/sql_injection.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/utils/json/NotSortJSONProvider.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/utils/limit/req/redis_rate_limiter.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask/utils/swagger/swagger_template.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{lesscode_flask-0.2.62 → lesscode_flask-0.2.89}/lesscode_flask.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|