File size: 14,103 Bytes
ac41afc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8213c64
 
 
ac41afc
 
8213c64
ac41afc
8213c64
 
ac41afc
 
 
8213c64
 
 
 
725aa10
8213c64
ac41afc
 
8213c64
 
ac41afc
8213c64
 
ac41afc
 
 
 
 
8213c64
 
ac41afc
8213c64
 
ac41afc
 
 
 
 
 
 
 
 
 
fb0cf86
 
 
ac41afc
 
8213c64
 
 
ac41afc
 
 
 
 
8213c64
ac41afc
 
 
 
 
 
 
 
cc2f2c0
 
 
8213c64
 
 
ac41afc
 
 
 
 
 
 
 
 
 
 
 
 
8213c64
 
 
 
ac41afc
 
 
 
 
 
 
 
 
 
8213c64
 
ac41afc
 
 
 
 
 
 
 
8213c64
 
 
ac41afc
 
 
 
 
8213c64
 
 
ac41afc
 
 
 
8213c64
 
ac41afc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8213c64
 
 
ac41afc
 
8213c64
ac41afc
725aa10
ac41afc
 
 
 
 
 
 
 
 
 
 
 
 
8213c64
ac41afc
 
 
 
 
 
 
 
 
 
8213c64
ac41afc
 
 
 
 
 
 
 
 
 
 
8213c64
 
ac41afc
 
8213c64
 
ac41afc
8213c64
 
ac41afc
8213c64
 
 
ac41afc
8213c64
ac41afc
 
 
 
8213c64
ac41afc
 
 
 
8213c64
ac41afc
8213c64
ac41afc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8213c64
ac41afc
8213c64
ac41afc
 
 
 
8213c64
 
 
 
 
ac41afc
725aa10
ac41afc
 
 
 
 
 
 
 
 
 
 
 
8213c64
ac41afc
8213c64
 
ac41afc
8213c64
 
 
ac41afc
 
 
 
 
 
8213c64
 
ac41afc
 
8213c64
 
 
ac41afc
 
 
 
8213c64
 
 
ac41afc
 
 
 
 
 
 
 
 
 
 
8213c64
 
 
 
ac41afc
 
 
 
8213c64
 
ac41afc
 
8213c64
 
ac41afc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8213c64
ac41afc
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
"""v5 备份 API:/u/api/backups/v5/*

方案 C:文件级细粒度 blob + Manifest + ZIP 整包恢复通道。

端点(11 个):
- GET    /sets                            列出当前用户备份集
- POST   /sets                            创建待提交 set
- GET    /sets/{setId}                    获取指定 set 的完整 manifest
- DELETE /sets/{setId}                    删除备份集(refs--)
- POST   /sets/{setId}/manifest           提交 manifest,原子标记为最新
- GET    /sets/{setId}/archive            ZIP 整包下载(流式)
- POST   /blobs/upload                    上传单个 blob(multipart),返回 key=sha256
- GET    /blobs/{key}                     下载单个 blob 原始 bytes
- GET    /blobs/{key}/meta                查询 blob 元信息
- POST   /blobs/check                     批量查询 blob 是否已存在(按 key)
- POST   /blobs/batch_check               卸载重装兜底(按 path+size+mtime)
"""
from __future__ import annotations

import asyncio
import json
import logging
import urllib.parse

from fastapi import APIRouter, Depends, File, Form, Request, UploadFile
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
from pydantic import BaseModel, Field
from typing import List, Optional

from ..errors import HttpError
from ..services import backup_v5_store
from ._common import CORS_HEADERS, ok_with_cors, read_json_body
from .user_files import require_user, require_user_for_download

logger = logging.getLogger(__name__)

router = APIRouter(prefix="/u/api/backups/v5", tags=["user-backups-v5"])

MAX_BLOB_SIZE = 10 * 1024 * 1024  # 框架硬限制


def _err(code: str, message: str, status: int = 400, **extra) -> HttpError:
    detail = {"code": code, "message": message}
    if extra:
        detail["details"] = extra
    return HttpError(message, status=status, code=code)


# ===== sets =====

