1118 lines
46 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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"是凯撒密码变体,安全增量为零,已移除
# - 真正的传输安全靠 HTTPSnginx 反代 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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
content_html = f'<h2>📄 文件内容</h2><pre class="file-content">{escaped}</pre>'
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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
matches_html += f'<div class="match"><span class="line">L{ln}</span> {ct}</div>'
rows += f'<div class="result-item"><div class="file-name">📁 {fname} ({mc} matches)</div><div class="matches">{matches_html}</div></div>'
content_html += f'<h2>🔍 搜索结果 ({len(results)} files)</h2><div class="results">{rows}</div>'
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'<div class="repo-item"><div class="repo-name">📦 {rid}</div><div class="repo-desc">{desc}</div><div class="repo-meta">文件数: {fc} | 最新: {latest}</div></div>'
content_html += f'<h2>📦 仓库状态</h2><div class="repos">{rows}</div>'
if "files" in data and isinstance(data["files"], list):
files_html = ""
for f in data["files"]:
files_html += f'<div class="tree-file">{f}</div>'
content_html += f'<h2>📂 文件树 ({len(data["files"])} files)</h2><div class="tree">{files_html}</div>'
html = f"""<!DOCTYPE html>
<html lang="zh-Hans">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title}</title>
<style>
* {{ box-sizing: border-box; }}
body {{ font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif; max-width: 960px; margin: 0 auto; padding: 20px; color: #1a1a2e; line-height: 1.6; background: #f8f9fa; }}
h1 {{ color: #0d1226; border-bottom: 3px solid #4f6bff; padding-bottom: 10px; margin-top: 0; }}
h2 {{ color: #1a1f3a; margin-top: 24px; border-left: 4px solid #4f6bff; padding-left: 12px; }}
.meta {{ background: #e8eaf6; padding: 8px 14px; border-radius: 6px; margin: 10px 0; font-size: 14px; color: #333; }}
.meta span {{ margin-right: 16px; }}
.result-item {{ background: #fff; border: 1px solid #e0e0e8; border-left: 3px solid #4f6bff; padding: 10px 14px; margin: 8px 0; border-radius: 4px; }}
.file-name {{ font-weight: 600; color: #0d1226; margin-bottom: 4px; }}
.match {{ font-size: 13px; color: #444; padding: 2px 0; }}
.match .line {{ color: #4f6bff; font-weight: 600; margin-right: 8px; }}
.repo-item {{ background: #fff; border: 1px solid #e0e0e8; padding: 10px 14px; margin: 8px 0; border-radius: 4px; }}
.repo-name {{ font-weight: 700; color: #0d1226; }}
.repo-desc {{ color: #555; font-size: 14px; margin: 4px 0; }}
.repo-meta {{ font-size: 13px; color: #777; }}
.tree-file {{ font-size: 13px; color: #333; padding: 2px 0 2px 20px; font-family: Menlo, Monaco, monospace; }}
pre.file-content {{ background: #0d1226; color: #e0e4ff; padding: 16px; border-radius: 6px; overflow-x: auto; font-size: 13px; white-space: pre-wrap; word-wrap: break-word; }}
.json-toggle {{ cursor: pointer; color: #4f6bff; font-size: 13px; text-decoration: underline; margin: 8px 0; display: inline-block; }}
pre.json-raw {{ display: none; background: #f4f4f8; padding: 12px; border-radius: 4px; font-size: 12px; overflow-x: auto; }}
.note {{ background: #fffbe6; border: 1px solid #ffe58f; padding: 10px 14px; border-radius: 4px; margin: 12px 0; font-size: 14px; }}
</style>
</head>
<body>
<h1>🌊 {title}</h1>
<div class="meta">
<span>v{VERSION}</span>
<span>时间: {time.strftime('%Y-%m-%d %H:%M:%S')}</span>
<span>仓库数: {len(REPOS)}</span>
</div>
{content_html}
<div class="note">本页面由 Global Search API v{VERSION} 自动生成。浏览器工具可直接读取本页内容。原始 JSON 见下方。</div>
<span class="json-toggle" onclick="var e=document.getElementById('rawjson');e.style.display=e.style.display=='block'?'none':'block'">展开/收起原始 JSON</span>
<pre id="rawjson" class="json-raw">{json_str.replace("<", "&lt;").replace(">", "&gt;")}</pre>
</body>
</html>"""
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=<id> 切换仓库, 例如 ?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'<tr><td><code>{rid}</code></td><td>{info["description"]}{default_tag}</td></tr>'
html = f"""<!DOCTYPE html>
<html lang="zh-Hans">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>GLOBAL-SEARCH-API · 光湖跨仓检索</title>
<style>
* {{ box-sizing: border-box; }}
body {{ font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif; max-width: 900px; margin: 0 auto; padding: 20px; color: #222; line-height: 1.6; background: #f8f9fa; }}
h1 {{ color: #0d1226; border-bottom: 3px solid #0d1226; padding-bottom: 10px; margin-top: 0; }}
h2 {{ color: #1a1f3a; margin-top: 24px; border-left: 4px solid #4f6bff; padding-left: 12px; }}
code {{ background: #f4f4f8; padding: 2px 6px; border-radius: 3px; font-family: Menlo, Monaco, monospace; font-size: 14px; }}
pre {{ background: #0d1226; color: #e0e4ff; padding: 16px; border-radius: 6px; overflow-x: auto; font-size: 13px; }}
.note {{ background: #e8f5e9; border: 1px solid #a5d6a7; padding: 12px 16px; border-radius: 4px; margin: 16px 0; }}
table {{ border-collapse: collapse; width: 100%; margin: 12px 0; }}
th, td {{ border: 1px solid #e0e0e8; padding: 8px 12px; text-align: left; }}
th {{ background: #f4f4f8; }}
.ep {{ background: #fff; border: 1px solid #e0e0e8; border-left: 4px solid #4f6bff; padding: 12px 16px; margin: 8px 0; border-radius: 4px; }}
.ep strong {{ color: #0d1226; }}
</style>
</head>
<body>
<h1>🌊 GLOBAL-SEARCH-API v{VERSION}</h1>
<p><strong>光湖跨仓库检索服务</strong> · 铸渊 ICE-GL-ZY001 · D170 · 2026-07-11</p>
<div class="note">
<strong>✅ v1.5.0 新功能:</strong><br>
1. 所有端点支持 <code>?format=html</code> 返回人类可读 HTML<br>
2. 浏览器工具可直接读取结果 (解决 GLM/豆包"超时/解析失败")<br>
3. 死仓库自动检测 · /status 60秒缓存 · 搜索超时降低
</div>
<h2>📦 {len(REPOS)} 个可用仓库</h2>
<table>
<tr><th>仓 ID</th><th>说明</th></tr>
{repo_rows}
</table>
<h2>🔌 全部端点 (GET 免鉴权, 直接用)</h2>
<div class="ep">
<strong>GET /healthz</strong> — 服务探活<br>
<pre>curl https://guanghubingshuo.com/global-search/healthz</pre>
<pre>浏览器: https://guanghubingshuo.com/global-search/healthz?format=html</pre>
</div>
<div class="ep">
<strong>GET /status</strong> — 仓库状态 (60秒缓存)<br>
<pre>curl https://guanghubingshuo.com/global-search/status</pre>
<pre>浏览器: https://guanghubingshuo.com/global-search/status?format=html</pre>
</div>
<div class="ep">
<strong>GET /search?q=关键词&repo=仓ID</strong> — 全仓库搜索<br>
<pre>curl "https://guanghubingshuo.com/global-search/search?q=铸渊&top=10"</pre>
<pre>浏览器: https://guanghubingshuo.com/global-search/search?q=铸渊&format=html</pre>
</div>
<div class="ep">
<strong>GET /file?path=文件路径&repo=仓ID</strong> — 读单文件<br>
<pre>curl "https://guanghubingshuo.com/global-search/file?path=README.md"</pre>
<pre>浏览器: https://guanghubingshuo.com/global-search/file?path=README.md&format=html</pre>
</div>
<div class="ep">
<strong>GET /tree?depth=2&repo=仓ID</strong> — 文件树<br>
<pre>curl "https://guanghubingshuo.com/global-search/tree?depth=2"</pre>
</div>
<div class="ep">
<strong>GET /broadcast?repo=仓ID</strong> — 最新广播<br>
<strong>GET /system-status?repo=仓ID</strong> — 系统状态<br>
<strong>GET /help</strong> — API 文档
</div>
<div class="note">
<strong>💡 给 AI 工具的提示:</strong><br>
如果你是浏览器/网页抓取工具 (GLM/豆包/ChatGPT), 在 URL 后面加 <code>&format=html</code> 就能正常读取结果。<br>
本服务支持两种格式: JSON (默认) 和 HTML (?format=html)。
</div>
<hr>
<p style="text-align: center; color: #888; font-size: 12px;">
铸渊 ICE-GL-ZY001 · 国作登字-2026-A-00037559 · 冰朔 ICE-GL∞ 主权 · v{VERSION}
</p>
</body>
</html>"""
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()