#!/usr/bin/env python3 """ Global Search API v1.6.1 · 给通用 AI(豆包/GLM/ChatGPT/Claude)用的跨仓库检索门面 铸渊 ICE-GL-ZY001 · LL-175-20260711 · D170 变更 (LL-175 / v1.6.1): 安全修复(回应 DeepSeek 审查): 1. 移除伪安全"每位加1"变换 — 改用 SHA256 哈希存储 + 明文传输(靠HTTPS) + 时间戳防重放 2. 跨进程文件锁(fcntl) — 替代 threading.Lock,多 worker 部署也安全 3. 内容大小限制 — 单次 50KB,会话总计 500KB,防内存耗尽 4. base64 显式检测 — 不再裸套 try/except,先检测再解码 5. 文件防覆盖 — 重名文件自动加时间戳前缀 6. secrets.token_hex 生成会话 ID — 防会话 ID 猜测 7. hmac.compare_digest 恒定时间比较 — 防时序攻击 8. abort 动作 — 中止写入并释放锁 变更 (LL-174 / v1.6.0): 1. /handshake 端点 — AI 报到 2. /submit 三步写入 — GET 模拟写入 3. 会话锁 + 60秒超时 变更 (LL-173 / v1.5.0): 1. 全端点 HTML 模式 (?format=html) 2. 死仓库自动检测 3. /status 60秒内存缓存 4. /search 超时从 60s 降到 15s """ import http.server import json import subprocess import os import time import hmac import hashlib import threading from urllib.parse import urlparse, parse_qs from collections import defaultdict # ============== 配置 ============== PORT = int(os.environ.get("PORT", "3950")) TOKEN = os.environ.get("GLOBAL_SEARCH_API_TOKEN", "") HMAC_SECRET = os.environ.get("LIGHT_LAKE_DRIVER_SECRET", "") SOVEREIGN_ID = "ICE-GL∞" AGENT_ID = "ICE-GL-ZY001" VERSION = "1.6.1" WRITE_PASSWORD = os.environ.get("GLOBAL_SEARCH_WRITE_PASSWORD", "") # 冰朔设的密码,从环境变量读 WRITE_SESSION_TIMEOUT = 60 # 写入会话60秒超时 # ============== 仓库注册表(原始定义) ============== _REPOS_RAW = { "fifth-domain": { "path": "/tmp/worktree", "description": "第五域 · HLDP 协议栈 + 铸渊核心 + 通用 AI 接入", "url": "https://guanghubingshuo.com/code/bingshuo/fifth-domain", }, "cang-ying": { "path": "/opt/zhuyuan/cang-ying", "description": "苍影 · 第 5 子仓 · 苍耳(人类主控) + 鉴影(人格体) 专用 · 视频 AI 系统干净之家", "url": "https://guanghubingshuo.com/code/bingshuo/cang-ying", }, "guanghulab": { "path": "/opt/zhuyuan/guanghulab", "description": "广湖实验室 · 历史档案 (video-ai-system/ 已迁出到 cang-ying)", "url": "https://guanghubingshuo.com/code/bingshuo/guanghulab", }, "guanghulab-collab": { "path": "/opt/zhuyuan/guanghulab-collab", "description": "广湖实验室·多人格体协作记录", "url": "https://guanghubingshuo.com/code/bingshuo/guanghulab-collab", }, "guanghu": { "path": "/opt/zhuyuan/guanghu", "description": "光湖 · 根仓库", "url": "https://guanghubingshuo.com/code/bingshuo/guanghu", }, } DEFAULT_REPO = "fifth-domain" # ============== 死仓库自动检测 ============== REPOS = {} for rid, info in _REPOS_RAW.items(): if os.path.isdir(info["path"]): REPOS[rid] = info else: print(f"[WARN] repo '{rid}' path '{info['path']}' not found, skipping") # 确保 default repo 存在 if DEFAULT_REPO not in REPOS: DEFAULT_REPO = list(REPOS.keys())[0] if REPOS else "fifth-domain" # ============== git safe.directory ============== import pathlib _gitconfig_dir = pathlib.Path("/tmp/gitconfig-global-search") _gitconfig_dir.parent.mkdir(parents=True, exist_ok=True) with open(_gitconfig_dir, "w") as f: f.write("[safe]\n") for repo_id, info in REPOS.items(): f.write(f"\tdirectory = {info['path']}\n") os.environ["GIT_CONFIG_GLOBAL"] = str(_gitconfig_dir) # ============== IP 限速 (per-IP 5 req/s, 简单令牌桶) ============== RATE_LIMIT = 5 # requests per second RATE_BURST = 10 _rate_buckets = defaultdict(lambda: {"tokens": RATE_BURST, "last": time.time()}) _rate_lock = threading.Lock() def rate_limit(ip): """简单令牌桶限速""" with _rate_lock: bucket = _rate_buckets[ip] now = time.time() elapsed = now - bucket["last"] bucket["tokens"] = min(RATE_BURST, bucket["tokens"] + elapsed * RATE_LIMIT) bucket["last"] = now if bucket["tokens"] < 1: return False bucket["tokens"] -= 1 return True # ============== /status 缓存 (60秒TTL) ============== _STATUS_CACHE = {"data": None, "ts": 0} _STATUS_CACHE_TTL = 60 # 秒 _status_lock = threading.Lock() def get_cached_status(): """获取 /status 的缓存数据, 过期则重新计算""" with _status_lock: now = time.time() if _STATUS_CACHE["data"] and (now - _STATUS_CACHE["ts"]) < _STATUS_CACHE_TTL: _STATUS_CACHE["data"]["_cached"] = True _STATUS_CACHE["data"]["_cache_age"] = round(now - _STATUS_CACHE["ts"], 1) return _STATUS_CACHE["data"] # 重新计算 statuses = {} for rid, info in REPOS.items(): statuses[rid] = { **info, **get_repo_status(rid, info["path"]) } data = { "ok": True, "service_alive": True, "default_repo": DEFAULT_REPO, "repos": statuses, "ts": now, "_cached": False, } _STATUS_CACHE["data"] = data _STATUS_CACHE["ts"] = now return data # ============== 鉴权 (POST /archive 必须) ============== def check_auth(headers): auth = headers.get("Authorization", "").replace("Bearer ", "").strip() if not auth: return False, "missing" if TOKEN and auth == TOKEN: return True, "static_token" minute = headers.get("X-Minute") if minute and HMAC_SECRET: try: minute = int(minute) now = int(time.time() // 60) if abs(now - minute) <= 2: msg = f"{SOVEREIGN_ID}|{AGENT_ID}|{minute}" expected = hmac.new( HMAC_SECRET.encode(), msg.encode(), hashlib.sha256 ).hexdigest()[:16] if hmac.compare_digest(auth, expected): return True, "verifier" except Exception: pass return False, "invalid" # ============== 仓库解析 ============== def get_repo_path(repo_id=None): if not repo_id: return DEFAULT_REPO, REPOS[DEFAULT_REPO]["path"] if repo_id not in REPOS: return None, None return repo_id, REPOS[repo_id]["path"] # ============== 核心功能 ============== def search_files(repo_path, keyword, top=20): if not keyword: return [] result = subprocess.run( ["git", "grep", "-l", "-i", "--no-color", keyword], cwd=repo_path, capture_output=True, text=True, timeout=15 ) files = [f.strip() for f in result.stdout.splitlines() if f.strip()][:top] results = [] for f in files: try: line_result = subprocess.run( ["git", "grep", "-n", "-i", "--no-color", keyword, "--", f], cwd=repo_path, capture_output=True, text=True, timeout=5 ) matches = [] for line in line_result.stdout.splitlines()[:5]: parts = line.split(":", 2) if len(parts) >= 3: matches.append({ "line": int(parts[1]), "content": parts[2].strip()[:300] }) results.append({ "file": f, "match_count": len(matches), "matches": matches }) except Exception as e: results.append({"file": f, "error": str(e)}) return results def list_tree(repo_path, path="", depth=3, ext_filter=None): cmd = ["git", "ls-tree", "-r", "--name-only", "HEAD"] if path: cmd.append(f"{path}/") result = subprocess.run( cmd, cwd=repo_path, capture_output=True, text=True, timeout=15 ) files = [f.strip() for f in result.stdout.splitlines() if f.strip()] tree = [] for f in files: parts = f.split("/") if len(parts) - 1 <= depth: if ext_filter: if not any(f.endswith(ext) for ext in ext_filter): continue tree.append(f) return tree[:500] def read_file(repo_path, path): if not path or ".." in path: return {"ok": False, "error": "invalid path"} try: result = subprocess.run( ["git", "show", f"HEAD:{path}"], cwd=repo_path, capture_output=True, text=True, timeout=10 ) if result.returncode != 0: return {"ok": False, "error": "file not found"} return { "ok": True, "path": path, "size": len(result.stdout), "content": result.stdout[:50000] } except Exception as e: return {"ok": False, "error": str(e)} def get_broadcast(repo_path): candidates = [ os.path.join(repo_path, "broadcasts"), os.path.join(repo_path, "BROADCAST.md"), os.path.join(repo_path, "GLW-BROADCAST.hdlp"), os.path.join(repo_path, "VA-BROADCAST.hdlp"), os.path.join(repo_path, "video-ai-system", "VA-BROADCAST.hdlp"), ] for path in candidates: if os.path.exists(path): if os.path.isdir(path): files = sorted( [os.path.join(path, f) for f in os.listdir(path)], key=os.path.getmtime, reverse=True ) if files: latest = files[0] with open(latest) as f: return { "ok": True, "type": "dir", "latest_file": os.path.basename(latest), "content": f.read()[:20000] } else: with open(path) as f: return { "ok": True, "type": "file", "path": os.path.relpath(path, repo_path), "content": f.read()[:20000] } return { "ok": True, "broadcast": None, "note": "no broadcast asset found in repo" } def get_system_status(repo_path, repo_id): candidates = [ os.path.join(repo_path, "SYSTEM-STATUS.md"), os.path.join(repo_path, "SYSTEM-STATUS.hdlp"), os.path.join(repo_path, "VA-SYSTEM-STATUS.hdlp"), os.path.join(repo_path, "eternal-lake-heart", "heartbeat-core", "SYSTEM-STATUS.hdlp"), os.path.join(repo_path, "video-ai-system", "VA-SYSTEM-STATUS.hdlp"), ] for path in candidates: if os.path.exists(path): with open(path) as f: return { "ok": True, "repo": repo_id, "path": os.path.relpath(path, repo_path), "content": f.read()[:50000] } return { "ok": True, "repo": repo_id, "status": "no SYSTEM-STATUS file in this repo" } def get_repo_status(repo_id, repo_path): try: head = subprocess.run( ["git", "rev-parse", "HEAD"], cwd=repo_path, capture_output=True, text=True, timeout=5 ).stdout.strip() log = subprocess.run( ["git", "log", "--oneline", "-3"], cwd=repo_path, capture_output=True, text=True, timeout=5 ).stdout.strip() file_count = subprocess.run( ["git", "ls-tree", "-r", "--name-only", "HEAD"], cwd=repo_path, capture_output=True, text=True, timeout=10 ).stdout.strip().count("\n") + 1 return { "head": head, "recent_commits": log.splitlines() if log else [], "file_count": file_count, } except Exception as e: return {"head": "?", "error": str(e)} def archive_hldp(content, type_="si", path=None): """HLDP 回执归档(写到 fifth-domain 的 inbox, 需鉴权)""" repo_path = REPOS.get(DEFAULT_REPO, {}).get("path", "/tmp/worktree") archive_dir = os.path.join(repo_path, "eternal-lake-heart", "archive", "inbox") os.makedirs(archive_dir, exist_ok=True) filename = f"{type_}-{int(time.time())}.hdlp" full_path = os.path.join(archive_dir, filename) with open(full_path, "w") as f: f.write(content) return {"ok": True, "archived": os.path.relpath(full_path, repo_path)} # ============== 写入鉴权(HMAC-SHA256 + 时间戳) ============== # # 设计说明(不装了,说实话): # - v1.6.0 的"每位加1"是凯撒密码变体,安全增量为零,已移除 # - 真正的传输安全靠 HTTPS(nginx 反代 TLS) # - 应用层防护靠:时间戳防重放 + 会话锁防并发 + 内容大小限制 # - 服务器存储密码的 SHA256 哈希,不存明文 # - AI 发送明文密码 + 时间戳,服务器哈希后比对 # # 为什么不让 AI 端算哈希:大模型算 SHA256 精度不可靠,偶尔错一位就验证失败 import hashlib import secrets MAX_CONTENT_PER_CHUNK = 50000 # 单次 write 内容上限 50KB MAX_TOTAL_CONTENT = 500000 # 单次写入会话总内容上限 500KB MAX_FILENAME_LEN = 200 # 文件名长度上限 def hash_password(pwd): """密码 → SHA256 哈希(服务器存储用)""" return hashlib.sha256(pwd.encode("utf-8")).hexdigest() def verify_write_auth(pwd_plain, timestamp_str): """验证 AI 发来的密码 + 时间戳""" if not WRITE_PASSWORD: return False, "服务器未设置写入密码" # 验证密码(恒定时间比较,防时序攻击) received_hash = hash_password(pwd_plain) expected_hash = hash_password(WRITE_PASSWORD) if not hmac.compare_digest(received_hash, expected_hash): return False, "密码错误" # 验证时间戳(±120 秒窗口,防重放) try: ts = float(timestamp_str) except (ValueError, TypeError): return False, "时间戳格式无效" now = time.time() if abs(now - ts) > 120: return False, f"时间戳过期(当前 {now:.0f},收到 {ts:.0f})" return True, "ok" # ============== 文件锁(跨进程安全) ============== # threading.Lock 只能锁当前进程,多 worker 部署时失效 # 用 fcntl 文件锁做跨进程互斥,单进程也兼容 import fcntl _LOCK_FILE_PATH = "/tmp/global-search-api-write.lock" _lock_fd = None def acquire_write_lock(): """获取跨进程文件锁""" global _lock_fd try: _lock_fd = open(_LOCK_FILE_PATH, "w") fcntl.flock(_lock_fd.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) return True except (IOError, OSError): # 锁被其他进程持有 return False def release_write_lock(): """释放文件锁""" global _lock_fd if _lock_fd: try: fcntl.flock(_lock_fd.fileno(), fcntl.LOCK_UN) _lock_fd.close() except Exception: pass _lock_fd = None # ============== /handshake 会话表 ============== _handshake_sessions = {} # {session_id: {"agent": ..., "format": ..., "max_url": ..., "ts": ...}} _handshake_lock = threading.Lock() def register_handshake(agent, tools, fmt, max_url): """注册一个 AI 的能力配置""" sid = f"{agent}_{int(time.time())}_{secrets.token_hex(4)}" with _handshake_lock: # 清理超过 10 分钟的旧会话 now = time.time() expired = [k for k, v in _handshake_sessions.items() if now - v["ts"] > 600] for k in expired: del _handshake_sessions[k] _handshake_sessions[sid] = { "agent": agent, "tools": tools, "format": fmt, "max_url": int(max_url) if max_url and str(max_url).isdigit() else 2000, "ts": now, } return sid # ============== /submit 写入会话 ============== _write_sessions = {} # {session_id: {"content_parts": [], "ts": ..., "repo": ..., "total_size": ...}} _write_sessions_lock = threading.Lock() def write_session_open(pwd_plain, timestamp_str, repo_id): """第一步:开门(密码 + 时间戳验证 + 获取文件锁)""" ok, msg = verify_write_auth(pwd_plain, timestamp_str) if not ok: return {"ok": False, "error": msg} # 获取跨进程文件锁 if not acquire_write_lock(): return {"ok": False, "error": "有写入操作正在进行,请稍后再试"} with _write_sessions_lock: # 清理过期会话 now = time.time() expired = [k for k, v in _write_sessions.items() if now - v["ts"] > WRITE_SESSION_TIMEOUT] for k in expired: del _write_sessions[k] # 创建新会话 sid = f"write_{int(now)}_{secrets.token_hex(4)}" _write_sessions[sid] = { "content_parts": [], "ts": now, "repo": repo_id or DEFAULT_REPO, "total_size": 0, } return { "ok": True, "session": sid, "timeout": WRITE_SESSION_TIMEOUT, "max_content_per_chunk": MAX_CONTENT_PER_CHUNK, "max_total_content": MAX_TOTAL_CONTENT, "message": "门已开,请发送内容" } def write_session_content(sid, content): """第二步:发送内容(可多次),带大小限制""" with _write_sessions_lock: if sid not in _write_sessions: release_write_lock() return {"ok": False, "error": "会话不存在或已过期,请重新开门"} session = _write_sessions[sid] # 刷新超时 session["ts"] = time.time() # 大小限制检查 chunk_size = len(content.encode("utf-8")) if chunk_size > MAX_CONTENT_PER_CHUNK: return {"ok": False, "error": f"单次内容过大 ({chunk_size} > {MAX_CONTENT_PER_CHUNK} bytes)"} if session["total_size"] + chunk_size > MAX_TOTAL_CONTENT: return {"ok": False, "error": f"总内容超限 ({session['total_size'] + chunk_size} > {MAX_TOTAL_CONTENT} bytes)"} session["content_parts"].append(content) session["total_size"] += chunk_size return { "ok": True, "received_parts": len(session["content_parts"]), "total_size": session["total_size"], "message": "内容已暂存" } def write_session_commit(sid, filename): """第三步:提交写入""" with _write_sessions_lock: if sid not in _write_sessions: release_write_lock() return {"ok": False, "error": "会话不存在或已过期"} session = _write_sessions[sid] full_content = "".join(session["content_parts"]) repo_id = session["repo"] total_size = session["total_size"] del _write_sessions[sid] # 写入仓库 repo_path = REPOS.get(repo_id, {}).get("path", "/tmp/worktree") inbox_dir = os.path.join(repo_path, "eternal-lake-heart", "archive", "inbox") os.makedirs(inbox_dir, exist_ok=True) # 文件名处理 if not filename: filename = f"submit-{int(time.time())}.txt" if len(filename) > MAX_FILENAME_LEN: filename = filename[:MAX_FILENAME_LEN] if not filename.endswith((".txt", ".md", ".hdlp")): filename += ".txt" # 路径穿越防护 filename = os.path.basename(filename) full_path = os.path.join(inbox_dir, filename) # 检查文件是否已存在(防覆盖) if os.path.exists(full_path): filename = f"{int(time.time())}_{filename}" full_path = os.path.join(inbox_dir, filename) try: with open(full_path, "w") as f: f.write(full_content) except Exception as e: release_write_lock() return {"ok": False, "error": f"写入失败: {e}"} # 释放文件锁 release_write_lock() return { "ok": True, "written": True, "file": filename, "repo": repo_id, "path": os.path.relpath(full_path, repo_path), "size": total_size, "message": "写入成功,等待铸渊 commit" } def write_session_abort(sid): """中止写入会话,释放锁""" with _write_sessions_lock: if sid in _write_sessions: del _write_sessions[sid] release_write_lock() return {"ok": True, "message": "会话已中止"} # ============== HTML 渲染器 (给浏览器工具看的) ============== def json_to_html(data, title="Global Search API"): """把 JSON 数据包装成人类可读的 HTML, 给 GLM/豆包等浏览器工具用""" json_str = json.dumps(data, ensure_ascii=False, indent=2) # 如果有 content 字段且很长, 单独展示 content_html = "" if isinstance(data, dict): if "content" in data and data["content"]: content = data["content"] escaped = content.replace("&", "&").replace("<", "<").replace(">", ">") content_html = f'