@router.get("/sets")
async def list_sets(request: Request):
    payload = require_user(request)
    user_id = str(payload.get("sub") or "")
    if not user_id:
        raise _err("unauthorized", "missing user", status=401)
    try:
        limit = int(request.query_params.get("limit") or "20")
        offset = int(request.query_params.get("offset") or "0")
    except Exception:
        limit, offset = 20, 0
    # 本地 DB 为空时触发 Hub 同步(HF Space 重建后 DB 丢失,Hub 上 manifest 仍在)
    if backup_v5_store.count_sets(user_id) == 0:
        asyncio.create_task(backup_v5_store._sync_manifests_background())
    items = backup_v5_store.list_sets(user_id, limit=limit, offset=offset)
    return ok_with_cors({"items": items, "count": len(items), "total": len(items)})


@router.post("/sets")
async def create_set(request: Request):
    payload = require_user(request)
    user_id = str(payload.get("sub") or "")
    if not user_id:
        raise _err("unauthorized", "missing user", status=401)
    body = await read_json_body(request)
    alias = str(body.get("alias") or "").strip()[:200]
    try:
        result = backup_v5_store.create_pending_set(user_id, alias=alias)
    except ValueError as e:
        msg = str(e)
        if msg == "quota_exceeded":
            raise _err("quota_exceeded", "已达备份集上限(5 份),请先删除旧的备份", status=409)
        raise _err("create_failed", msg, status=500)
    # 注意:小天才 fetch.fetch 对非 200 的 2xx(如 201)会触发 fail 回调而非 success,
    # 导致前端报"网络错误 201 Created"。这里统一返回 200 保证兼容。
    return ok_with_cors(result, status=200)


@router.get("/sets/{set_id}")
async def get_set(set_id: str, request: Request):
    payload = require_user(request)
    user_id = str(payload.get("sub") or "")
    if not user_id:
        raise _err("unauthorized", "missing user", status=401)
    owner = backup_v5_store.get_set_owner(set_id)
    if not owner:
        raise _err("set_not_found", "备份集不存在", status=404)
    if owner != user_id:
        raise _err("forbidden", "无权访问他人备份", status=403)
    manifest = backup_v5_store.get_set_manifest(set_id)
    if manifest is None:
        raise _err("manifest_missing", "manifest 缺失", status=500)
    return ok_with_cors({"manifest": manifest})


@router.delete("/sets/{set_id}")
async def delete_set(set_id: str, request: Request):
    payload = require_user(request)
    user_id = str(payload.get("sub") or "")
    if not user_id:
        raise _err("unauthorized", "missing user", status=401)
    owner = backup_v5_store.get_set_owner(set_id)
    if not owner:
        raise _err("set_not_found", "备份集不存在", status=404)
    if owner != user_id:
        raise _err("forbidden", "无权删除他人备份", status=403)
    try:
        result = backup_v5_store.delete_set(set_id, user_id)
    except ValueError as e:
        msg = str(e)
        if msg == "set_not_found":
            raise _err("set_not_found", "备份集不存在", status=404)
        if msg == "forbidden":
            raise _err("forbidden", "无权删除他人备份", status=403)
        raise _err("delete_failed", msg, status=500)
    return ok_with_cors({"deleted": True, "setId": set_id, **result})


