# -*- coding: utf-8 -*- r'''后台加一个「抢救报告」分区。 用户要求: 「如果紧急预案未能解决,则将问题打包成 .txt,然后在后台加一个分区 专门放还有下载这个 txt」 rescue.sh 抢救失败时会往 relay/rescue_reports/ 写一份诊断报告 .txt。 这里让后台能: * 列出这些报告(时间、大小、一句话结论) * 点一下下载看内容 == 为什么不复用 /download/ == /download/ 是**给用户**的(安装包、APK),带 cf 长缓存 (max-age=604800, immutable)——因为文件名带版本号、内容永不改变。 抢救报告正相反:内容会变、是给管理员看的、不该被边缘缓存。 所以单独开一条 /rescue/<名字>,发 no-store。 ''' from __future__ import annotations import html import sys from pathlib import Path ROOT = Path(__file__).resolve().parent.parent RELAY = ROOT / "relay" / "relay.py" ADMIN = ROOT / "relay" / "admin.py" def patch(path: Path, pairs, label: str): raw = path.read_bytes() had_bom = raw.startswith(b"\xef\xbb\xbf") text = raw.decode("utf-8-sig") results = [] for old, new, what in pairs: if old not in text: results.append(("✗", what, "找不到锚点")) continue if text.count(old) != 1: results.append(("!", what, "锚点 %d 处" % text.count(old))) text = text.replace(old, new, 1) results.append(("✓", what, "")) print(" ── %s ──" % label) for m, w, why in results: print(" %s %s%s" % (m, w, (" —— " + why) if why else "")) if any(m == "✗" for m, _w, _y in results): return False path.write_bytes((b"\xef\xbb\xbf" if had_bom else b"") + text.encode("utf-8")) return True # =========================================================================== # relay.py # =========================================================================== RELAY_LIST = ''' # 抢救报告目录。rescue.sh 抢救失败时往这里写 .txt 诊断报告。 # 和 downloads/ 分开:那个是给用户的安装包(带长缓存), # 这个是给管理员看的诊断(必须不缓存)。 RESCUE_DIR = Path(__file__).resolve().parent / "rescue_reports" RESCUE_KEEP = 30 # 只保留最近 30 份(460MB 的小机器,别塞满) def _safe_rescue_name(name: str) -> str: """校验抢救报告文件名。只允许 <时间戳>.txt 这种形状。""" name = (name or "").strip() if not name or len(name) > 80: return "" if "/" in name or "\\\\" in name or ".." in name: return "" if not name.endswith(".txt"): return "" for ch in name: if ch.isalnum() or ch in "._-": continue return "" return name def _list_rescue_reports() -> list: """列出抢救报告(按时间倒序)。 顺带读每个文件的前几行,抽出结论 —— 后台列表上直接显示, 不用一个个点进去看。 """ items = [] try: if not RESCUE_DIR.is_dir(): return items for entry in sorted(RESCUE_DIR.glob("*.txt"), reverse=True): if not entry.is_file(): continue safe = _safe_rescue_name(entry.name) if not safe: continue stat = entry.stat() summary = "" try: with entry.open("r", encoding="utf-8", errors="replace") as fh: head = fh.read(1200) for line in head.splitlines(): s = line.strip() if s.startswith("判断:"): summary = s.replace("判断:", "").strip() break if not summary: for line in head.splitlines(): s = line.strip() if s.startswith("公网 ") and "->" in s: summary = s break except OSError: pass items.append({ "name": entry.name, "size": stat.st_size, "mtime": stat.st_mtime, "summary": summary[:120], }) except OSError: return items items.sort(key=lambda i: i["mtime"], reverse=True) return items[:RESCUE_KEEP] ''' ADMIN_CALL = ''' admin_path=ADMIN_PATH,''' ADMIN_CALL_NEW = ''' admin_path=ADMIN_PATH, rescue_fn=_list_rescue_reports,''' # 下载路由(GET):插在 /download/ 之前 RESCUE_ROUTE_ANCHOR = ''' if path.startswith("/download/"): wanted = urllib.parse.unquote(path[len("/download/"):]) safe = _safe_download_name(wanted)''' RESCUE_ROUTE = ''' if path.startswith("/rescue/"): # 抢救报告下载。单独一条路由,**不发缓存头** —— # 内容会变,而且不该让 Cloudflare 边缘留着。 wanted = urllib.parse.unquote(path[len("/rescue/"):]) safe = _safe_rescue_name(wanted) if not safe: self._json(400, {"ok": False, "error": "文件名不合法"}) return target = RESCUE_DIR / safe if not target.is_file(): self._json(404, {"ok": False, "error": "没有这份报告"}) return try: body = target.read_bytes() except OSError as exc: self._json(500, {"ok": False, "error": f"读不到: {exc}"}) return self.send_response(200) self.send_header("Content-Type", "text/plain; charset=utf-8") self.send_header("Content-Length", str(len(body))) quoted = urllib.parse.quote(safe) self.send_header( "Content-Disposition", f"attachment; filename*=UTF-8''{quoted}") self.send_header("Cache-Control", "no-store") self.end_headers() self.wfile.write(body) return ''' # =========================================================================== # admin.py # =========================================================================== ADMIN_SIG = '''def render_admin(snap: dict, public: str, admin_path: str, codes: int, downloads_fn) -> str:''' ADMIN_SIG_NEW = '''def render_admin(snap: dict, public: str, admin_path: str, codes: int, downloads_fn, rescue_fn=None) -> str:''' ADMIN_CARD_ANCHOR = '''

