Files
dailycheckin-plus/panel/server.py
T
2026-10-15 06:53:42 -07:00

482 lines
16 KiB
Python
Raw 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.
"""面板 HTTP 服务。
刻意只用标准库 `http.server`:容器里除了引擎自己的 4 个依赖之外,
不再引入 flask/fastapi/uvicorn 之类的框架,任何架构(x86 / arm64 / armv7)
都能直接跑起来,也不会因为依赖装不上而白折腾。
"""
from __future__ import annotations
import json
import mimetypes
import os
import re
import time
import urllib.parse
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from . import catalog, cookies as ck, paths, runner, schedule, store, verify
PANEL_VERSION = "1.0.0"
MAX_BODY = 4 * 1024 * 1024
ROUTES: list[tuple[str, re.Pattern, str]] = []
def route(method: str, pattern: str):
def deco(func):
ROUTES.append((method, re.compile("^" + pattern + "$"), func.__name__))
return func
return deco
# --------------------------------------------------------------------------- #
# 工具
# --------------------------------------------------------------------------- #
def engine_version() -> str:
path = os.path.join(paths.HOME, "dailycheckin", "__version__.py")
try:
with open(path, encoding="utf-8") as f:
m = re.search(r'__version__\s*=\s*["\']([^"\']+)', f.read())
if m:
return m.group(1)
except Exception: # noqa: BLE001
pass
try:
from importlib.metadata import version
return version("dailycheckin")
except Exception: # noqa: BLE001
return ""
def tail_log(name: str = "dailycheckin.log", lines: int = 300) -> str:
path = os.path.join(paths.LOG_DIR, os.path.basename(name))
if not os.path.exists(path):
return ""
lines = max(1, min(int(lines), 3000))
try:
with open(path, "rb") as f:
f.seek(0, os.SEEK_END)
size = f.tell()
block = min(size, lines * 400)
f.seek(size - block)
data = f.read().decode("utf-8", errors="replace")
return "\n".join(data.splitlines()[-lines:])
except Exception as exc: # noqa: BLE001
return f"读取日志失败:{exc}"
def log_files() -> list[dict]:
if not os.path.isdir(paths.LOG_DIR):
return []
out = []
for name in sorted(os.listdir(paths.LOG_DIR)):
full = os.path.join(paths.LOG_DIR, name)
if os.path.isfile(full):
out.append({"name": name, "size": os.path.getsize(full), "mtime": os.path.getmtime(full)})
return out
def build_state() -> dict:
data = store.load()
status = runner.all_status()
sites = []
for meta in catalog.all_sites():
key = meta["key"]
accounts = store.accounts(data, key)
# 逐账号做一次本地格式体检(不联网),前端直接显示
for account in accounts:
account["_check"] = local_check(key, account)
sites.append(
{
**meta,
"accounts": accounts,
"configured": store.has_config(data, key),
"status": status["sites"].get(key, {"state": "unverified", "source": None, "message": "", "accounts": []}),
}
)
notice = {key: data.get(key, "") for key in store.NOTICE_KEY_NAMES}
return {
"panel": {
"title": paths.TITLE,
"version": PANEL_VERSION,
"token_required": bool(paths.TOKEN),
"home": paths.HOME,
"config_path": paths.CONFIG_PATH,
"tz": paths.TZ,
},
"engine": {**runner.probe_engine(), "version": engine_version()},
"sites": sites,
"notice_keys": [{"key": k, "label": v} for k, v in store.NOTICE_KEYS],
"notice": notice,
"schedule": schedule.parse(),
"status_updated": status.get("updated", 0),
"runs": runner.list_runs(8),
"backups": store.list_backups()[:8],
"server_time": time.time(),
}
def local_check(key: str, account: dict) -> dict:
"""纯本地格式体检:不发任何网络请求。"""
meta = catalog.site_meta(key)
if meta["credential"] == "cookie":
raw = str(account.get("cookie") or "")
if not raw.strip():
return {"ok": None, "message": "未填写 cookie", "warnings": [], "missing": [], "keys": [], "count": 0}
result = ck.normalize(raw, required=meta.get("required") or [])
return {
"ok": result.ok,
"message": result.cookie if result.ok else ";".join(result.errors) or "缺少必填字段",
"warnings": result.warnings,
"missing": result.missing,
"keys": [k for k, _ in result.items],
"count": result.count,
"needs_fix": result.needs_fix(raw),
"normalized": result.cookie,
"source": result.source,
}
missing = []
for field in meta.get("fields", []):
if field.get("required") and not str(account.get(field["key"]) or "").strip():
missing.append(field["label"])
return {"ok": not missing, "message": "" if not missing else "缺少:" + "、".join(missing), "warnings": [], "missing": missing, "keys": [], "count": 0}
# --------------------------------------------------------------------------- #
# 路由处理
# --------------------------------------------------------------------------- #
@route("GET", r"/api/health")
def h_health(h):
return {"ok": True, "time": time.time(), "version": PANEL_VERSION}
@route("GET", r"/api/state")
def h_state(h):
return build_state()
@route("GET", r"/api/config/raw")
def h_config_raw(h):
return {"text": store.read_raw(), "path": paths.CONFIG_PATH}
@route("PUT", r"/api/config/raw")
def h_config_put(h):
body = h.body()
text = body.get("text", "")
if not isinstance(text, str) or not text.strip():
raise HttpError(400, "内容为空")
try:
data = store.write_raw(text)
except json.JSONDecodeError as exc:
raise HttpError(400, f"JSON 语法错误:{exc}") from exc
return {"ok": True, "sites": len([k for k, v in data.items() if isinstance(v, list)])}
@route("POST", r"/api/cookies/normalize")
def h_normalize(h):
body = h.body()
raw = body.get("raw", "")
key = str(body.get("site", "")).upper()
required = catalog.required_of(key) if key else []
result = ck.normalize(raw, required=required)
out = result.as_dict()
out["required"] = required
out["recommended"] = catalog.site_meta(key).get("recommended", []) if key else []
return out
@route("POST", r"/api/sites/(?P<key>[A-Za-z0-9_]+)/accounts")
def h_save_accounts(h):
key = h.params["key"].upper()
body = h.body()
accounts = body.get("accounts")
if not isinstance(accounts, list):
raise HttpError(400, "accounts 必须是数组")
cleaned: list[dict] = []
for one in accounts:
if not isinstance(one, dict):
continue
account = {}
for field_key, value in one.items():
if str(field_key).startswith("_"):
continue
if field_key == "cookie":
result = ck.normalize(str(value or ""), required=catalog.required_of(key))
account["cookie"] = result.cookie
account["_normalized"] = result.ok
else:
account[field_key] = value
if any(str(v or "").strip() for k, v in account.items() if not str(k).startswith("_")):
cleaned.append({k: v for k, v in account.items() if not str(k).startswith("_")})
data = store.load()
store.set_accounts(data, key, cleaned)
store.write(data)
return {"ok": True, "count": len(cleaned)}
@route("POST", r"/api/notice")
def h_notice(h):
body = h.body()
notice = body.get("notice")
if not isinstance(notice, dict):
raise HttpError(400, "notice 必须是对象")
data = store.load()
changed = 0
for notice_key in store.NOTICE_KEY_NAMES:
if notice_key in notice:
data[notice_key] = notice[notice_key]
changed += 1
store.write(data)
return {"ok": True, "changed": changed}
@route("POST", r"/api/sites/(?P<key>[A-Za-z0-9_]+)/verify")
def h_verify_site(h):
key = h.params["key"].upper()
data = store.load()
accounts = store.accounts(data, key)
if not accounts:
return {"ok": True, "state": "unverified", "results": [], "message": "该站点还没有配置账号"}
meta = catalog.site_meta(key)
results = [verify.verify_account(key, one) for one in accounts]
states = {one["ok"] for one in results}
state = "valid" if states == {True} else ("invalid" if False in states else "unknown")
users = "、".join([one["user"] for one in results if one.get("user")])
runner.mark_site(
key,
state,
"api",
accounts=results,
user=users or None,
message=";".join([one["message"] for one in results])[:300],
)
return {"ok": True, "state": state, "results": results, "label": meta["label"]}
@route("POST", r"/api/run")
def h_run(h):
body = h.body()
sites = body.get("sites") or []
if not isinstance(sites, list):
raise HttpError(400, "sites 必须是数组")
return runner.start_run([str(s) for s in sites])
@route("POST", r"/api/verify")
def h_verify(h):
body = h.body()
sites = body.get("sites") or []
if not isinstance(sites, list):
raise HttpError(400, "sites 必须是数组")
return runner.start_verify([str(s) for s in sites])
@route("GET", r"/api/runs")
def h_runs(h):
return {"runs": runner.list_runs(20)}
@route("GET", r"/api/runs/(?P<id>[A-Za-z0-9]+)")
def h_run_get(h):
one = runner.get_run(h.params["id"])
if not one:
raise HttpError(404, "找不到该任务")
return one
@route("GET", r"/api/logs")
def h_logs(h):
query = h.query
name = query.get("name", ["dailycheckin.log"])[0]
lines = int(query.get("lines", ["400"])[0])
return {"files": log_files(), "name": os.path.basename(name), "text": tail_log(name, lines)}
@route("GET", r"/api/schedule")
def h_schedule_get(h):
return schedule.parse()
@route("PUT", r"/api/schedule")
def h_schedule_put(h):
body = h.body()
enabled = bool(body.get("enabled", True))
cron = str(body.get("cron") or schedule.DEFAULT_CRON)
try:
out = schedule.set_schedule(enabled, cron)
except ValueError as exc:
raise HttpError(400, str(exc)) from exc
return out
@route("GET", r"/api/crontab")
def h_crontab_get(h):
return {"text": schedule.read_text(), "installed": schedule.installed()}
@route("PUT", r"/api/crontab")
def h_crontab_put(h):
body = h.body()
text = body.get("text", "")
if not isinstance(text, str) or not text.strip():
raise HttpError(400, "内容为空")
schedule.write_text(text)
return {"ok": True, "applied": schedule.apply()}
@route("GET", r"/api/backups")
def h_backups(h):
return {"backups": store.list_backups()}
@route("POST", r"/api/backups/(?P<name>[A-Za-z0-9._\-]+)/restore")
def h_restore(h):
try:
store.restore_backup(h.params["name"])
except ValueError as exc:
raise HttpError(400, str(exc)) from exc
return {"ok": True}
# --------------------------------------------------------------------------- #
# HTTP 层
# --------------------------------------------------------------------------- #
class HttpError(Exception):
def __init__(self, status: int, message: str) -> None:
super().__init__(message)
self.status = status
self.message = message
HANDLERS = {}
class Handler(BaseHTTPRequestHandler):
server_version = "dailycheckin-panel/" + PANEL_VERSION
protocol_version = "HTTP/1.1"
# ----- 基础 -----
def log_message(self, fmt, *args): # noqa: A003 - 覆盖父类,降低噪音
if os.environ.get("PANEL_VERBOSE"):
super().log_message(fmt, *args)
def _send(self, status: int, payload: bytes, content_type: str, extra: dict | None = None):
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(payload)))
self.send_header("Cache-Control", "no-store")
for key, value in (extra or {}).items():
self.send_header(key, value)
self.end_headers()
if self.command != "HEAD":
self.wfile.write(payload)
def _json(self, status: int, data):
body = json.dumps(data, ensure_ascii=False).encode("utf-8")
self._send(status, body, "application/json; charset=utf-8")
def body(self) -> dict:
try:
length = int(self.headers.get("Content-Length") or 0)
except ValueError:
length = 0
if length <= 0:
return {}
if length > MAX_BODY:
raise HttpError(413, "请求体过大")
raw = self.rfile.read(length)
try:
data = json.loads(raw.decode("utf-8"))
except Exception as exc: # noqa: BLE001
raise HttpError(400, f"请求体不是合法 JSON:{exc}") from exc
return data if isinstance(data, dict) else {"_": data}
def _authorized(self) -> bool:
if not paths.TOKEN:
return True
header = self.headers.get("X-Panel-Token") or ""
if header == paths.TOKEN:
return True
return self.query.get("token", [""])[0] == paths.TOKEN
def _dispatch(self, method: str):
parsed = urllib.parse.urlparse(self.path)
self.path_only = urllib.parse.unquote(parsed.path)
self.query = urllib.parse.parse_qs(parsed.query)
for route_method, pattern, name in ROUTES:
m = pattern.match(self.path_only)
if not m:
continue
if route_method != method:
continue
self.params = m.groupdict()
if not self._authorized():
return self._json(401, {"error": "需要口令", "token_required": True})
try:
result = globals()[name](self)
except HttpError as exc:
return self._json(exc.status, {"error": exc.message})
except ValueError as exc:
return self._json(400, {"error": str(exc)})
except Exception as exc: # noqa: BLE001
return self._json(500, {"error": f"{type(exc).__name__}: {exc}"})
return self._json(200, result)
if method == "GET":
return self._static(self.path_only)
return self._json(404, {"error": f"未知接口 {method} {self.path_only}"})
def do_GET(self): # noqa: N802
self._dispatch("GET")
def do_POST(self): # noqa: N802
self._dispatch("POST")
def do_PUT(self): # noqa: N802
self._dispatch("PUT")
# ----- 静态文件 -----
def _static(self, path: str):
if path in ("/", "/index.html"):
return self._file(os.path.join(paths.STATIC_DIR, "index.html"))
if path == "/favicon.ico":
return self._send(204, b"", "image/x-icon")
if path.startswith("/static/"):
name = os.path.normpath(path[len("/static/") :]).replace("\\", "/")
if name.startswith("..") or name.startswith("/"):
return self._json(400, {"error": "非法路径"})
return self._file(os.path.join(paths.STATIC_DIR, name))
return self._json(404, {"error": "not found"})
def _file(self, full: str):
if not os.path.isfile(full):
return self._json(404, {"error": "文件不存在"})
ctype = mimetypes.guess_type(full)[0] or "application/octet-stream"
if ctype.startswith("text/") or ctype in ("application/javascript", "application/json"):
ctype += "; charset=utf-8"
with open(full, "rb") as f:
self._send(200, f.read(), ctype)
def serve() -> None:
paths.ensure_dirs()
runner.bootstrap()
httpd = ThreadingHTTPServer((paths.HOST, paths.PORT), Handler)
httpd.daemon_threads = True
shown = "127.0.0.1" if paths.HOST == "0.0.0.0" else paths.HOST
print(f"签到管理面板已启动:http://{shown}:{paths.PORT}", flush=True)
print(f" 工作目录 : {paths.HOME}")
print(f" 配置文件 : {paths.CONFIG_PATH}")
print(f" 口令保护 : {'已开启' if paths.TOKEN else '未开启(仅建议内网使用)'}", flush=True)
try:
httpd.serve_forever()
except KeyboardInterrupt:
print("\n已停止", flush=True)
finally:
httpd.server_close()