#!/usr/bin/env python3 """AgentCheckin CGI API server (invoked by app/ui/index.cgi). All endpoints are JSON. The fnOS web server validates the NAS login session before calling the CGI, so no extra auth is implemented here. """ import json import os import sys import time import urllib.parse sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from common import (load_accounts, load_settings, save_settings, load_state, save_state, log, fmt_now, now_ts, PID_FILE, TRIGGER_FILE, ensure_dirs, mask_token, public_account) import engine import workbuddy MAX_BODY = 256 * 1024 OAUTH_TTL = 10 * 60 # seconds a login link stays valid # --------------------------------------------------------------------------- # CGI plumbing # --------------------------------------------------------------------------- def cgi_env(): return { "method": os.environ.get("REQUEST_METHOD", "GET").upper(), "uri": os.environ.get("REQUEST_URI", ""), "query": os.environ.get("QUERY_STRING", ""), } def read_body(): try: length = int(os.environ.get("CONTENT_LENGTH") or 0) except ValueError: length = 0 if length <= 0: return {} if length > MAX_BODY: return None raw = sys.stdin.buffer.read(length) try: data = json.loads(raw.decode("utf-8")) return data if isinstance(data, dict) else {} except (ValueError, json.JSONDecodeError): return {} def respond(obj, status="200 OK"): body = json.dumps(obj, ensure_ascii=False) out = sys.stdout out.write("Status: %s\r\n" % status) out.write("Content-Type: application/json; charset=utf-8\r\n") out.write("Cache-Control: no-store\r\n") out.write("Content-Length: %d\r\n" % len(body.encode("utf-8"))) out.write("\r\n") out.write(body) def error(msg, status="400 Bad Request"): respond({"error": msg}, status) def route_path(uri): path = uri.split("?", 1)[0] idx = path.find("index.cgi") if idx >= 0: path = path[idx + len("index.cgi"):] return path.rstrip("/") or "/" def query_params(qs): return {k: v[0] for k, v in urllib.parse.parse_qs(qs, keep_blank_values=True).items()} def daemon_alive(): try: with open(PID_FILE) as f: pid = int(f.read().strip()) os.kill(pid, 0) return True except (OSError, ValueError): return False # --------------------------------------------------------------------------- # Handlers # --------------------------------------------------------------------------- def h_state(_params, _body): data = engine.dashboard() data["daemon_alive"] = daemon_alive() return data def h_checkin(_params, body): account_id = body.get("account_id") accounts = load_accounts() if account_id and not any(a.get("id") == account_id for a in accounts): return ("error", "账号不存在") if daemon_alive(): ensure_dirs() with open(TRIGGER_FILE, "w") as f: f.write(json.dumps({"account_id": account_id, "ts": now_ts()})) return {"ok": True, "mode": "trigger", "message": "已触发签到,守护进程正在执行"} # daemon not running: run synchronously as a fallback results = engine.run_all(account_id=account_id, reason="manual-cgi") return {"ok": True, "mode": "sync", "results": results} def h_accounts(_params, _body): return {"accounts": [public_account(a) for a in load_accounts()]} def h_add_wb_manual(_params, body): token = (body.get("access_token") or "").strip() if not token: return ("error", "access_token 不能为空") if len(token) > 8192: return ("error", "access_token 过长") acc = engine.add_workbuddy_manual( name=(body.get("name") or "").strip()[:60], access_token=token, refresh_token=(body.get("refresh_token") or "").strip(), domain=(body.get("domain") or "codebuddy.cn").strip(), ) return {"ok": True, "account": public_account(acc)} def h_add_trae(_params, body): token = (body.get("access_token") or "").strip() if not token: return ("error", "access_token 不能为空") if len(token) > 8192: return ("error", "access_token 过长") acc = engine.add_trae( name=(body.get("name") or "").strip()[:60], access_token=token, refresh_token=(body.get("refresh_token") or "").strip(), device_id_str=(body.get("device_id") or "").strip(), ) return {"ok": True, "account": public_account(acc)} def h_add_qoder(_params, body): token = (body.get("access_token") or "").strip() if not token: return ("error", "access_token 不能为空") if len(token) > 8192: return ("error", "access_token 过长") device = body.get("device") or {} if not isinstance(device, dict): return ("error", "device 必须是对象") acc = engine.add_qoder( name=(body.get("name") or "").strip()[:60], access_token=token, refresh_token=(body.get("refresh_token") or "").strip(), device=device, domain=(body.get("domain") or "openapi.qoder.com.cn").strip(), ) return {"ok": True, "account": public_account(acc)} def h_update_account(_params, body): acc_id = body.get("id") if not acc_id: return ("error", "缺少 id") fields = {} if "name" in body: fields["name"] = str(body["name"])[:60] if "enabled" in body: fields["enabled"] = bool(body["enabled"]) acc = engine.update_account(acc_id, **fields) if not acc: return ("error", "账号不存在") return {"ok": True, "account": public_account(acc)} def h_fix_qoder_domain(_params, body): """Probe both Qoder sites with the account token and switch to the one that authenticates (the two sites' tokens are not interchangeable).""" acc_id = body.get("id") if not acc_id: return ("error", "缺少 id") accounts = load_accounts() acc = engine.find_account(accounts, acc_id) if not acc or acc.get("platform") != "qoder": return ("error", "账号不存在或不是 Qoder 账号") import qoder as _q probe = _q.probe_domains(acc) for dom, r in probe.items(): if r.get("auth"): acc.setdefault("auth", {})["domain"] = dom engine.save_accounts(accounts) log("qoder account %s domain switched to %s" % (acc_id, dom)) return {"ok": True, "domain": dom, "probe": probe} return {"ok": False, "probe": probe, "error": "两个站点均无法认证(Token 可能已过期),请重新获取 Token"} def h_delete_account(_params, body): acc_id = body.get("id") if not acc_id: return ("error", "缺少 id") ok = engine.delete_account(acc_id) if not ok: return ("error", "账号不存在") return {"ok": True} def h_test_account(_params, body): acc_id = body.get("id") if not acc_id: return ("error", "缺少 id") return engine.test_account(acc_id) def h_wb_oauth_begin(_params, _body): state, login_url, err = workbuddy.begin_oauth() if err: return ("error", err) st = load_state() sessions = st.setdefault("oauth_sessions", {}) # drop expired sessions now = now_ts() for k in list(sessions.keys()): if now - sessions[k].get("created", 0) > OAUTH_TTL: del sessions[k] sessions[state] = {"created": now} save_state(st) log("workbuddy oauth session started") return {"ok": True, "state": state, "login_url": login_url, "ttl": OAUTH_TTL} def h_wb_oauth_poll(params, _body): state = params.get("state", "") if not state: return ("error", "缺少 state") st = load_state() sessions = st.get("oauth_sessions") or {} sess = sessions.get(state) if not sess: return ("error", "授权会话不存在或已过期,请重新生成链接") if now_ts() - sess.get("created", 0) > OAUTH_TTL: del sessions[state] save_state(st) return ("error", "授权链接已过期,请重新生成") status, payload, err = workbuddy.poll_oauth(state) if status == "done": acc = engine.add_workbuddy_oauth(payload) del sessions[state] save_state(st) return {"ok": True, "status": "done", "account": public_account(acc)} return {"ok": True, "status": "waiting"} def h_settings_get(_params, _body): return {"settings": load_settings()} def h_settings_post(_params, body): settings = load_settings() if "schedule_times" in body: times = body["schedule_times"] if not isinstance(times, list): return ("error", "schedule_times 必须是数组") clean = [] for t in times: t = str(t) if len(t) == 5 and t[2] == ":" and t[:2].isdigit() and t[3:].isdigit(): hh, mm = int(t[:2]), int(t[3:]) if 0 <= hh < 24 and 0 <= mm < 60: clean.append(t) if not clean: return ("error", "至少需要一个有效的签到时间(HH:MM)") settings["schedule_times"] = sorted(set(clean)) if "webhook_url" in body: url = str(body["webhook_url"]).strip() if url and not url.startswith(("http://", "https://")): return ("error", "webhook_url 必须以 http(s):// 开头") settings["webhook_url"] = url[:500] if "webhook_type" in body: wt = str(body["webhook_type"]) if wt in engine.WEBHOOK_TYPES: settings["webhook_type"] = wt else: return ("error", "不支持的 webhook_type") if "notify_on" in body: nf = body["notify_on"] if isinstance(nf, list): settings["notify_on"] = [n for n in nf if n in ("success", "fail")] save_settings(settings) return {"ok": True, "settings": settings} def h_logs(params, _body): try: lines = int(params.get("lines", "200")) except ValueError: lines = 200 lines = max(10, min(lines, 2000)) from common import LOG_FILE if not os.path.exists(LOG_FILE): return {"lines": []} try: with open(LOG_FILE, encoding="utf-8") as f: content = f.read().splitlines() return {"lines": content[-lines:]} except OSError as e: return {"lines": [], "error": str(e)} def h_debug(_params, _body): """Diagnostics: resolved paths, env, daemon liveness (no secrets).""" import common as _c info = { "data_dir": _c.DATA_DIR, "accounts_file": _c.ACCOUNTS_FILE, "state_file": _c.STATE_FILE, "pid_file": _c.PID_FILE, "log_file": _c.LOG_FILE, "env": { "TRIM_PKGVAR": os.environ.get("TRIM_PKGVAR", ""), "TRIM_APPDEST": os.environ.get("TRIM_APPDEST", ""), "AGENTCHECKIN_DEV": os.environ.get("AGENTCHECKIN_DEV", ""), }, "file": __file__, } info["state_file_exists"] = os.path.exists(_c.STATE_FILE) if info["state_file_exists"]: st = _c.load_state() info["state_daemon"] = st.get("daemon") info["state_last_run"] = st.get("last_run") info["pid_file_exists"] = os.path.exists(_c.PID_FILE) info["daemon_alive"] = daemon_alive() return info ROUTES = { ("GET", "/api/state"): h_state, ("GET", "/api/debug"): h_debug, ("POST", "/api/checkin"): h_checkin, ("GET", "/api/accounts"): h_accounts, ("POST", "/api/accounts/add_wb_manual"): h_add_wb_manual, ("POST", "/api/accounts/add_trae"): h_add_trae, ("POST", "/api/accounts/add_qoder"): h_add_qoder, ("POST", "/api/accounts/update"): h_update_account, ("POST", "/api/accounts/delete"): h_delete_account, ("POST", "/api/accounts/fix_domain"): h_fix_qoder_domain, ("POST", "/api/accounts/test"): h_test_account, ("POST", "/api/wb_oauth_begin"): h_wb_oauth_begin, ("GET", "/api/wb_oauth_poll"): h_wb_oauth_poll, ("GET", "/api/settings"): h_settings_get, ("POST", "/api/settings"): h_settings_post, ("GET", "/api/logs"): h_logs, } def main(): env = cgi_env() path = route_path(env["uri"]) params = query_params(env["query"]) handler = ROUTES.get((env["method"], path)) if not handler: return error("not found: %s %s" % (env["method"], path), "404 Not Found") body = read_body() if env["method"] == "POST" else {} if body is None: return error("payload too large") try: result = handler(params, body) except Exception as e: log("api error on %s %s: %s" % (env["method"], path, e), "ERROR") return error("internal error: %s" % e, "500 Internal Server Error") if isinstance(result, tuple) and len(result) == 2 and result[0] == "error": return error(result[1]) respond(result if isinstance(result, dict) else {"data": result}) if __name__ == "__main__": main()