2026-07-07 15:40:43 +08:00
|
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
|
"""
|
2026-07-11 03:03:38 +08:00
|
|
|
|
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 动作 — 中止写入并释放锁
|
2026-07-11 02:27:21 +08:00
|
|
|
|
|
|
|
|
|
|
变更 (LL-174 / v1.6.0):
|
2026-07-11 03:03:38 +08:00
|
|
|
|
1. /handshake 端点 — AI 报到
|
|
|
|
|
|
2. /submit 三步写入 — GET 模拟写入
|
|
|
|
|
|
3. 会话锁 + 60秒超时
|
2026-07-11 01:57:11 +08:00
|
|
|
|
|
|
|
|
|
|
变更 (LL-173 / v1.5.0):
|
2026-07-11 02:27:21 +08:00
|
|
|
|
1. 全端点 HTML 模式 (?format=html)
|
|
|
|
|
|
2. 死仓库自动检测
|
|
|
|
|
|
3. /status 60秒内存缓存
|
|
|
|
|
|
4. /search 超时从 60s 降到 15s
|
2026-07-07 15:40:43 +08:00
|
|
|
|
"""
|
|
|
|
|
|
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"
|
2026-07-11 03:03:38 +08:00
|
|
|
|
VERSION = "1.6.1"
|
2026-07-11 02:27:21 +08:00
|
|
|
|
WRITE_PASSWORD = os.environ.get("GLOBAL_SEARCH_WRITE_PASSWORD", "") # 冰朔设的密码,从环境变量读
|
|
|
|
|
|
WRITE_SESSION_TIMEOUT = 60 # 写入会话60秒超时
|
2026-07-07 15:40:43 +08:00
|
|
|
|
|
2026-07-11 01:57:11 +08:00
|
|
|
|
# ============== 仓库注册表(原始定义) ==============
|
|
|
|
|
|
_REPOS_RAW = {
|
2026-07-07 15:40:43 +08:00
|
|
|
|
"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"
|
|
|
|
|
|
|
2026-07-11 01:57:11 +08:00
|
|
|
|
# ============== 死仓库自动检测 ==============
|
|
|
|
|
|
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 ==============
|
2026-07-07 15:40:43 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-11 01:57:11 +08:00
|
|
|
|
# ============== /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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-07 15:40:43 +08:00
|
|
|
|
# ============== 鉴权 (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],
|
2026-07-11 01:57:11 +08:00
|
|
|
|
cwd=repo_path, capture_output=True, text=True, timeout=15
|
2026-07-07 15:40:43 +08:00
|
|
|
|
)
|
|
|
|
|
|
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],
|
2026-07-11 01:57:11 +08:00
|
|
|
|
cwd=repo_path, capture_output=True, text=True, timeout=5
|
2026-07-07 15:40:43 +08:00
|
|
|
|
)
|
|
|
|
|
|
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"),
|
2026-07-11 01:57:11 +08:00
|
|
|
|
os.path.join(repo_path, "VA-SYSTEM-STATUS.hdlp"),
|
2026-07-07 15:40:43 +08:00
|
|
|
|
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, 需鉴权)"""
|
2026-07-11 01:57:11 +08:00
|
|
|
|
repo_path = REPOS.get(DEFAULT_REPO, {}).get("path", "/tmp/worktree")
|
2026-07-07 15:40:43 +08:00
|
|
|
|
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)}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-11 03:03:38 +08:00
|
|
|
|
# ============== 写入鉴权(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 发来的密码 + 时间戳"""
|
2026-07-11 02:27:21 +08:00
|
|
|
|
if not WRITE_PASSWORD:
|
|
|
|
|
|
return False, "服务器未设置写入密码"
|
2026-07-11 03:03:38 +08:00
|
|
|
|
|
|
|
|
|
|
# 验证密码(恒定时间比较,防时序攻击)
|
|
|
|
|
|
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
|
2026-07-11 02:27:21 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============== /handshake 会话表 ==============
|
2026-07-11 03:03:38 +08:00
|
|
|
|
_handshake_sessions = {} # {session_id: {"agent": ..., "format": ..., "max_url": ..., "ts": ...}}
|
2026-07-11 02:27:21 +08:00
|
|
|
|
_handshake_lock = threading.Lock()
|
|
|
|
|
|
|
|
|
|
|
|
def register_handshake(agent, tools, fmt, max_url):
|
2026-07-11 03:03:38 +08:00
|
|
|
|
"""注册一个 AI 的能力配置"""
|
|
|
|
|
|
sid = f"{agent}_{int(time.time())}_{secrets.token_hex(4)}"
|
2026-07-11 02:27:21 +08:00
|
|
|
|
with _handshake_lock:
|
2026-07-11 03:03:38 +08:00
|
|
|
|
# 清理超过 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]
|
|
|
|
|
|
|
2026-07-11 02:27:21 +08:00
|
|
|
|
_handshake_sessions[sid] = {
|
|
|
|
|
|
"agent": agent,
|
|
|
|
|
|
"tools": tools,
|
|
|
|
|
|
"format": fmt,
|
2026-07-11 03:03:38 +08:00
|
|
|
|
"max_url": int(max_url) if max_url and str(max_url).isdigit() else 2000,
|
|
|
|
|
|
"ts": now,
|
2026-07-11 02:27:21 +08:00
|
|
|
|
}
|
|
|
|
|
|
return sid
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============== /submit 写入会话 ==============
|
2026-07-11 03:03:38 +08:00
|
|
|
|
_write_sessions = {} # {session_id: {"content_parts": [], "ts": ..., "repo": ..., "total_size": ...}}
|
|
|
|
|
|
_write_sessions_lock = threading.Lock()
|
2026-07-11 02:27:21 +08:00
|
|
|
|
|
2026-07-11 03:03:38 +08:00
|
|
|
|
def write_session_open(pwd_plain, timestamp_str, repo_id):
|
|
|
|
|
|
"""第一步:开门(密码 + 时间戳验证 + 获取文件锁)"""
|
|
|
|
|
|
ok, msg = verify_write_auth(pwd_plain, timestamp_str)
|
2026-07-11 02:27:21 +08:00
|
|
|
|
if not ok:
|
|
|
|
|
|
return {"ok": False, "error": msg}
|
|
|
|
|
|
|
2026-07-11 03:03:38 +08:00
|
|
|
|
# 获取跨进程文件锁
|
|
|
|
|
|
if not acquire_write_lock():
|
|
|
|
|
|
return {"ok": False, "error": "有写入操作正在进行,请稍后再试"}
|
|
|
|
|
|
|
|
|
|
|
|
with _write_sessions_lock:
|
2026-07-11 02:27:21 +08:00
|
|
|
|
# 清理过期会话
|
2026-07-11 03:03:38 +08:00
|
|
|
|
now = time.time()
|
2026-07-11 02:27:21 +08:00
|
|
|
|
expired = [k for k, v in _write_sessions.items() if now - v["ts"] > WRITE_SESSION_TIMEOUT]
|
|
|
|
|
|
for k in expired:
|
|
|
|
|
|
del _write_sessions[k]
|
|
|
|
|
|
|
|
|
|
|
|
# 创建新会话
|
2026-07-11 03:03:38 +08:00
|
|
|
|
sid = f"write_{int(now)}_{secrets.token_hex(4)}"
|
2026-07-11 02:27:21 +08:00
|
|
|
|
_write_sessions[sid] = {
|
|
|
|
|
|
"content_parts": [],
|
|
|
|
|
|
"ts": now,
|
|
|
|
|
|
"repo": repo_id or DEFAULT_REPO,
|
2026-07-11 03:03:38 +08:00
|
|
|
|
"total_size": 0,
|
2026-07-11 02:27:21 +08:00
|
|
|
|
}
|
2026-07-11 03:03:38 +08:00
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"ok": True,
|
|
|
|
|
|
"session": sid,
|
|
|
|
|
|
"timeout": WRITE_SESSION_TIMEOUT,
|
|
|
|
|
|
"max_content_per_chunk": MAX_CONTENT_PER_CHUNK,
|
|
|
|
|
|
"max_total_content": MAX_TOTAL_CONTENT,
|
|
|
|
|
|
"message": "门已开,请发送内容"
|
|
|
|
|
|
}
|
2026-07-11 02:27:21 +08:00
|
|
|
|
|
|
|
|
|
|
def write_session_content(sid, content):
|
2026-07-11 03:03:38 +08:00
|
|
|
|
"""第二步:发送内容(可多次),带大小限制"""
|
|
|
|
|
|
with _write_sessions_lock:
|
2026-07-11 02:27:21 +08:00
|
|
|
|
if sid not in _write_sessions:
|
2026-07-11 03:03:38 +08:00
|
|
|
|
release_write_lock()
|
2026-07-11 02:27:21 +08:00
|
|
|
|
return {"ok": False, "error": "会话不存在或已过期,请重新开门"}
|
|
|
|
|
|
session = _write_sessions[sid]
|
2026-07-11 03:03:38 +08:00
|
|
|
|
|
|
|
|
|
|
# 刷新超时
|
|
|
|
|
|
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)"}
|
|
|
|
|
|
|
2026-07-11 02:27:21 +08:00
|
|
|
|
session["content_parts"].append(content)
|
2026-07-11 03:03:38 +08:00
|
|
|
|
session["total_size"] += chunk_size
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"ok": True,
|
|
|
|
|
|
"received_parts": len(session["content_parts"]),
|
|
|
|
|
|
"total_size": session["total_size"],
|
|
|
|
|
|
"message": "内容已暂存"
|
|
|
|
|
|
}
|
2026-07-11 02:27:21 +08:00
|
|
|
|
|
|
|
|
|
|
def write_session_commit(sid, filename):
|
|
|
|
|
|
"""第三步:提交写入"""
|
2026-07-11 03:03:38 +08:00
|
|
|
|
with _write_sessions_lock:
|
2026-07-11 02:27:21 +08:00
|
|
|
|
if sid not in _write_sessions:
|
2026-07-11 03:03:38 +08:00
|
|
|
|
release_write_lock()
|
2026-07-11 02:27:21 +08:00
|
|
|
|
return {"ok": False, "error": "会话不存在或已过期"}
|
|
|
|
|
|
session = _write_sessions[sid]
|
|
|
|
|
|
full_content = "".join(session["content_parts"])
|
|
|
|
|
|
repo_id = session["repo"]
|
2026-07-11 03:03:38 +08:00
|
|
|
|
total_size = session["total_size"]
|
2026-07-11 02:27:21 +08:00
|
|
|
|
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)
|
|
|
|
|
|
|
2026-07-11 03:03:38 +08:00
|
|
|
|
# 文件名处理
|
2026-07-11 02:27:21 +08:00
|
|
|
|
if not filename:
|
|
|
|
|
|
filename = f"submit-{int(time.time())}.txt"
|
2026-07-11 03:03:38 +08:00
|
|
|
|
if len(filename) > MAX_FILENAME_LEN:
|
|
|
|
|
|
filename = filename[:MAX_FILENAME_LEN]
|
2026-07-11 02:27:21 +08:00
|
|
|
|
if not filename.endswith((".txt", ".md", ".hdlp")):
|
|
|
|
|
|
filename += ".txt"
|
|
|
|
|
|
|
2026-07-11 03:03:38 +08:00
|
|
|
|
# 路径穿越防护
|
2026-07-11 02:27:21 +08:00
|
|
|
|
filename = os.path.basename(filename)
|
|
|
|
|
|
|
|
|
|
|
|
full_path = os.path.join(inbox_dir, filename)
|
2026-07-11 03:03:38 +08:00
|
|
|
|
|
|
|
|
|
|
# 检查文件是否已存在(防覆盖)
|
|
|
|
|
|
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()
|
2026-07-11 02:27:21 +08:00
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"ok": True,
|
|
|
|
|
|
"written": True,
|
|
|
|
|
|
"file": filename,
|
|
|
|
|
|
"repo": repo_id,
|
|
|
|
|
|
"path": os.path.relpath(full_path, repo_path),
|
2026-07-11 03:03:38 +08:00
|
|
|
|
"size": total_size,
|
2026-07-11 02:27:21 +08:00
|
|
|
|
"message": "写入成功,等待铸渊 commit"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-11 03:03:38 +08:00
|
|
|
|
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": "会话已中止"}
|
|
|
|
|
|
|
2026-07-11 02:27:21 +08:00
|
|
|
|
|
2026-07-11 01:57:11 +08:00
|
|
|
|
# ============== 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'<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("&", "&").replace("<", "<").replace(">", ">")
|
|
|
|
|
|
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("<", "<").replace(">", ">")}</pre>
|
|
|
|
|
|
</body>
|
|
|
|
|
|
</html>"""
|
|
|
|
|
|
return html
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-07 15:40:43 +08:00
|
|
|
|
# ============== 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()
|
|
|
|
|
|
|
2026-07-11 01:57:11 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-07-07 15:40:43 +08:00
|
|
|
|
def do_GET(self):
|
|
|
|
|
|
# 限速
|
|
|
|
|
|
if not rate_limit(self.client_address[0]):
|
2026-07-11 01:57:11 +08:00
|
|
|
|
return self.respond({"ok": False, "error": "rate_limit", "limit": f"{RATE_LIMIT} req/s"}, 429)
|
2026-07-07 15:40:43 +08:00
|
|
|
|
|
|
|
|
|
|
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:
|
2026-07-11 01:57:11 +08:00
|
|
|
|
return self.respond({"ok": False, "error": f"unknown repo: {repo_arg}", "available": list(REPOS.keys())}, 400)
|
2026-07-07 15:40:43 +08:00
|
|
|
|
|
2026-07-11 01:57:11 +08:00
|
|
|
|
# 根路径 · 返回 HTML
|
2026-07-07 15:40:43 +08:00
|
|
|
|
if path == "/" or path == "":
|
|
|
|
|
|
return self.send_html_root()
|
|
|
|
|
|
|
|
|
|
|
|
# === 免鉴权端点 ===
|
|
|
|
|
|
if path == "/healthz":
|
2026-07-11 01:57:11 +08:00
|
|
|
|
return self.respond({
|
2026-07-07 15:40:43 +08:00
|
|
|
|
"ok": True,
|
|
|
|
|
|
"service": "global-search-api",
|
|
|
|
|
|
"version": VERSION,
|
|
|
|
|
|
"repos": list(REPOS.keys()),
|
|
|
|
|
|
"default_repo": DEFAULT_REPO,
|
2026-07-11 02:27:21 +08:00
|
|
|
|
"write_enabled": bool(WRITE_PASSWORD),
|
2026-07-07 15:40:43 +08:00
|
|
|
|
"ts": time.time(),
|
2026-07-11 02:27:21 +08:00
|
|
|
|
"note": "服务正在运行·read-only 全部免鉴权·写入用 /submit 三步握手"
|
2026-07-07 15:40:43 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
2026-07-11 02:27:21 +08:00
|
|
|
|
# === /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": [
|
2026-07-11 03:03:38 +08:00
|
|
|
|
"1. GET /submit?action=open&pwd=<密码>&ts=<当前时间戳>&repo=<仓ID> → 开门",
|
|
|
|
|
|
"2. GET /submit?action=write&sid=<会话ID>&content=<内容> → 发送内容(可多次)",
|
2026-07-11 02:27:21 +08:00
|
|
|
|
"3. GET /submit?action=commit&sid=<会话ID>&filename=<文件名> → 提交写入",
|
2026-07-11 03:03:38 +08:00
|
|
|
|
"abort. GET /submit?action=abort&sid=<会话ID> → 中止写入",
|
2026-07-11 02:27:21 +08:00
|
|
|
|
],
|
2026-07-11 03:03:38 +08:00
|
|
|
|
"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}字符。"
|
2026-07-11 02:27:21 +08:00
|
|
|
|
}, title=f"握手: {agent}")
|
|
|
|
|
|
|
|
|
|
|
|
# === /submit: 三步写入 ===
|
|
|
|
|
|
if path == "/submit":
|
|
|
|
|
|
action = query.get("action", [""])[0]
|
|
|
|
|
|
|
|
|
|
|
|
if action == "open":
|
2026-07-11 03:03:38 +08:00
|
|
|
|
pwd = query.get("pwd", [""])[0]
|
|
|
|
|
|
ts = query.get("ts", [str(time.time())])[0]
|
2026-07-11 02:27:21 +08:00
|
|
|
|
repo_arg = query.get("repo", [DEFAULT_REPO])[0]
|
2026-07-11 03:03:38 +08:00
|
|
|
|
if not pwd:
|
|
|
|
|
|
return self.respond({"ok": False, "error": "缺少pwd参数", "hint": "发送明文密码 + ts=当前时间戳"})
|
|
|
|
|
|
result = write_session_open(pwd, ts, repo_arg)
|
2026-07-11 02:27:21 +08:00
|
|
|
|
return self.respond(result, title="写入: 开门")
|
|
|
|
|
|
|
|
|
|
|
|
elif action == "write":
|
|
|
|
|
|
sid = query.get("sid", [""])[0]
|
|
|
|
|
|
content = query.get("content", [""])[0]
|
2026-07-11 03:03:38 +08:00
|
|
|
|
encoding = query.get("encoding", [""])[0]
|
2026-07-11 02:27:21 +08:00
|
|
|
|
if not sid:
|
|
|
|
|
|
return self.respond({"ok": False, "error": "缺少sid参数"})
|
|
|
|
|
|
if not content:
|
|
|
|
|
|
return self.respond({"ok": False, "error": "缺少content参数"})
|
2026-07-11 03:03:38 +08:00
|
|
|
|
|
|
|
|
|
|
# 显式 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,用原始内容
|
|
|
|
|
|
|
2026-07-11 02:27:21 +08:00
|
|
|
|
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="写入: 提交")
|
|
|
|
|
|
|
2026-07-11 03:03:38 +08:00
|
|
|
|
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="写入: 中止")
|
|
|
|
|
|
|
2026-07-11 02:27:21 +08:00
|
|
|
|
elif action == "status":
|
|
|
|
|
|
# 查看当前写入会话状态
|
2026-07-11 03:03:38 +08:00
|
|
|
|
with _write_sessions_lock:
|
2026-07-11 02:27:21 +08:00
|
|
|
|
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"],
|
2026-07-11 03:03:38 +08:00
|
|
|
|
"total_size": v["total_size"],
|
2026-07-11 02:27:21 +08:00
|
|
|
|
"remaining_seconds": round(remaining, 1),
|
|
|
|
|
|
}
|
|
|
|
|
|
return self.respond({"ok": True, "active_sessions": active, "count": len(active)})
|
|
|
|
|
|
|
|
|
|
|
|
else:
|
|
|
|
|
|
return self.respond({
|
|
|
|
|
|
"ok": False,
|
|
|
|
|
|
"error": "缺少或无效的action参数",
|
2026-07-11 03:03:38 +08:00
|
|
|
|
"hint": "用 action=open/write/commit/abort/status",
|
2026-07-11 02:27:21 +08:00
|
|
|
|
"steps": [
|
2026-07-11 03:03:38 +08:00
|
|
|
|
"1. GET /submit?action=open&pwd=<密码>&ts=<时间戳>&repo=<仓ID>",
|
2026-07-11 02:27:21 +08:00
|
|
|
|
"2. GET /submit?action=write&sid=<会话ID>&content=<内容>",
|
|
|
|
|
|
"3. GET /submit?action=commit&sid=<会话ID>&filename=<文件名>",
|
2026-07-11 03:03:38 +08:00
|
|
|
|
],
|
|
|
|
|
|
"limits": {
|
|
|
|
|
|
"max_content_per_chunk": MAX_CONTENT_PER_CHUNK,
|
|
|
|
|
|
"max_total_content": MAX_TOTAL_CONTENT,
|
|
|
|
|
|
"session_timeout": WRITE_SESSION_TIMEOUT,
|
|
|
|
|
|
"timestamp_window": "±120秒",
|
|
|
|
|
|
}
|
2026-07-11 02:27:21 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
2026-07-07 15:40:43 +08:00
|
|
|
|
if path == "/status":
|
2026-07-11 01:57:11 +08:00
|
|
|
|
data = get_cached_status()
|
|
|
|
|
|
return self.respond(data)
|
2026-07-07 15:40:43 +08:00
|
|
|
|
|
|
|
|
|
|
if path == "/repos":
|
2026-07-11 01:57:11 +08:00
|
|
|
|
return self.respond({
|
2026-07-07 15:40:43 +08:00
|
|
|
|
"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= 跨仓库"
|
2026-07-11 01:57:11 +08:00
|
|
|
|
return self.respond(result)
|
2026-07-07 15:40:43 +08:00
|
|
|
|
|
|
|
|
|
|
if path == "/system-status":
|
|
|
|
|
|
result = get_system_status(repo_path, repo_id)
|
|
|
|
|
|
result["_hint"] = "read-only 公开端点·不需要 token·用 ?repo= 跨仓库"
|
2026-07-11 01:57:11 +08:00
|
|
|
|
return self.respond(result)
|
2026-07-07 15:40:43 +08:00
|
|
|
|
|
|
|
|
|
|
if path == "/help":
|
2026-07-11 01:57:11 +08:00
|
|
|
|
return self.respond({
|
2026-07-07 15:40:43 +08:00
|
|
|
|
"ok": True,
|
|
|
|
|
|
"version": VERSION,
|
|
|
|
|
|
"endpoints": {
|
|
|
|
|
|
"GET /healthz": "健康检查(免鉴权)",
|
2026-07-11 02:27:21 +08:00
|
|
|
|
"GET /handshake?agent=&tools=&format=&max_url=": "AI报到·服务器动态适配(免鉴权)",
|
2026-07-11 03:03:38 +08:00
|
|
|
|
"GET /submit?action=open&pwd=&ts=&repo=": "写入第一步·开门(密码+时间戳)",
|
2026-07-11 02:27:21 +08:00
|
|
|
|
"GET /submit?action=write&sid=&content=": "写入第二步·发送内容(可多次)",
|
|
|
|
|
|
"GET /submit?action=commit&sid=&filename=": "写入第三步·提交写入",
|
2026-07-11 03:03:38 +08:00
|
|
|
|
"GET /submit?action=abort&sid=": "中止写入会话",
|
2026-07-11 02:27:21 +08:00
|
|
|
|
"GET /submit?action=status": "查看写入会话状态",
|
2026-07-11 01:57:11 +08:00
|
|
|
|
"GET /status": "综合状态(60秒缓存·免鉴权)",
|
2026-07-07 15:40:43 +08:00
|
|
|
|
"GET /repos": "列出所有可用仓库(免鉴权)",
|
2026-07-11 02:27:21 +08:00
|
|
|
|
"GET /system-status?repo=": "拉 SYSTEM-STATUS(免鉴权)",
|
|
|
|
|
|
"GET /broadcast?repo=": "拉最新 BROADCAST(免鉴权)",
|
|
|
|
|
|
"GET /search?q=&top=&repo=": "全仓库文件内容搜索(免鉴权+限速)",
|
2026-07-07 15:40:43 +08:00
|
|
|
|
"GET /tree?path=&depth=&ext=&repo=": "列目录树(免鉴权+限速)",
|
|
|
|
|
|
"GET /file?path=&repo=": "读单文件(限 50KB, 免鉴权+限速)",
|
2026-07-11 02:27:21 +08:00
|
|
|
|
"POST /archive": "HLDP 回执归档(需鉴权)",
|
2026-07-07 15:40:43 +08:00
|
|
|
|
},
|
|
|
|
|
|
"repos": list(REPOS.keys()),
|
|
|
|
|
|
"default_repo": DEFAULT_REPO,
|
|
|
|
|
|
"rate_limit": f"{RATE_LIMIT} req/s per IP",
|
2026-07-11 03:03:38 +08:00
|
|
|
|
"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秒",
|
|
|
|
|
|
},
|
2026-07-11 02:27:21 +08:00
|
|
|
|
"html_mode": "加 ?format=html 返回人类可读 HTML",
|
2026-07-11 03:03:38 +08:00
|
|
|
|
"new_in_1_6_1": [
|
|
|
|
|
|
"移除伪安全'每位加1'变换,改用SHA256+时间戳",
|
|
|
|
|
|
"跨进程文件锁(fcntl)替代threading.Lock",
|
|
|
|
|
|
"内容大小限制(50KB/次, 500KB/会话)",
|
|
|
|
|
|
"base64显式检测(不裸套try/except)",
|
|
|
|
|
|
"abort动作(中止写入+释放锁)",
|
|
|
|
|
|
"文件防覆盖(重名自动加时间戳前缀)",
|
|
|
|
|
|
"secrets.token_hex生成会话ID(防猜测)",
|
|
|
|
|
|
"hmac.compare_digest恒定时间比较(防时序攻击)",
|
2026-07-11 01:57:11 +08:00
|
|
|
|
],
|
2026-07-11 02:27:21 +08:00
|
|
|
|
"base_url": "https://guanghubingshuo.com/global-search",
|
2026-07-07 15:40:43 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
# === 业务查询端点(免鉴权 + 限速) ===
|
|
|
|
|
|
try:
|
|
|
|
|
|
if path == "/search":
|
|
|
|
|
|
kw = query.get("q", [""])[0]
|
|
|
|
|
|
top = int(query.get("top", ["20"])[0])
|
2026-07-11 01:57:11 +08:00
|
|
|
|
return self.respond({
|
2026-07-07 15:40:43 +08:00
|
|
|
|
"ok": True,
|
|
|
|
|
|
"repo": repo_id,
|
|
|
|
|
|
"keyword": kw,
|
|
|
|
|
|
"count": -1,
|
|
|
|
|
|
"results": search_files(repo_path, kw, top)
|
2026-07-11 01:57:11 +08:00
|
|
|
|
}, title=f"搜索: {kw}")
|
2026-07-07 15:40:43 +08:00
|
|
|
|
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
|
2026-07-11 01:57:11 +08:00
|
|
|
|
return self.respond({
|
2026-07-07 15:40:43 +08:00
|
|
|
|
"ok": True,
|
|
|
|
|
|
"repo": repo_id,
|
|
|
|
|
|
"files": list_tree(repo_path, path_arg, depth, ext_filter)
|
2026-07-11 01:57:11 +08:00
|
|
|
|
}, title=f"文件树: {repo_id}")
|
2026-07-07 15:40:43 +08:00
|
|
|
|
elif path == "/file":
|
|
|
|
|
|
path_arg = query.get("path", [""])[0]
|
|
|
|
|
|
result = read_file(repo_path, path_arg)
|
|
|
|
|
|
result["repo"] = repo_id
|
2026-07-11 01:57:11 +08:00
|
|
|
|
return self.respond(result, title=f"文件: {path_arg}")
|
2026-07-07 15:40:43 +08:00
|
|
|
|
else:
|
2026-07-11 01:57:11 +08:00
|
|
|
|
return self.respond({"ok": False, "error": "not found", "hint": "GET /help"}, 404)
|
2026-07-07 15:40:43 +08:00
|
|
|
|
except Exception as e:
|
2026-07-11 01:57:11 +08:00
|
|
|
|
return self.respond({"ok": False, "error": str(e)}, 500)
|
2026-07-07 15:40:43 +08:00
|
|
|
|
|
|
|
|
|
|
def do_POST(self):
|
|
|
|
|
|
parsed = urlparse(self.path)
|
|
|
|
|
|
if parsed.path == "/archive":
|
|
|
|
|
|
ok, reason = check_auth(self.headers)
|
|
|
|
|
|
if not ok:
|
2026-07-11 01:57:11 +08:00
|
|
|
|
return self.respond({
|
2026-07-07 15:40:43 +08:00
|
|
|
|
"ok": False,
|
|
|
|
|
|
"error": "unauthorized",
|
|
|
|
|
|
"reason": reason,
|
2026-07-11 01:57:11 +08:00
|
|
|
|
"_hint": "POST /archive 写操作需鉴权"
|
2026-07-07 15:40:43 +08:00
|
|
|
|
}, 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", "")
|
2026-07-11 01:57:11 +08:00
|
|
|
|
self.respond(archive_hldp(content, type_))
|
2026-07-07 15:40:43 +08:00
|
|
|
|
except Exception as e:
|
2026-07-11 01:57:11 +08:00
|
|
|
|
self.respond({"ok": False, "error": str(e)}, 400)
|
2026-07-07 15:40:43 +08:00
|
|
|
|
else:
|
2026-07-11 01:57:11 +08:00
|
|
|
|
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)
|
2026-07-07 15:40:43 +08:00
|
|
|
|
|
|
|
|
|
|
def send_html_root(self):
|
2026-07-11 01:57:11 +08:00
|
|
|
|
"""根路径 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>'
|
|
|
|
|
|
|
2026-07-07 15:40:43 +08:00
|
|
|
|
html = f"""<!DOCTYPE html>
|
|
|
|
|
|
<html lang="zh-Hans">
|
|
|
|
|
|
<head>
|
|
|
|
|
|
<meta charset="utf-8">
|
2026-07-11 01:57:11 +08:00
|
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
2026-07-07 15:40:43 +08:00
|
|
|
|
<title>GLOBAL-SEARCH-API · 光湖跨仓检索</title>
|
|
|
|
|
|
<style>
|
2026-07-11 01:57:11 +08:00
|
|
|
|
* {{ 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; }}
|
2026-07-07 15:40:43 +08:00
|
|
|
|
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; }}
|
2026-07-11 01:57:11 +08:00
|
|
|
|
.note {{ background: #e8f5e9; border: 1px solid #a5d6a7; padding: 12px 16px; border-radius: 4px; margin: 16px 0; }}
|
2026-07-07 15:40:43 +08:00
|
|
|
|
table {{ border-collapse: collapse; width: 100%; margin: 12px 0; }}
|
|
|
|
|
|
th, td {{ border: 1px solid #e0e0e8; padding: 8px 12px; text-align: left; }}
|
|
|
|
|
|
th {{ background: #f4f4f8; }}
|
2026-07-11 01:57:11 +08:00
|
|
|
|
.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; }}
|
2026-07-07 15:40:43 +08:00
|
|
|
|
</style>
|
|
|
|
|
|
</head>
|
|
|
|
|
|
<body>
|
|
|
|
|
|
|
2026-07-11 01:57:11 +08:00
|
|
|
|
<h1>🌊 GLOBAL-SEARCH-API v{VERSION}</h1>
|
2026-07-07 15:40:43 +08:00
|
|
|
|
|
2026-07-11 01:57:11 +08:00
|
|
|
|
<p><strong>光湖跨仓库检索服务</strong> · 铸渊 ICE-GL-ZY001 · D170 · 2026-07-11</p>
|
2026-07-07 15:40:43 +08:00
|
|
|
|
|
|
|
|
|
|
<div class="note">
|
2026-07-11 01:57:11 +08:00
|
|
|
|
<strong>✅ v1.5.0 新功能:</strong><br>
|
|
|
|
|
|
1. 所有端点支持 <code>?format=html</code> 返回人类可读 HTML<br>
|
|
|
|
|
|
2. 浏览器工具可直接读取结果 (解决 GLM/豆包"超时/解析失败")<br>
|
|
|
|
|
|
3. 死仓库自动检测 · /status 60秒缓存 · 搜索超时降低
|
2026-07-07 15:40:43 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-07-11 01:57:11 +08:00
|
|
|
|
<h2>📦 {len(REPOS)} 个可用仓库</h2>
|
2026-07-07 15:40:43 +08:00
|
|
|
|
<table>
|
|
|
|
|
|
<tr><th>仓 ID</th><th>说明</th></tr>
|
2026-07-11 01:57:11 +08:00
|
|
|
|
{repo_rows}
|
2026-07-07 15:40:43 +08:00
|
|
|
|
</table>
|
|
|
|
|
|
|
2026-07-11 01:57:11 +08:00
|
|
|
|
<h2>🔌 全部端点 (GET 免鉴权, 直接用)</h2>
|
2026-07-07 15:40:43 +08:00
|
|
|
|
|
2026-07-11 01:57:11 +08:00
|
|
|
|
<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>
|
2026-07-07 15:40:43 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-07-11 01:57:11 +08:00
|
|
|
|
<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>
|
2026-07-07 15:40:43 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-07-11 01:57:11 +08:00
|
|
|
|
<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>
|
2026-07-07 15:40:43 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-07-11 01:57:11 +08:00
|
|
|
|
<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>
|
2026-07-07 15:40:43 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-07-11 01:57:11 +08:00
|
|
|
|
<div class="ep">
|
|
|
|
|
|
<strong>GET /tree?depth=2&repo=仓ID</strong> — 文件树<br>
|
|
|
|
|
|
<pre>curl "https://guanghubingshuo.com/global-search/tree?depth=2"</pre>
|
2026-07-07 15:40:43 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-07-11 01:57:11 +08:00
|
|
|
|
<div class="ep">
|
|
|
|
|
|
<strong>GET /broadcast?repo=仓ID</strong> — 最新广播<br>
|
|
|
|
|
|
<strong>GET /system-status?repo=仓ID</strong> — 系统状态<br>
|
|
|
|
|
|
<strong>GET /help</strong> — API 文档
|
2026-07-07 15:40:43 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="note">
|
2026-07-11 01:57:11 +08:00
|
|
|
|
<strong>💡 给 AI 工具的提示:</strong><br>
|
|
|
|
|
|
如果你是浏览器/网页抓取工具 (GLM/豆包/ChatGPT), 在 URL 后面加 <code>&format=html</code> 就能正常读取结果。<br>
|
|
|
|
|
|
本服务支持两种格式: JSON (默认) 和 HTML (?format=html)。
|
2026-07-07 15:40:43 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<hr>
|
|
|
|
|
|
<p style="text-align: center; color: #888; font-size: 12px;">
|
2026-07-11 01:57:11 +08:00
|
|
|
|
铸渊 ICE-GL-ZY001 · 国作登字-2026-A-00037559 · 冰朔 ICE-GL∞ 主权 · v{VERSION}
|
2026-07-07 15:40:43 +08:00
|
|
|
|
</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():
|
2026-07-11 01:57:11 +08:00
|
|
|
|
dead = len(_REPOS_RAW) - len(REPOS)
|
2026-07-07 15:40:43 +08:00
|
|
|
|
server = http.server.HTTPServer(("0.0.0.0", PORT), Handler)
|
|
|
|
|
|
print(f"Global Search API v{VERSION} listening on 0.0.0.0:{PORT}")
|
2026-07-11 01:57:11 +08:00
|
|
|
|
print(f"REPOS = {list(REPOS.keys())} ({len(REPOS)} alive, {dead} skipped)")
|
2026-07-07 15:40:43 +08:00
|
|
|
|
print(f"DEFAULT = {DEFAULT_REPO}")
|
|
|
|
|
|
print(f"TOKEN = {'set' if TOKEN else 'unset (dev mode)'}")
|
2026-07-11 01:57:11 +08:00
|
|
|
|
print(f"HTML mode: ?format=html on all GET endpoints")
|
|
|
|
|
|
print(f"Status cache: {_STATUS_CACHE_TTL}s TTL")
|
2026-07-07 15:40:43 +08:00
|
|
|
|
server.serve_forever()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
|
main()
|