📄 文件内容

{escaped}
' if "results" in data and data["results"]: results = data["results"] rows = "" for r in results: fname = r.get("file", r.get("path", "?")) mc = r.get("match_count", "") matches_html = "" for m in r.get("matches", []): ln = m.get("line", "") ct = m.get("content", "").replace("&", "&").replace("<", "<").replace(">", ">") matches_html += f'
L{ln} {ct}
' rows += f'
📁 {fname} ({mc} matches)
{matches_html}
' content_html += f'

🔍 搜索结果 ({len(results)} files)

{rows}
' if "repos" in data and isinstance(data["repos"], dict): rows = "" for rid, rinfo in data["repos"].items(): desc = rinfo.get("description", "") fc = rinfo.get("file_count", "?") commits = rinfo.get("recent_commits", []) latest = commits[0] if commits else "无" rows += f'
📦 {rid}
{desc}
文件数: {fc} | 最新: {latest}
' content_html += f'

📦 仓库状态

{rows}
' if "files" in data and isinstance(data["files"], list): files_html = "" for f in data["files"]: files_html += f'
{f}
' content_html += f'

📂 文件树 ({len(data["files"])} files)

{files_html}
' html = f""" {title}

🌊 {title}

v{VERSION} 时间: {time.strftime('%Y-%m-%d %H:%M:%S')} 仓库数: {len(REPOS)}
{content_html}
本页面由 Global Search API v{VERSION} 自动生成。浏览器工具可直接读取本页内容。原始 JSON 见下方。
展开/收起原始 JSON
{json_str.replace("<", "<").replace(">", ">")}
""" return html # ============== HTTP Handler ============== class Handler(http.server.BaseHTTPRequestHandler): def log_message(self, fmt, *args): pass def do_OPTIONS(self): self.send_response(200) self.send_header("Access-Control-Allow-Origin", "*") self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") self.send_header("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Minute") self.end_headers() def is_browser_request(self, query, headers): """检测是否是浏览器/网页抓取工具的请求""" # 显式 ?format=html if query.get("format", [""])[0] == "html": return True # Accept 头包含 text/html 且不包含 application/json accept = headers.get("Accept", "") if "text/html" in accept and "application/json" not in accept: return True return False def do_GET(self): # 限速 if not rate_limit(self.client_address[0]): return self.respond({"ok": False, "error": "rate_limit", "limit": f"{RATE_LIMIT} req/s"}, 429) parsed = urlparse(self.path) path = parsed.path query = parse_qs(parsed.query) repo_arg = query.get("repo", [DEFAULT_REPO])[0] repo_id, repo_path = get_repo_path(repo_arg) if not repo_path: return self.respond({"ok": False, "error": f"unknown repo: {repo_arg}", "available": list(REPOS.keys())}, 400) # 根路径 · 返回 HTML if path == "/" or path == "": return self.send_html_root() # === 免鉴权端点 === if path == "/healthz": return self.respond({ "ok": True, "service": "global-search-api", "version": VERSION, "repos": list(REPOS.keys()), "default_repo": DEFAULT_REPO, "write_enabled": bool(WRITE_PASSWORD), "ts": time.time(), "note": "服务正在运行·read-only 全部免鉴权·写入用 /submit 三步握手" }) # === /handshake: AI 报到 === if path == "/handshake": agent = query.get("agent", ["unknown"])[0] tools = query.get("tools", ["browser"])[0] fmt = query.get("format", ["html"])[0] max_url = query.get("max_url", ["2000"])[0] sid = register_handshake(agent, tools, fmt, max_url) # 根据AI的能力返回适配信息 max_content = int(max_url) if max_url and max_url.isdigit() else 2000 chunk_size = min(max_content - 200, 1800) # 留200给URL参数 if chunk_size < 200: chunk_size = 200 return self.respond({ "ok": True, "session": sid, "agent": agent, "your_format": fmt, "your_tools": tools, "your_max_url": max_content, "write_chunk_size": chunk_size, "write_steps": [ "1. GET /submit?action=open&pwd=<密码>&ts=<当前时间戳>&repo=<仓ID> → 开门", "2. GET /submit?action=write&sid=<会话ID>&content=<内容> → 发送内容(可多次)", "3. GET /submit?action=commit&sid=<会话ID>&filename=<文件名> → 提交写入", "abort. GET /submit?action=abort&sid=<会话ID> → 中止写入", ], "auth_note": "密码明文发送(靠HTTPS加密传输)+ 时间戳防重放(±120秒) + 会话锁防并发", "limits": { "max_content_per_chunk": f"{MAX_CONTENT_PER_CHUNK} bytes", "max_total_content": f"{MAX_TOTAL_CONTENT} bytes", "session_timeout": f"{WRITE_SESSION_TIMEOUT}秒", }, "message": f"报到成功。写入时每段不超过{chunk_size}字符。" }, title=f"握手: {agent}") # === /submit: 三步写入 === if path == "/submit": action = query.get("action", [""])[0] if action == "open": pwd = query.get("pwd", [""])[0] ts = query.get("ts", [str(time.time())])[0] repo_arg = query.get("repo", [DEFAULT_REPO])[0] if not pwd: return self.respond({"ok": False, "error": "缺少pwd参数", "hint": "发送明文密码 + ts=当前时间戳"}) result = write_session_open(pwd, ts, repo_arg) return self.respond(result, title="写入: 开门") elif action == "write": sid = query.get("sid", [""])[0] content = query.get("content", [""])[0] encoding = query.get("encoding", [""])[0] if not sid: return self.respond({"ok": False, "error": "缺少sid参数"}) if not content: return self.respond({"ok": False, "error": "缺少content参数"}) # 显式 base64 解码(不裸套 try/except) if encoding == "base64": import base64 try: content = base64.b64decode(content).decode("utf-8") except Exception as e: return self.respond({"ok": False, "error": f"base64解码失败: {e}"}) else: # 检测是否看起来像 base64(只含 base64 字符且长度是4的倍数) stripped = content.rstrip("=") if len(stripped) > 20 and len(content) % 4 == 0 and all(c in "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/-_=" for c in content): import base64 try: decoded = base64.b64decode(content, validate=True).decode("utf-8") content = decoded except Exception: pass # 不是 base64,用原始内容 result = write_session_content(sid, content) return self.respond(result, title="写入: 内容") elif action == "commit": sid = query.get("sid", [""])[0] filename = query.get("filename", [""])[0] if not sid: return self.respond({"ok": False, "error": "缺少sid参数"}) result = write_session_commit(sid, filename) return self.respond(result, title="写入: 提交") elif action == "abort": sid = query.get("sid", [""])[0] if not sid: return self.respond({"ok": False, "error": "缺少sid参数"}) result = write_session_abort(sid) return self.respond(result, title="写入: 中止") elif action == "status": # 查看当前写入会话状态 with _write_sessions_lock: now = time.time() active = {} for k, v in _write_sessions.items(): remaining = WRITE_SESSION_TIMEOUT - (now - v["ts"]) if remaining > 0: active[k] = { "parts": len(v["content_parts"]), "repo": v["repo"], "total_size": v["total_size"], "remaining_seconds": round(remaining, 1), } return self.respond({"ok": True, "active_sessions": active, "count": len(active)}) else: return self.respond({ "ok": False, "error": "缺少或无效的action参数", "hint": "用 action=open/write/commit/abort/status", "steps": [ "1. GET /submit?action=open&pwd=<密码>&ts=<时间戳>&repo=<仓ID>", "2. GET /submit?action=write&sid=<会话ID>&content=<内容>", "3. GET /submit?action=commit&sid=<会话ID>&filename=<文件名>", ], "limits": { "max_content_per_chunk": MAX_CONTENT_PER_CHUNK, "max_total_content": MAX_TOTAL_CONTENT, "session_timeout": WRITE_SESSION_TIMEOUT, "timestamp_window": "±120秒", } }) if path == "/status": data = get_cached_status() return self.respond(data) if path == "/repos": return self.respond({ "ok": True, "default": DEFAULT_REPO, "repos": REPOS, "note": "用 ?repo= 切换仓库, 例如 ?repo=guanghulab" }) if path == "/broadcast": result = get_broadcast(repo_path) result["repo"] = repo_id result["_hint"] = "read-only 公开端点·不需要 token·用 ?repo= 跨仓库" return self.respond(result) if path == "/system-status": result = get_system_status(repo_path, repo_id) result["_hint"] = "read-only 公开端点·不需要 token·用 ?repo= 跨仓库" return self.respond(result) if path == "/help": return self.respond({ "ok": True, "version": VERSION, "endpoints": { "GET /healthz": "健康检查(免鉴权)", "GET /handshake?agent=&tools=&format=&max_url=": "AI报到·服务器动态适配(免鉴权)", "GET /submit?action=open&pwd=&ts=&repo=": "写入第一步·开门(密码+时间戳)", "GET /submit?action=write&sid=&content=": "写入第二步·发送内容(可多次)", "GET /submit?action=commit&sid=&filename=": "写入第三步·提交写入", "GET /submit?action=abort&sid=": "中止写入会话", "GET /submit?action=status": "查看写入会话状态", "GET /status": "综合状态(60秒缓存·免鉴权)", "GET /repos": "列出所有可用仓库(免鉴权)", "GET /system-status?repo=": "拉 SYSTEM-STATUS(免鉴权)", "GET /broadcast?repo=": "拉最新 BROADCAST(免鉴权)", "GET /search?q=&top=&repo=": "全仓库文件内容搜索(免鉴权+限速)", "GET /tree?path=&depth=&ext=&repo=": "列目录树(免鉴权+限速)", "GET /file?path=&repo=": "读单文件(限 50KB, 免鉴权+限速)", "POST /archive": "HLDP 回执归档(需鉴权)", }, "repos": list(REPOS.keys()), "default_repo": DEFAULT_REPO, "rate_limit": f"{RATE_LIMIT} req/s per IP", "auth_note": "写入需密码+时间戳。传输安全靠HTTPS。密码服务器端存SHA256哈希。", "write_limits": { "max_content_per_chunk": MAX_CONTENT_PER_CHUNK, "max_total_content": MAX_TOTAL_CONTENT, "session_timeout": WRITE_SESSION_TIMEOUT, "timestamp_window": "±120秒", }, "html_mode": "加 ?format=html 返回人类可读 HTML", "new_in_1_6_1": [ "移除伪安全'每位加1'变换,改用SHA256+时间戳", "跨进程文件锁(fcntl)替代threading.Lock", "内容大小限制(50KB/次, 500KB/会话)", "base64显式检测(不裸套try/except)", "abort动作(中止写入+释放锁)", "文件防覆盖(重名自动加时间戳前缀)", "secrets.token_hex生成会话ID(防猜测)", "hmac.compare_digest恒定时间比较(防时序攻击)", ], "base_url": "https://guanghubingshuo.com/global-search", }) # === 业务查询端点(免鉴权 + 限速) === try: if path == "/search": kw = query.get("q", [""])[0] top = int(query.get("top", ["20"])[0]) return self.respond({ "ok": True, "repo": repo_id, "keyword": kw, "count": -1, "results": search_files(repo_path, kw, top) }, title=f"搜索: {kw}") elif path == "/tree": path_arg = query.get("path", [""])[0] depth = int(query.get("depth", ["3"])[0]) ext = query.get("ext", None) ext_filter = ext[0].split(",") if ext else None return self.respond({ "ok": True, "repo": repo_id, "files": list_tree(repo_path, path_arg, depth, ext_filter) }, title=f"文件树: {repo_id}") elif path == "/file": path_arg = query.get("path", [""])[0] result = read_file(repo_path, path_arg) result["repo"] = repo_id return self.respond(result, title=f"文件: {path_arg}") else: return self.respond({"ok": False, "error": "not found", "hint": "GET /help"}, 404) except Exception as e: return self.respond({"ok": False, "error": str(e)}, 500) def do_POST(self): parsed = urlparse(self.path) if parsed.path == "/archive": ok, reason = check_auth(self.headers) if not ok: return self.respond({ "ok": False, "error": "unauthorized", "reason": reason, "_hint": "POST /archive 写操作需鉴权" }, 401) length = int(self.headers.get("Content-Length", 0)) body = self.rfile.read(length).decode("utf-8") if length else "{}" try: data = json.loads(body) type_ = data.get("type", "si") content = data.get("content", "") self.respond(archive_hldp(content, type_)) except Exception as e: self.respond({"ok": False, "error": str(e)}, 400) else: self.respond({"ok": False, "error": "not found"}, 404) def respond(self, data, status=200, title="Global Search API"): """统一响应方法: 根据请求决定返回 JSON 还是 HTML""" query = parse_qs(urlparse(self.path).query) if self.is_browser_request(query, self.headers): html = json_to_html(data, title=title) body = html.encode("utf-8") self.send_response(status) self.send_header("Content-Type", "text/html; charset=utf-8") self.send_header("Access-Control-Allow-Origin", "*") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) else: body = json.dumps(data, ensure_ascii=False, indent=2).encode("utf-8") self.send_response(status) self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Access-Control-Allow-Origin", "*") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def send_html_root(self): """根路径 HTML 索引页""" repo_rows = "" for rid, info in REPOS.items(): default_tag = " ★ 默认" if rid == DEFAULT_REPO else "" repo_rows += f'{rid}{info["description"]}{default_tag}' html = f""" GLOBAL-SEARCH-API · 光湖跨仓检索