下载区

官网下载按钮指向这里的文件。
%(dl)s
''' ADMIN_CARD = '''

下载区

官网下载按钮指向这里的文件。
%(dl)s

抢救报告

网站挂了、自动预案也没救回来时,机器会把现场 打包成一份 txt 放在这里。点开看看是哪一段断了。
%(rescue)s
''' def _rescue_rows(items: list) -> str: """抢救报告列表的 HTML。照 dl_rows 的风格(页面里直接渲染)。""" if not items: return ('
还没有报告 —— 说明网站一直没出过' '「自动救不回来」的问题。
') import time as _t import urllib.parse as _up rows = [] for it in items: when = _t.strftime("%m-%d %H:%M", _t.localtime(it["mtime"])) size = it["size"] / 1024 size_s = ("%.1f KB" % size) if size >= 1 else ("%d B" % it["size"]) summary = html.escape(it.get("summary") or "(没有结论行)") href = "/rescue/" + _up.quote(it["name"]) rows.append( '
' '%s' '' '%s' '' '%s
' % (href, when, size_s, summary)) return "".join(rows) def main() -> int: ok = True ok &= patch(RELAY, [ ("DOWNLOADS_DIR = Path(__file__).resolve().parent / \"downloads\"", "DOWNLOADS_DIR = Path(__file__).resolve().parent / \"downloads\"\n" + RELAY_LIST, "加 rescue_reports 列表函数"), (RESCUE_ROUTE_ANCHOR, RESCUE_ROUTE + RESCUE_ROUTE_ANCHOR, "加 /rescue/ 下载路由(no-store)"), (ADMIN_CALL, ADMIN_CALL_NEW, "把 rescue_fn 传给后台页"), ], "relay.py") ok &= patch(ADMIN, [ (ADMIN_SIG, ADMIN_SIG_NEW, "render_admin 加 rescue_fn 参数"), (ADMIN_CARD_ANCHOR, ADMIN_CARD, "后台加「抢救报告」卡片"), # 算报告列表(照 dl_rows 的写法) (''' return """''', ''' # 抢救报告列表(和下载区同样在页面里直接渲染,不用 JS 拉) try: rescue_items = rescue_fn() if rescue_fn else [] except Exception: # noqa: BLE001 rescue_items = [] rescue_rows = _rescue_rows(rescue_items) return """''', "算抢救报告列表"), ('"dl": dl_rows,', '"dl": dl_rows,\n "rescue": rescue_rows,', "把报告列表塞进模板"), ], "admin.py") return 0 if ok else 1 if __name__ == "__main__": sys.exit(main())