AstrBot 4.10.3__py3-none-any.whl → 4.10.4__py3-none-any.whl
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.
- astrbot/builtin_stars/astrbot/main.py +2 -10
- astrbot/builtin_stars/python_interpreter/main.py +130 -131
- astrbot/cli/__init__.py +1 -1
- astrbot/core/agent/message.py +23 -1
- astrbot/core/agent/runners/tool_loop_agent_runner.py +24 -7
- astrbot/core/astr_agent_hooks.py +6 -0
- astrbot/core/backup/exporter.py +1 -0
- astrbot/core/config/astrbot_config.py +2 -0
- astrbot/core/config/default.py +47 -6
- astrbot/core/knowledge_base/chunking/recursive.py +10 -2
- astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py +184 -174
- astrbot/core/pipeline/result_decorate/stage.py +65 -57
- astrbot/core/pipeline/waking_check/stage.py +29 -2
- astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py +15 -29
- astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py +1 -6
- astrbot/core/platform/sources/dingtalk/dingtalk_event.py +15 -1
- astrbot/core/platform/sources/lark/lark_adapter.py +2 -10
- astrbot/core/platform/sources/misskey/misskey_adapter.py +0 -5
- astrbot/core/platform/sources/misskey/misskey_utils.py +0 -3
- astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py +4 -9
- astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_adapter.py +4 -9
- astrbot/core/platform/sources/satori/satori_adapter.py +6 -1
- astrbot/core/platform/sources/slack/slack_adapter.py +3 -6
- astrbot/core/platform/sources/webchat/webchat_adapter.py +0 -1
- astrbot/core/platform/sources/wechatpadpro/wechatpadpro_adapter.py +3 -5
- astrbot/core/provider/entities.py +9 -1
- astrbot/core/provider/sources/anthropic_source.py +60 -3
- astrbot/core/provider/sources/gemini_source.py +37 -3
- astrbot/core/provider/sources/minimax_tts_api_source.py +4 -1
- astrbot/core/provider/sources/openai_source.py +25 -31
- astrbot/core/provider/sources/xai_source.py +29 -0
- astrbot/core/provider/sources/xinference_stt_provider.py +24 -12
- astrbot/core/star/star_manager.py +41 -0
- astrbot/core/utils/pip_installer.py +20 -1
- astrbot/dashboard/routes/backup.py +519 -15
- astrbot/dashboard/routes/config.py +45 -0
- astrbot/dashboard/server.py +1 -0
- {astrbot-4.10.3.dist-info → astrbot-4.10.4.dist-info}/METADATA +1 -1
- {astrbot-4.10.3.dist-info → astrbot-4.10.4.dist-info}/RECORD +42 -41
- {astrbot-4.10.3.dist-info → astrbot-4.10.4.dist-info}/WHEEL +0 -0
- {astrbot-4.10.3.dist-info → astrbot-4.10.4.dist-info}/entry_points.txt +0 -0
- {astrbot-4.10.3.dist-info → astrbot-4.10.4.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
"""备份管理 API 路由"""
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
|
+
import json
|
|
4
5
|
import os
|
|
5
6
|
import re
|
|
7
|
+
import shutil
|
|
8
|
+
import time
|
|
6
9
|
import traceback
|
|
7
10
|
import uuid
|
|
11
|
+
import zipfile
|
|
8
12
|
from datetime import datetime
|
|
9
13
|
from pathlib import Path
|
|
10
14
|
|
|
15
|
+
import jwt
|
|
11
16
|
from quart import request, send_file
|
|
12
17
|
|
|
13
18
|
from astrbot.core import logger
|
|
@@ -22,6 +27,10 @@ from astrbot.core.utils.astrbot_path import (
|
|
|
22
27
|
|
|
23
28
|
from .route import Response, Route, RouteContext
|
|
24
29
|
|
|
30
|
+
# 分片上传常量
|
|
31
|
+
CHUNK_SIZE = 1024 * 1024 # 1MB
|
|
32
|
+
UPLOAD_EXPIRE_SECONDS = 3600 # 上传会话过期时间(1小时)
|
|
33
|
+
|
|
25
34
|
|
|
26
35
|
def secure_filename(filename: str) -> str:
|
|
27
36
|
"""清洗文件名,移除路径遍历字符和危险字符
|
|
@@ -54,17 +63,17 @@ def secure_filename(filename: str) -> str:
|
|
|
54
63
|
|
|
55
64
|
|
|
56
65
|
def generate_unique_filename(original_filename: str) -> str:
|
|
57
|
-
"""
|
|
66
|
+
"""生成唯一的文件名,在原文件名后添加时间戳后缀避免重名
|
|
58
67
|
|
|
59
68
|
Args:
|
|
60
69
|
original_filename: 原始文件名(已清洗)
|
|
61
70
|
|
|
62
71
|
Returns:
|
|
63
|
-
|
|
72
|
+
添加了时间戳后缀的唯一文件名,格式为 {原文件名}_{YYYYMMDD_HHMMSS}.{扩展名}
|
|
64
73
|
"""
|
|
65
|
-
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
66
74
|
name, ext = os.path.splitext(original_filename)
|
|
67
|
-
|
|
75
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
76
|
+
return f"{name}_{timestamp}{ext}"
|
|
68
77
|
|
|
69
78
|
|
|
70
79
|
class BackupRoute(Route):
|
|
@@ -84,21 +93,34 @@ class BackupRoute(Route):
|
|
|
84
93
|
self.core_lifecycle = core_lifecycle
|
|
85
94
|
self.backup_dir = get_astrbot_backups_path()
|
|
86
95
|
self.data_dir = get_astrbot_data_path()
|
|
96
|
+
self.chunks_dir = os.path.join(self.backup_dir, ".chunks")
|
|
87
97
|
|
|
88
98
|
# 任务状态跟踪
|
|
89
99
|
self.backup_tasks: dict[str, dict] = {}
|
|
90
100
|
self.backup_progress: dict[str, dict] = {}
|
|
91
101
|
|
|
102
|
+
# 分片上传会话跟踪
|
|
103
|
+
# upload_id -> {filename, total_chunks, received_chunks, last_activity, chunk_dir}
|
|
104
|
+
self.upload_sessions: dict[str, dict] = {}
|
|
105
|
+
|
|
106
|
+
# 后台清理任务句柄
|
|
107
|
+
self._cleanup_task: asyncio.Task | None = None
|
|
108
|
+
|
|
92
109
|
# 注册路由
|
|
93
110
|
self.routes = {
|
|
94
111
|
"/backup/list": ("GET", self.list_backups),
|
|
95
112
|
"/backup/export": ("POST", self.export_backup),
|
|
96
|
-
"/backup/upload": ("POST", self.upload_backup), #
|
|
113
|
+
"/backup/upload": ("POST", self.upload_backup), # 上传文件(兼容小文件)
|
|
114
|
+
"/backup/upload/init": ("POST", self.upload_init), # 分片上传初始化
|
|
115
|
+
"/backup/upload/chunk": ("POST", self.upload_chunk), # 上传分片
|
|
116
|
+
"/backup/upload/complete": ("POST", self.upload_complete), # 完成分片上传
|
|
117
|
+
"/backup/upload/abort": ("POST", self.upload_abort), # 取消上传
|
|
97
118
|
"/backup/check": ("POST", self.check_backup), # 预检查
|
|
98
119
|
"/backup/import": ("POST", self.import_backup), # 确认导入
|
|
99
120
|
"/backup/progress": ("GET", self.get_progress),
|
|
100
121
|
"/backup/download": ("GET", self.download_backup),
|
|
101
122
|
"/backup/delete": ("POST", self.delete_backup),
|
|
123
|
+
"/backup/rename": ("POST", self.rename_backup), # 重命名备份
|
|
102
124
|
}
|
|
103
125
|
self.register_routes()
|
|
104
126
|
|
|
@@ -173,7 +195,81 @@ class BackupRoute(Route):
|
|
|
173
195
|
|
|
174
196
|
return _callback
|
|
175
197
|
|
|
198
|
+
def _ensure_cleanup_task_started(self):
|
|
199
|
+
"""确保后台清理任务已启动(在异步上下文中延迟启动)"""
|
|
200
|
+
if self._cleanup_task is None or self._cleanup_task.done():
|
|
201
|
+
try:
|
|
202
|
+
self._cleanup_task = asyncio.create_task(
|
|
203
|
+
self._cleanup_expired_uploads()
|
|
204
|
+
)
|
|
205
|
+
except RuntimeError:
|
|
206
|
+
# 如果没有运行中的事件循环,跳过(等待下次异步调用时启动)
|
|
207
|
+
pass
|
|
208
|
+
|
|
209
|
+
async def _cleanup_expired_uploads(self):
|
|
210
|
+
"""定期清理过期的上传会话
|
|
211
|
+
|
|
212
|
+
基于 last_activity 字段判断过期,避免清理活跃的上传会话。
|
|
213
|
+
"""
|
|
214
|
+
while True:
|
|
215
|
+
try:
|
|
216
|
+
await asyncio.sleep(300) # 每5分钟检查一次
|
|
217
|
+
current_time = time.time()
|
|
218
|
+
expired_sessions = []
|
|
219
|
+
|
|
220
|
+
for upload_id, session in self.upload_sessions.items():
|
|
221
|
+
# 使用 last_activity 判断过期,而非 created_at
|
|
222
|
+
last_activity = session.get("last_activity", session["created_at"])
|
|
223
|
+
if current_time - last_activity > UPLOAD_EXPIRE_SECONDS:
|
|
224
|
+
expired_sessions.append(upload_id)
|
|
225
|
+
|
|
226
|
+
for upload_id in expired_sessions:
|
|
227
|
+
await self._cleanup_upload_session(upload_id)
|
|
228
|
+
logger.info(f"清理过期的上传会话: {upload_id}")
|
|
229
|
+
|
|
230
|
+
except asyncio.CancelledError:
|
|
231
|
+
# 任务被取消,正常退出
|
|
232
|
+
break
|
|
233
|
+
except Exception as e:
|
|
234
|
+
logger.error(f"清理过期上传会话失败: {e}")
|
|
235
|
+
|
|
236
|
+
async def _cleanup_upload_session(self, upload_id: str):
|
|
237
|
+
"""清理上传会话"""
|
|
238
|
+
if upload_id in self.upload_sessions:
|
|
239
|
+
session = self.upload_sessions[upload_id]
|
|
240
|
+
chunk_dir = session.get("chunk_dir")
|
|
241
|
+
if chunk_dir and os.path.exists(chunk_dir):
|
|
242
|
+
try:
|
|
243
|
+
shutil.rmtree(chunk_dir)
|
|
244
|
+
except Exception as e:
|
|
245
|
+
logger.warning(f"清理分片目录失败: {e}")
|
|
246
|
+
del self.upload_sessions[upload_id]
|
|
247
|
+
|
|
248
|
+
def _get_backup_manifest(self, zip_path: str) -> dict | None:
|
|
249
|
+
"""从备份文件读取 manifest.json
|
|
250
|
+
|
|
251
|
+
Args:
|
|
252
|
+
zip_path: ZIP 文件路径
|
|
253
|
+
|
|
254
|
+
Returns:
|
|
255
|
+
dict | None: manifest 内容,如果不是有效备份则返回 None
|
|
256
|
+
"""
|
|
257
|
+
try:
|
|
258
|
+
with zipfile.ZipFile(zip_path, "r") as zf:
|
|
259
|
+
if "manifest.json" in zf.namelist():
|
|
260
|
+
manifest_data = zf.read("manifest.json")
|
|
261
|
+
return json.loads(manifest_data.decode("utf-8"))
|
|
262
|
+
else:
|
|
263
|
+
# 没有 manifest.json,不是有效的 AstrBot 备份
|
|
264
|
+
return None
|
|
265
|
+
except Exception as e:
|
|
266
|
+
logger.debug(f"读取备份 manifest 失败: {e}")
|
|
267
|
+
return None # 无法读取,不是有效备份
|
|
268
|
+
|
|
176
269
|
async def list_backups(self):
|
|
270
|
+
# 确保后台清理任务已启动
|
|
271
|
+
self._ensure_cleanup_task_started()
|
|
272
|
+
|
|
177
273
|
"""获取备份列表
|
|
178
274
|
|
|
179
275
|
Query 参数:
|
|
@@ -190,16 +286,34 @@ class BackupRoute(Route):
|
|
|
190
286
|
# 获取所有备份文件
|
|
191
287
|
backup_files = []
|
|
192
288
|
for filename in os.listdir(self.backup_dir):
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
289
|
+
# 只处理 .zip 文件,排除隐藏文件和目录
|
|
290
|
+
if not filename.endswith(".zip") or filename.startswith("."):
|
|
291
|
+
continue
|
|
292
|
+
|
|
293
|
+
file_path = os.path.join(self.backup_dir, filename)
|
|
294
|
+
if not os.path.isfile(file_path):
|
|
295
|
+
continue
|
|
296
|
+
|
|
297
|
+
# 读取 manifest.json 获取备份信息
|
|
298
|
+
# 如果返回 None,说明不是有效的 AstrBot 备份,跳过
|
|
299
|
+
manifest = self._get_backup_manifest(file_path)
|
|
300
|
+
if manifest is None:
|
|
301
|
+
logger.debug(f"跳过无效备份文件: {filename}")
|
|
302
|
+
continue
|
|
303
|
+
|
|
304
|
+
stat = os.stat(file_path)
|
|
305
|
+
backup_files.append(
|
|
306
|
+
{
|
|
307
|
+
"filename": filename,
|
|
308
|
+
"size": stat.st_size,
|
|
309
|
+
"created_at": stat.st_mtime,
|
|
310
|
+
"type": manifest.get(
|
|
311
|
+
"origin", "exported"
|
|
312
|
+
), # 老版本没有 origin 默认为 exported
|
|
313
|
+
"astrbot_version": manifest.get("astrbot_version", "未知"),
|
|
314
|
+
"exported_at": manifest.get("exported_at"),
|
|
315
|
+
}
|
|
316
|
+
)
|
|
203
317
|
|
|
204
318
|
# 按创建时间倒序排序
|
|
205
319
|
backup_files.sort(key=lambda x: x["created_at"], reverse=True)
|
|
@@ -345,6 +459,309 @@ class BackupRoute(Route):
|
|
|
345
459
|
logger.error(traceback.format_exc())
|
|
346
460
|
return Response().error(f"上传备份文件失败: {e!s}").__dict__
|
|
347
461
|
|
|
462
|
+
async def upload_init(self):
|
|
463
|
+
"""初始化分片上传
|
|
464
|
+
|
|
465
|
+
创建一个上传会话,返回 upload_id 供后续分片上传使用。
|
|
466
|
+
|
|
467
|
+
JSON Body:
|
|
468
|
+
- filename: 原始文件名
|
|
469
|
+
- total_size: 文件总大小(字节)
|
|
470
|
+
|
|
471
|
+
返回:
|
|
472
|
+
- upload_id: 上传会话 ID
|
|
473
|
+
- chunk_size: 分片大小(由后端决定)
|
|
474
|
+
- total_chunks: 分片总数(由后端根据 total_size 和 chunk_size 计算)
|
|
475
|
+
"""
|
|
476
|
+
try:
|
|
477
|
+
data = await request.json
|
|
478
|
+
filename = data.get("filename")
|
|
479
|
+
total_size = data.get("total_size", 0)
|
|
480
|
+
|
|
481
|
+
if not filename:
|
|
482
|
+
return Response().error("缺少 filename 参数").__dict__
|
|
483
|
+
|
|
484
|
+
if not filename.endswith(".zip"):
|
|
485
|
+
return Response().error("请上传 ZIP 格式的备份文件").__dict__
|
|
486
|
+
|
|
487
|
+
if total_size <= 0:
|
|
488
|
+
return Response().error("无效的文件大小").__dict__
|
|
489
|
+
|
|
490
|
+
# 由后端计算分片总数,确保前后端一致
|
|
491
|
+
import math
|
|
492
|
+
|
|
493
|
+
total_chunks = math.ceil(total_size / CHUNK_SIZE)
|
|
494
|
+
|
|
495
|
+
# 生成上传 ID
|
|
496
|
+
upload_id = str(uuid.uuid4())
|
|
497
|
+
|
|
498
|
+
# 创建分片存储目录
|
|
499
|
+
chunk_dir = os.path.join(self.chunks_dir, upload_id)
|
|
500
|
+
Path(chunk_dir).mkdir(parents=True, exist_ok=True)
|
|
501
|
+
|
|
502
|
+
# 清洗文件名
|
|
503
|
+
safe_filename = secure_filename(filename)
|
|
504
|
+
unique_filename = generate_unique_filename(safe_filename)
|
|
505
|
+
|
|
506
|
+
# 创建上传会话
|
|
507
|
+
current_time = time.time()
|
|
508
|
+
self.upload_sessions[upload_id] = {
|
|
509
|
+
"filename": unique_filename,
|
|
510
|
+
"original_filename": filename,
|
|
511
|
+
"total_size": total_size,
|
|
512
|
+
"total_chunks": total_chunks,
|
|
513
|
+
"received_chunks": set(),
|
|
514
|
+
"created_at": current_time,
|
|
515
|
+
"last_activity": current_time, # 用于判断会话是否活跃
|
|
516
|
+
"chunk_dir": chunk_dir,
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
logger.info(
|
|
520
|
+
f"初始化分片上传: upload_id={upload_id}, "
|
|
521
|
+
f"filename={unique_filename}, total_chunks={total_chunks}"
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
return (
|
|
525
|
+
Response()
|
|
526
|
+
.ok(
|
|
527
|
+
{
|
|
528
|
+
"upload_id": upload_id,
|
|
529
|
+
"chunk_size": CHUNK_SIZE,
|
|
530
|
+
"total_chunks": total_chunks,
|
|
531
|
+
"filename": unique_filename,
|
|
532
|
+
}
|
|
533
|
+
)
|
|
534
|
+
.__dict__
|
|
535
|
+
)
|
|
536
|
+
except Exception as e:
|
|
537
|
+
logger.error(f"初始化分片上传失败: {e}")
|
|
538
|
+
logger.error(traceback.format_exc())
|
|
539
|
+
return Response().error(f"初始化分片上传失败: {e!s}").__dict__
|
|
540
|
+
|
|
541
|
+
async def upload_chunk(self):
|
|
542
|
+
"""上传分片
|
|
543
|
+
|
|
544
|
+
上传单个分片数据。
|
|
545
|
+
|
|
546
|
+
Form Data:
|
|
547
|
+
- upload_id: 上传会话 ID
|
|
548
|
+
- chunk_index: 分片索引(从 0 开始)
|
|
549
|
+
- chunk: 分片数据
|
|
550
|
+
|
|
551
|
+
返回:
|
|
552
|
+
- received: 已接收的分片数量
|
|
553
|
+
- total: 分片总数
|
|
554
|
+
"""
|
|
555
|
+
try:
|
|
556
|
+
form = await request.form
|
|
557
|
+
files = await request.files
|
|
558
|
+
|
|
559
|
+
upload_id = form.get("upload_id")
|
|
560
|
+
chunk_index_str = form.get("chunk_index")
|
|
561
|
+
|
|
562
|
+
if not upload_id or chunk_index_str is None:
|
|
563
|
+
return Response().error("缺少必要参数").__dict__
|
|
564
|
+
|
|
565
|
+
try:
|
|
566
|
+
chunk_index = int(chunk_index_str)
|
|
567
|
+
except ValueError:
|
|
568
|
+
return Response().error("无效的分片索引").__dict__
|
|
569
|
+
|
|
570
|
+
if "chunk" not in files:
|
|
571
|
+
return Response().error("缺少分片数据").__dict__
|
|
572
|
+
|
|
573
|
+
# 验证上传会话
|
|
574
|
+
if upload_id not in self.upload_sessions:
|
|
575
|
+
return Response().error("上传会话不存在或已过期").__dict__
|
|
576
|
+
|
|
577
|
+
session = self.upload_sessions[upload_id]
|
|
578
|
+
|
|
579
|
+
# 验证分片索引
|
|
580
|
+
if chunk_index < 0 or chunk_index >= session["total_chunks"]:
|
|
581
|
+
return Response().error("分片索引超出范围").__dict__
|
|
582
|
+
|
|
583
|
+
# 保存分片
|
|
584
|
+
chunk_file = files["chunk"]
|
|
585
|
+
chunk_path = os.path.join(session["chunk_dir"], f"{chunk_index}.part")
|
|
586
|
+
await chunk_file.save(chunk_path)
|
|
587
|
+
|
|
588
|
+
# 记录已接收的分片,并更新最后活动时间
|
|
589
|
+
session["received_chunks"].add(chunk_index)
|
|
590
|
+
session["last_activity"] = time.time() # 刷新活动时间,防止活跃上传被清理
|
|
591
|
+
|
|
592
|
+
received_count = len(session["received_chunks"])
|
|
593
|
+
total_chunks = session["total_chunks"]
|
|
594
|
+
|
|
595
|
+
logger.debug(
|
|
596
|
+
f"接收分片: upload_id={upload_id}, "
|
|
597
|
+
f"chunk={chunk_index + 1}/{total_chunks}"
|
|
598
|
+
)
|
|
599
|
+
|
|
600
|
+
return (
|
|
601
|
+
Response()
|
|
602
|
+
.ok(
|
|
603
|
+
{
|
|
604
|
+
"received": received_count,
|
|
605
|
+
"total": total_chunks,
|
|
606
|
+
"chunk_index": chunk_index,
|
|
607
|
+
}
|
|
608
|
+
)
|
|
609
|
+
.__dict__
|
|
610
|
+
)
|
|
611
|
+
except Exception as e:
|
|
612
|
+
logger.error(f"上传分片失败: {e}")
|
|
613
|
+
logger.error(traceback.format_exc())
|
|
614
|
+
return Response().error(f"上传分片失败: {e!s}").__dict__
|
|
615
|
+
|
|
616
|
+
def _mark_backup_as_uploaded(self, zip_path: str) -> None:
|
|
617
|
+
"""修改备份文件的 manifest.json,将 origin 设置为 uploaded
|
|
618
|
+
|
|
619
|
+
使用 zipfile 的 append 模式添加新的 manifest.json,
|
|
620
|
+
ZIP 规范中后添加的同名文件会覆盖先前的文件。
|
|
621
|
+
|
|
622
|
+
Args:
|
|
623
|
+
zip_path: ZIP 文件路径
|
|
624
|
+
"""
|
|
625
|
+
try:
|
|
626
|
+
# 读取原有 manifest
|
|
627
|
+
manifest = {"origin": "uploaded", "uploaded_at": datetime.now().isoformat()}
|
|
628
|
+
with zipfile.ZipFile(zip_path, "r") as zf:
|
|
629
|
+
if "manifest.json" in zf.namelist():
|
|
630
|
+
manifest_data = zf.read("manifest.json")
|
|
631
|
+
manifest = json.loads(manifest_data.decode("utf-8"))
|
|
632
|
+
manifest["origin"] = "uploaded"
|
|
633
|
+
manifest["uploaded_at"] = datetime.now().isoformat()
|
|
634
|
+
|
|
635
|
+
# 使用 append 模式添加新的 manifest.json
|
|
636
|
+
# ZIP 规范中,后添加的同名文件会覆盖先前的
|
|
637
|
+
with zipfile.ZipFile(zip_path, "a") as zf:
|
|
638
|
+
new_manifest = json.dumps(manifest, ensure_ascii=False, indent=2)
|
|
639
|
+
zf.writestr("manifest.json", new_manifest)
|
|
640
|
+
|
|
641
|
+
logger.debug(f"已标记备份为上传来源: {zip_path}")
|
|
642
|
+
except Exception as e:
|
|
643
|
+
logger.warning(f"标记备份来源失败: {e}")
|
|
644
|
+
|
|
645
|
+
async def upload_complete(self):
|
|
646
|
+
"""完成分片上传
|
|
647
|
+
|
|
648
|
+
合并所有分片为完整文件。
|
|
649
|
+
|
|
650
|
+
JSON Body:
|
|
651
|
+
- upload_id: 上传会话 ID
|
|
652
|
+
|
|
653
|
+
返回:
|
|
654
|
+
- filename: 合并后的文件名
|
|
655
|
+
- size: 文件大小
|
|
656
|
+
"""
|
|
657
|
+
try:
|
|
658
|
+
data = await request.json
|
|
659
|
+
upload_id = data.get("upload_id")
|
|
660
|
+
|
|
661
|
+
if not upload_id:
|
|
662
|
+
return Response().error("缺少 upload_id 参数").__dict__
|
|
663
|
+
|
|
664
|
+
# 验证上传会话
|
|
665
|
+
if upload_id not in self.upload_sessions:
|
|
666
|
+
return Response().error("上传会话不存在或已过期").__dict__
|
|
667
|
+
|
|
668
|
+
session = self.upload_sessions[upload_id]
|
|
669
|
+
|
|
670
|
+
# 检查是否所有分片都已接收
|
|
671
|
+
received = session["received_chunks"]
|
|
672
|
+
total = session["total_chunks"]
|
|
673
|
+
|
|
674
|
+
if len(received) != total:
|
|
675
|
+
missing = set(range(total)) - received
|
|
676
|
+
return (
|
|
677
|
+
Response()
|
|
678
|
+
.error(f"分片不完整,缺少: {sorted(missing)[:10]}...")
|
|
679
|
+
.__dict__
|
|
680
|
+
)
|
|
681
|
+
|
|
682
|
+
# 合并分片
|
|
683
|
+
chunk_dir = session["chunk_dir"]
|
|
684
|
+
filename = session["filename"]
|
|
685
|
+
|
|
686
|
+
Path(self.backup_dir).mkdir(parents=True, exist_ok=True)
|
|
687
|
+
output_path = os.path.join(self.backup_dir, filename)
|
|
688
|
+
|
|
689
|
+
try:
|
|
690
|
+
with open(output_path, "wb") as outfile:
|
|
691
|
+
for i in range(total):
|
|
692
|
+
chunk_path = os.path.join(chunk_dir, f"{i}.part")
|
|
693
|
+
with open(chunk_path, "rb") as chunk_file:
|
|
694
|
+
# 分块读取,避免内存溢出
|
|
695
|
+
while True:
|
|
696
|
+
data_block = chunk_file.read(8192)
|
|
697
|
+
if not data_block:
|
|
698
|
+
break
|
|
699
|
+
outfile.write(data_block)
|
|
700
|
+
|
|
701
|
+
file_size = os.path.getsize(output_path)
|
|
702
|
+
|
|
703
|
+
# 标记备份为上传来源(修改 manifest.json 中的 origin 字段)
|
|
704
|
+
self._mark_backup_as_uploaded(output_path)
|
|
705
|
+
|
|
706
|
+
logger.info(
|
|
707
|
+
f"分片上传完成: {filename}, size={file_size}, chunks={total}"
|
|
708
|
+
)
|
|
709
|
+
|
|
710
|
+
# 清理分片目录
|
|
711
|
+
await self._cleanup_upload_session(upload_id)
|
|
712
|
+
|
|
713
|
+
return (
|
|
714
|
+
Response()
|
|
715
|
+
.ok(
|
|
716
|
+
{
|
|
717
|
+
"filename": filename,
|
|
718
|
+
"original_filename": session["original_filename"],
|
|
719
|
+
"size": file_size,
|
|
720
|
+
}
|
|
721
|
+
)
|
|
722
|
+
.__dict__
|
|
723
|
+
)
|
|
724
|
+
except Exception as e:
|
|
725
|
+
# 如果合并失败,删除不完整的文件
|
|
726
|
+
if os.path.exists(output_path):
|
|
727
|
+
os.remove(output_path)
|
|
728
|
+
raise e
|
|
729
|
+
|
|
730
|
+
except Exception as e:
|
|
731
|
+
logger.error(f"完成分片上传失败: {e}")
|
|
732
|
+
logger.error(traceback.format_exc())
|
|
733
|
+
return Response().error(f"完成分片上传失败: {e!s}").__dict__
|
|
734
|
+
|
|
735
|
+
async def upload_abort(self):
|
|
736
|
+
"""取消分片上传
|
|
737
|
+
|
|
738
|
+
取消上传并清理已上传的分片。
|
|
739
|
+
|
|
740
|
+
JSON Body:
|
|
741
|
+
- upload_id: 上传会话 ID
|
|
742
|
+
"""
|
|
743
|
+
try:
|
|
744
|
+
data = await request.json
|
|
745
|
+
upload_id = data.get("upload_id")
|
|
746
|
+
|
|
747
|
+
if not upload_id:
|
|
748
|
+
return Response().error("缺少 upload_id 参数").__dict__
|
|
749
|
+
|
|
750
|
+
if upload_id not in self.upload_sessions:
|
|
751
|
+
# 会话已不存在,可能已过期或已完成
|
|
752
|
+
return Response().ok(message="上传已取消").__dict__
|
|
753
|
+
|
|
754
|
+
# 清理会话
|
|
755
|
+
await self._cleanup_upload_session(upload_id)
|
|
756
|
+
|
|
757
|
+
logger.info(f"取消分片上传: {upload_id}")
|
|
758
|
+
|
|
759
|
+
return Response().ok(message="上传已取消").__dict__
|
|
760
|
+
except Exception as e:
|
|
761
|
+
logger.error(f"取消上传失败: {e}")
|
|
762
|
+
logger.error(traceback.format_exc())
|
|
763
|
+
return Response().error(f"取消上传失败: {e!s}").__dict__
|
|
764
|
+
|
|
348
765
|
async def check_backup(self):
|
|
349
766
|
"""预检查备份文件
|
|
350
767
|
|
|
@@ -537,12 +954,33 @@ class BackupRoute(Route):
|
|
|
537
954
|
|
|
538
955
|
Query 参数:
|
|
539
956
|
- filename: 备份文件名 (必填)
|
|
957
|
+
- token: JWT token (必填,用于浏览器原生下载鉴权)
|
|
958
|
+
|
|
959
|
+
注意: 此路由已被添加到 auth_middleware 白名单中,
|
|
960
|
+
使用 URL 参数中的 token 进行鉴权,以支持浏览器原生下载。
|
|
540
961
|
"""
|
|
541
962
|
try:
|
|
542
963
|
filename = request.args.get("filename")
|
|
964
|
+
token = request.args.get("token")
|
|
965
|
+
|
|
543
966
|
if not filename:
|
|
544
967
|
return Response().error("缺少参数 filename").__dict__
|
|
545
968
|
|
|
969
|
+
if not token:
|
|
970
|
+
return Response().error("缺少参数 token").__dict__
|
|
971
|
+
|
|
972
|
+
# 验证 JWT token
|
|
973
|
+
try:
|
|
974
|
+
jwt_secret = self.config.get("dashboard", {}).get("jwt_secret")
|
|
975
|
+
if not jwt_secret:
|
|
976
|
+
return Response().error("服务器配置错误").__dict__
|
|
977
|
+
|
|
978
|
+
jwt.decode(token, jwt_secret, algorithms=["HS256"])
|
|
979
|
+
except jwt.ExpiredSignatureError:
|
|
980
|
+
return Response().error("Token 已过期,请刷新页面后重试").__dict__
|
|
981
|
+
except jwt.InvalidTokenError:
|
|
982
|
+
return Response().error("Token 无效").__dict__
|
|
983
|
+
|
|
546
984
|
# 安全检查 - 防止路径遍历
|
|
547
985
|
if ".." in filename or "/" in filename or "\\" in filename:
|
|
548
986
|
return Response().error("无效的文件名").__dict__
|
|
@@ -587,3 +1025,69 @@ class BackupRoute(Route):
|
|
|
587
1025
|
logger.error(f"删除备份失败: {e}")
|
|
588
1026
|
logger.error(traceback.format_exc())
|
|
589
1027
|
return Response().error(f"删除备份失败: {e!s}").__dict__
|
|
1028
|
+
|
|
1029
|
+
async def rename_backup(self):
|
|
1030
|
+
"""重命名备份文件
|
|
1031
|
+
|
|
1032
|
+
Body:
|
|
1033
|
+
- filename: 当前文件名 (必填)
|
|
1034
|
+
- new_name: 新文件名 (必填,不含扩展名)
|
|
1035
|
+
"""
|
|
1036
|
+
try:
|
|
1037
|
+
data = await request.json
|
|
1038
|
+
filename = data.get("filename")
|
|
1039
|
+
new_name = data.get("new_name")
|
|
1040
|
+
|
|
1041
|
+
if not filename:
|
|
1042
|
+
return Response().error("缺少参数 filename").__dict__
|
|
1043
|
+
|
|
1044
|
+
if not new_name:
|
|
1045
|
+
return Response().error("缺少参数 new_name").__dict__
|
|
1046
|
+
|
|
1047
|
+
# 安全检查 - 防止路径遍历
|
|
1048
|
+
if ".." in filename or "/" in filename or "\\" in filename:
|
|
1049
|
+
return Response().error("无效的文件名").__dict__
|
|
1050
|
+
|
|
1051
|
+
# 清洗新文件名(移除路径和危险字符)
|
|
1052
|
+
new_name = secure_filename(new_name)
|
|
1053
|
+
|
|
1054
|
+
# 移除新文件名中的扩展名(如果有的话)
|
|
1055
|
+
if new_name.endswith(".zip"):
|
|
1056
|
+
new_name = new_name[:-4]
|
|
1057
|
+
|
|
1058
|
+
# 验证新文件名不为空
|
|
1059
|
+
if not new_name or new_name.replace("_", "") == "":
|
|
1060
|
+
return Response().error("新文件名无效").__dict__
|
|
1061
|
+
|
|
1062
|
+
# 强制使用 .zip 扩展名
|
|
1063
|
+
new_filename = f"{new_name}.zip"
|
|
1064
|
+
|
|
1065
|
+
# 检查原文件是否存在
|
|
1066
|
+
old_path = os.path.join(self.backup_dir, filename)
|
|
1067
|
+
if not os.path.exists(old_path):
|
|
1068
|
+
return Response().error("备份文件不存在").__dict__
|
|
1069
|
+
|
|
1070
|
+
# 检查新文件名是否已存在
|
|
1071
|
+
new_path = os.path.join(self.backup_dir, new_filename)
|
|
1072
|
+
if os.path.exists(new_path):
|
|
1073
|
+
return Response().error(f"文件名 '{new_filename}' 已存在").__dict__
|
|
1074
|
+
|
|
1075
|
+
# 执行重命名
|
|
1076
|
+
os.rename(old_path, new_path)
|
|
1077
|
+
|
|
1078
|
+
logger.info(f"备份文件重命名: {filename} -> {new_filename}")
|
|
1079
|
+
|
|
1080
|
+
return (
|
|
1081
|
+
Response()
|
|
1082
|
+
.ok(
|
|
1083
|
+
{
|
|
1084
|
+
"old_filename": filename,
|
|
1085
|
+
"new_filename": new_filename,
|
|
1086
|
+
}
|
|
1087
|
+
)
|
|
1088
|
+
.__dict__
|
|
1089
|
+
)
|
|
1090
|
+
except Exception as e:
|
|
1091
|
+
logger.error(f"重命名备份失败: {e}")
|
|
1092
|
+
logger.error(traceback.format_exc())
|
|
1093
|
+
return Response().error(f"重命名备份失败: {e!s}").__dict__
|
|
@@ -46,6 +46,46 @@ def try_cast(value: Any, type_: str):
|
|
|
46
46
|
return None
|
|
47
47
|
|
|
48
48
|
|
|
49
|
+
def _expect_type(value, expected_type, path_key, errors, expected_name=None):
|
|
50
|
+
if not isinstance(value, expected_type):
|
|
51
|
+
errors.append(
|
|
52
|
+
f"错误的类型 {path_key}: 期望是 {expected_name or expected_type.__name__}, "
|
|
53
|
+
f"得到了 {type(value).__name__}"
|
|
54
|
+
)
|
|
55
|
+
return False
|
|
56
|
+
return True
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _validate_template_list(value, meta, path_key, errors, validate_fn):
|
|
60
|
+
if not _expect_type(value, list, path_key, errors, "list"):
|
|
61
|
+
return
|
|
62
|
+
|
|
63
|
+
templates = meta.get("templates")
|
|
64
|
+
if not isinstance(templates, dict):
|
|
65
|
+
templates = {}
|
|
66
|
+
|
|
67
|
+
for idx, item in enumerate(value):
|
|
68
|
+
item_path = f"{path_key}[{idx}]"
|
|
69
|
+
if not _expect_type(item, dict, item_path, errors, "dict"):
|
|
70
|
+
continue
|
|
71
|
+
|
|
72
|
+
template_key = item.get("__template_key") or item.get("template")
|
|
73
|
+
if not template_key:
|
|
74
|
+
errors.append(f"缺少模板选择 {item_path}: 需要 __template_key")
|
|
75
|
+
continue
|
|
76
|
+
|
|
77
|
+
template_meta = templates.get(template_key)
|
|
78
|
+
if not template_meta:
|
|
79
|
+
errors.append(f"未知模板 {item_path}: {template_key}")
|
|
80
|
+
continue
|
|
81
|
+
|
|
82
|
+
validate_fn(
|
|
83
|
+
item,
|
|
84
|
+
template_meta.get("items", {}),
|
|
85
|
+
path=f"{item_path}.",
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
49
89
|
def validate_config(data, schema: dict, is_core: bool) -> tuple[list[str], dict]:
|
|
50
90
|
errors = []
|
|
51
91
|
|
|
@@ -61,6 +101,11 @@ def validate_config(data, schema: dict, is_core: bool) -> tuple[list[str], dict]
|
|
|
61
101
|
if value is None:
|
|
62
102
|
data[key] = DEFAULT_VALUE_MAP[meta["type"]]
|
|
63
103
|
continue
|
|
104
|
+
|
|
105
|
+
if meta["type"] == "template_list":
|
|
106
|
+
_validate_template_list(value, meta, f"{path}{key}", errors, validate)
|
|
107
|
+
continue
|
|
108
|
+
|
|
64
109
|
if meta["type"] == "list" and not isinstance(value, list):
|
|
65
110
|
errors.append(
|
|
66
111
|
f"错误的类型 {path}{key}: 期望是 list, 得到了 {type(value).__name__}",
|
astrbot/dashboard/server.py
CHANGED