@router.post("/sets/{set_id}/manifest")
async def commit_manifest(set_id: str, request: Request):
    payload = require_user(request)
    user_id = str(payload.get("sub") or "")
    if not user_id:
        raise _err("unauthorized", "missing user", status=401)
    body = await read_json_body(request)
    manifest = body.get("manifest")
    if not isinstance(manifest, dict):
        raise _err("invalid_manifest", "manifest 必须是 JSON 对象")
    # 强制 setId 与 set_id 一致
    if str(manifest.get("setId") or "") != set_id:
        manifest["setId"] = set_id
    try:
        result = backup_v5_store.commit_set(set_id, manifest, user_id)
    except ValueError as e:
        msg = str(e)
        if msg == "set_not_pending":
            raise _err("set_not_pending", "set 不在待提交状态(已提交或已过期)")
        if msg == "invalid_manifest":
            raise _err("invalid_manifest", "manifest 格式无效")
        if msg == "invalid_manifest_version":
            raise _err("invalid_manifest_version", "manifest.version 必须为 5")
        if msg == "invalid_manifest_blobs":
            raise _err("invalid_manifest_blobs", "manifest.blobs 必须是数组")
        if msg == "empty_blobs":
            raise _err("empty_blobs", "manifest.blobs 不能为空")
        if msg.startswith("blob_missing:"):
            missing = msg.split(":", 1)[1].split(",")
            raise _err("blob_missing", "manifest 引用了未上传的 blob", missing_keys=missing)
        if msg == "quota_exceeded":
            raise _err("quota_exceeded", "已达备份集上限", status=409)
        raise _err("commit_failed", msg, status=500)
    return ok_with_cors(result)


# ===== ZIP 整包下载 =====

@router.get("/sets/{set_id}/archive")
async def download_archive(set_id: str, request: Request):
    payload = require_user_for_download(request)
    user_id = str(payload.get("sub") or "")
    if not user_id:
        raise _err("unauthorized", "missing user", status=401)
    owner = backup_v5_store.get_set_owner(set_id)
    if not owner:
        raise _err("set_not_found", "备份集不存在", status=404)
    if owner != user_id:
        raise _err("forbidden", "无权下载他人备份", status=403)
    # 检查总大小
    manifest = backup_v5_store.get_set_manifest(set_id)
    if manifest is None:
        raise _err("manifest_missing", "manifest 缺失", status=500)
    total_size = sum(int(b.get("size") or 0) for b in manifest.get("blobs", []))
    if total_size > backup_v5_store.MAX_ZIP_SIZE:
        raise _err(
            "set_too_large",
            f"备份集 {total_size} 字节超过 ZIP 单文件限制 {backup_v5_store.MAX_ZIP_SIZE} 字节,请走 blob 通道",
            status=400,
            totalSize=total_size,
            maxSize=backup_v5_store.MAX_ZIP_SIZE,
        )
    try:
        zip_path, zip_size = await asyncio.to_thread(
            backup_v5_store.build_zip_to_file, set_id
        )
    except ValueError as e:
        msg = str(e)
        if msg == "set_not_found":
            raise _err("set_not_found", "备份集不存在", status=404)
        if msg == "set_too_large":
            raise _err("set_too_large", "备份集过大", status=400)
        raise _err("zip_build_failed", msg, status=500)

    filename = f"backup_{set_id[:8]}.zip"
    # 流式返回,传输完成后由 generator 删除临时文件
    generator = backup_v5_store.zip_iter_chunks(zip_path)

    headers = {
        "Content-Disposition": f'attachment; filename="{filename}"',
        "Content-Length": str(zip_size),
        **CORS_HEADERS,
    }
    return StreamingResponse(generator, media_type="application/zip", headers=headers)


# ===== blobs =====

@router.post("/blobs/upload")
async def upload_blob(
    request: Request,
    file: UploadFile = File(...),
    path: str = Form(""),
    type: str = Form(""),
    alias: Optional[str] = Form(None),
    id: Optional[str] = Form(None),
):
    payload = require_user(request)
    user_id = str(payload.get("sub") or "")
    if not user_id:
        raise _err("unauthorized", "missing user", status=401)

    # 读文件内容(≤10MB,可一次性读)
    content = await file.read()
    if len(content) > MAX_BLOB_SIZE:
        raise _err("blob_too_large", f"blob 超过 {MAX_BLOB_SIZE} 字节", status=413)

    # MIME 推断
    mime = str(file.content_type or "")
    if not mime:
        # 简单根据扩展名推断
        ext = (path or file.filename or "").rsplit(".", 1)[-1].lower() if "." in (path or file.filename or "") else ""
        mime_map = {
            "json": "application/json",
            "txt": "text/plain",
            "md": "text/markdown",
            "png": "image/png",
            "jpg": "image/jpeg",
            "jpeg": "image/jpeg",
            "gif": "image/gif",
            "webp": "image/webp",
            "amr": "audio/amr",
            "mp3": "audio/mpeg",
            "wav": "audio/wav",
        }
        mime = mime_map.get(ext, "application/octet-stream")

    try:
        result = backup_v5_store.store_blob(content, mime, user_id)
    except ValueError as e:
        msg = str(e)
        if msg == "blob_too_large":
            raise _err("blob_too_large", "blob 过大", status=413)
        raise _err("store_failed", msg, status=500)

    return ok_with_cors(result)