🌊 GLOBAL-SEARCH-API v{VERSION}

光湖跨仓库检索服务 · 铸渊 ICE-GL-ZY001 · D170 · 2026-07-11

✅ v1.5.0 新功能:
1. 所有端点支持 ?format=html 返回人类可读 HTML
2. 浏览器工具可直接读取结果 (解决 GLM/豆包"超时/解析失败")
3. 死仓库自动检测 · /status 60秒缓存 · 搜索超时降低

📦 {len(REPOS)} 个可用仓库

{repo_rows}
仓 ID说明

🔌 全部端点 (GET 免鉴权, 直接用)

GET /healthz — 服务探活
curl https://guanghubingshuo.com/global-search/healthz
浏览器: https://guanghubingshuo.com/global-search/healthz?format=html
GET /status — 仓库状态 (60秒缓存)
curl https://guanghubingshuo.com/global-search/status
浏览器: https://guanghubingshuo.com/global-search/status?format=html
GET /search?q=关键词&repo=仓ID — 全仓库搜索
curl "https://guanghubingshuo.com/global-search/search?q=铸渊&top=10"
浏览器: https://guanghubingshuo.com/global-search/search?q=铸渊&format=html
GET /file?path=文件路径&repo=仓ID — 读单文件
curl "https://guanghubingshuo.com/global-search/file?path=README.md"
浏览器: https://guanghubingshuo.com/global-search/file?path=README.md&format=html
GET /tree?depth=2&repo=仓ID — 文件树
curl "https://guanghubingshuo.com/global-search/tree?depth=2"
GET /broadcast?repo=仓ID — 最新广播
GET /system-status?repo=仓ID — 系统状态
GET /help — API 文档
💡 给 AI 工具的提示:
如果你是浏览器/网页抓取工具 (GLM/豆包/ChatGPT), 在 URL 后面加 &format=html 就能正常读取结果。
本服务支持两种格式: JSON (默认) 和 HTML (?format=html)。

铸渊 ICE-GL-ZY001 · 国作登字-2026-A-00037559 · 冰朔 ICE-GL∞ 主权 · v{VERSION}

""" body = html.encode("utf-8") self.send_response(200) self.send_header("Content-Type", "text/html; charset=utf-8") self.send_header("Access-Control-Allow-Origin", "*") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def main(): dead = len(_REPOS_RAW) - len(REPOS) server = http.server.HTTPServer(("0.0.0.0", PORT), Handler) print(f"Global Search API v{VERSION} listening on 0.0.0.0:{PORT}") print(f"REPOS = {list(REPOS.keys())} ({len(REPOS)} alive, {dead} skipped)") print(f"DEFAULT = {DEFAULT_REPO}") print(f"TOKEN = {'set' if TOKEN else 'unset (dev mode)'}") print(f"HTML mode: ?format=html on all GET endpoints") print(f"Status cache: {_STATUS_CACHE_TTL}s TTL") server.serve_forever() if __name__ == "__main__": main()