@router.get("/blobs/{key}")
async def download_blob(key: str, request: Request):
    payload = require_user_for_download(request)
    user_id = str(payload.get("sub") or "")
    if not user_id:
        raise _err("unauthorized", "missing user", status=401)
    if not _is_sha256_hex(key):
        raise _err("invalid_key", "key 必须是 64 位 sha256 hex", status=400)
    blob_path = backup_v5_store.get_blob_local_path(key)
    if not blob_path or not blob_path.exists():
        raise _err("blob_not_found", "blob 不存在", status=404)
    # 取 mime
    meta = backup_v5_store.get_blob_meta(key)
    mime = (meta or {}).get("mime") or "application/octet-stream"
    filename = blob_path.name
    headers = {
        "Content-Disposition": f'attachment; filename="{filename}"',
        **CORS_HEADERS,
    }
    return FileResponse(str(blob_path), media_type=mime, headers=headers)


@router.get("/blobs/{key}/meta")
async def get_blob_meta(key: str, request: Request):
    payload = require_user(request)
    if not payload.get("sub"):
        raise _err("unauthorized", "missing user", status=401)
    if not _is_sha256_hex(key):
        raise _err("invalid_key", "key 必须是 64 位 sha256 hex", status=400)
    meta = backup_v5_store.get_blob_meta(key)
    if not meta:
        raise _err("blob_not_found", "blob 不存在", status=404)
    return ok_with_cors(meta)


@router.post("/blobs/check")
async def check_blobs(request: Request):
    payload = require_user(request)
    if not payload.get("sub"):
        raise _err("unauthorized", "missing user", status=401)
    body = await read_json_body(request)
    keys = body.get("keys")
    if not isinstance(keys, list):
        raise _err("invalid_keys", "keys 必须是数组")
    if len(keys) > 100:
        raise _err("too_many_keys", "单次最多 100 个 key")
    # 校验每个 key 是合法 sha256 hex
    cleaned = []
    for k in keys:
        s = str(k or "").strip()
        if not _is_sha256_hex(s):
            continue
        cleaned.append(s)
    result = backup_v5_store.check_blobs(cleaned)
    return ok_with_cors(result)


@router.post("/blobs/batch_check")
async def batch_check_blobs(request: Request):
    payload = require_user(request)
    if not payload.get("sub"):
        raise _err("unauthorized", "missing user", status=401)
    body = await read_json_body(request)
    prev_set_id = body.get("prevSetId")
    if prev_set_id is not None:
        prev_set_id = str(prev_set_id).strip() or None
    paths = body.get("paths")
    if not isinstance(paths, list):
        raise _err("invalid_paths", "paths 必须是数组")
    if len(paths) > backup_v5_store.BATCH_CHECK_MAX:
        raise _err("too_many_paths", f"单次最多 {backup_v5_store.BATCH_CHECK_MAX} 个 path")
    # 清洗
    cleaned = []
    for p in paths:
        if not isinstance(p, dict):
            continue
        path = str(p.get("path") or "").strip()
        if not path:
            continue
        try:
            size = int(p.get("size") or 0)
        except Exception:
            size = 0
        try:
            mtime = int(p.get("mtime") or 0)
        except Exception:
            mtime = 0
        cleaned.append({"path": path, "size": size, "mtime": mtime})
    result = backup_v5_store.batch_check_blobs(prev_set_id, cleaned)
    return ok_with_cors(result)


def _is_sha256_hex(s: str) -> bool:
    if len(s) != 64:
        return False
    try:
        int(s, 16)
        return True
    except Exception:
        return False