Files
fnos-gitea-sync/GiteaHubSync/app/backend/panel.py
T
cbb 6dcd246567 feat: v1.1.0 — GitHub 多账号切换 + 自动创建仓库 + 私有 Gitea 仓库支持
面向大众使用的三项核心能力:

1. GitHub 多账号(设置页)
   - 添加多个 GitHub 账号(用户名 + Personal Access Token),一键切换推送目标
   - Token 自动识别账号名;添加时先调 /user 验证,失败给出明确原因
   - 每个账号独立记录同步进度(状态目录 .last-pushed/<账号>/<仓库>),
     切回原账号不会重复推送
   - 旧版「默认(SSH 私钥)」账号自动迁移生成,升级后行为不变;
     首次添加 Token 账号会自动切换

2. 自动创建 GitHub 仓库
   - 添加/同步时 GitHub 上不存在同名仓库,自动按账号设置建为私有/公开
   - 「自动创建」可开关;无 Token 时提示去设置页配置

3. 私有 Gitea 仓库支持
   - 设置页新增 Gitea 用户名/密码;密码经 git credential store 文件
     (.gitcreds,600)供 clone/fetch 使用,镜像仓库 remote 不落密码
   - API 查询同步改用 Basic Auth,私有仓库可被列出和克隆

其他:
- 首页/状态页显示当前 GitHub 目标账号与推送方式
- GitHub Token、Gitea 密码在日志和报错中自动打码
- accounts.json / settings.json / .gitcreds 均 600 权限
- manifest 版本 1.0.0 → 1.1.0,README 补充大众用法说明
2026-10-13 19:30:18 +08:00

1335 lines
62 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.
#!/usr/bin/env python3
# ============================================================
# GiteaHubSync — Gitea → GitHub 镜像同步守护进程 + 控制面板
# 纯 Python 标准库。随 fnOS 应用安装,由 cmd/main 以
# setsid nohup 方式拉起;数据目录 = TRIM_PKGVAR(@appdata/GiteaHubSync)
# 页面: / 控制面板(开关/立即同步/快捷入口)
# /status 项目状态(每个仓库的详细同步状态)
# /manage 项目管理(从 Gitea 快捷添加/移除同步项目)
# /settings 设置(GitHub 多账号/切换用户、Gitea 用户名、自动创建仓库)
# 多账号: accounts.json 保存多个 GitHub 账号(用户名 + Personal Access Token),
# 可随时切换「当前账号」;每个账号独立记录同步进度。带 Token 的账号走
# HTTPS 推送并支持自动创建 GitHub 仓库;无 Token 的旧账号走 SSH 私钥推送。
# ============================================================
import atexit
import json
import os
import re
import signal
import subprocess
import threading
import time
import uuid
from datetime import datetime
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import quote
APP_DIR = Path(__file__).resolve().parent.parent
DATA_DIR = Path(os.environ.get("GHS_DATA_DIR", "/vol1/@appdata/GiteaHubSync"))
REPO_DIR = DATA_DIR / "repos"
STATE_DIR = DATA_DIR / ".last-pushed"
LOG_FILE = DATA_DIR / "sync.log"
PID_FILE = DATA_DIR / "daemon.pid"
PANEL_STATE = DATA_DIR / "state.json"
REPOS_FILE = DATA_DIR / "repos.json"
STATS_FILE = DATA_DIR / "stats.json"
ACCOUNTS_FILE = DATA_DIR / "accounts.json" # GitHub 多账号(含 Token,权限 600)
SETTINGS_FILE = DATA_DIR / "settings.json" # Gitea 用户名/密码、自动创建开关
GITCREDS_FILE = DATA_DIR / ".gitcreds" # git credential store(Gitea 密码,权限 600)
SSH_KEY = APP_DIR / "ssh" / "id_ed25519"
if (DATA_DIR / "id_ed25519").exists():
SSH_KEY = DATA_DIR / "id_ed25519"
KNOWN_HOSTS = DATA_DIR / "known_hosts"
GITEA_API = "http://127.0.0.1:3000/api/v1"
GITEA_UI = os.environ.get("GITEA_URL", "http://192.168.31.10:3000")
DEFAULT_REPOS = ["SMB_Android", "DCU_APP", "GD32E50X_BOOT", "DCU_DesktopController",
"Motor_v2", "DCU_ECAT_Master", "GD32_BOOT"]
LEGACY_GH_USER = "cbb2625274797" # 旧版硬编码账号,仅用于首次运行的迁移
LEGACY_GITEA_USER = "cbb" # 旧版硬编码的 Gitea 用户名,仅作默认值
INTERVAL = 10
PANEL_PORT = int(os.environ.get("PANEL_PORT", "8899"))
START_TS = time.time()
NAME_RE = re.compile(r"^[A-Za-z0-9._-]{1,100}$")
ENV = dict(os.environ)
ENV["GIT_SSH_COMMAND"] = (f"ssh -i {SSH_KEY} -o IdentitiesOnly=yes "
f"-o StrictHostKeyChecking=accept-new "
f"-o UserKnownHostsFile={KNOWN_HOSTS}")
ENV["GIT_TERMINAL_PROMPT"] = "0"
# ---------------- 应用设置(settings.json) ----------------
cfg = {"gitea_user": LEGACY_GITEA_USER, "gitea_pass": "", "auto_create": True}
def load_settings():
try:
d = json.loads(SETTINGS_FILE.read_text(encoding="utf-8"))
if isinstance(d.get("gitea_user"), str) and valid_name(d["gitea_user"]):
cfg["gitea_user"] = d["gitea_user"]
cfg["gitea_pass"] = str(d.get("gitea_pass") or "")
cfg["auto_create"] = bool(d.get("auto_create", True))
except Exception:
pass
write_gitcreds()
def save_settings():
try:
SETTINGS_FILE.parent.mkdir(parents=True, exist_ok=True)
SETTINGS_FILE.write_text(json.dumps(cfg, ensure_ascii=False, indent=1),
encoding="utf-8")
os.chmod(SETTINGS_FILE, 0o600) # 含 Gitea 密码,仅属主可读
except Exception as e:
log(f"保存 settings.json 失败: {e}")
write_gitcreds()
def gitea_base():
"""Gitea 上当前用户的仓库前缀"""
return f"http://127.0.0.1:3000/{cfg['gitea_user']}"
# ---------------- GitHub 多账号(accounts.json) ----------------
accounts = [] # [{id,label,gh_user,token,private,created}]
active_id = None
def load_accounts():
"""加载账号;首次运行从旧版迁移:生成「默认(SSH 私钥)」账号,行为与旧版一致"""
global accounts, active_id
try:
d = json.loads(ACCOUNTS_FILE.read_text(encoding="utf-8"))
accounts = [a for a in d.get("accounts", []) if a.get("id") and a.get("gh_user")]
active_id = d.get("active")
except Exception:
accounts = []
if not accounts:
accounts = [{"id": "default", "label": "默认(SSH 私钥)",
"gh_user": LEGACY_GH_USER, "token": "", "private": True,
"created": datetime.now().strftime("%Y-%m-%d %H:%M:%S")}]
active_id = "default"
migrate_flat_state()
save_accounts()
if active_id not in [a["id"] for a in accounts]:
active_id = accounts[0]["id"]
save_accounts()
def save_accounts():
try:
ACCOUNTS_FILE.parent.mkdir(parents=True, exist_ok=True)
ACCOUNTS_FILE.write_text(
json.dumps({"active": active_id, "accounts": accounts},
ensure_ascii=False, indent=1), encoding="utf-8")
os.chmod(ACCOUNTS_FILE, 0o600) # 含 Token,仅属主可读
except Exception as e:
log(f"保存 accounts.json 失败: {e}")
def active_account():
for a in accounts:
if a["id"] == active_id:
return a
return accounts[0] if accounts else None
def find_account(aid):
for a in accounts:
if a["id"] == aid:
return a
return None
def migrate_flat_state():
"""旧版同步状态平铺在 STATE_DIR/<name>;新版按账号分目录 STATE_DIR/<account>/<name>"""
try:
legacy_dir = STATE_DIR / "default"
legacy_dir.mkdir(parents=True, exist_ok=True)
for f in STATE_DIR.iterdir():
if f.is_file() and valid_name(f.name):
f.rename(legacy_dir / f.name)
except Exception:
pass
def state_path(acc, name):
return STATE_DIR / ((acc or {}).get("id") or "default") / name
def mask_token(tok):
if not tok:
return ""
if len(tok) <= 8:
return "****"
return tok[:7] + "****" + tok[-4:]
def scrub(s, acc):
"""日志/报错脱敏:GitHub Token 和 Gitea 密码不进日志"""
if not s:
return s
if acc and acc.get("token"):
s = s.replace(acc["token"], "***")
if cfg.get("gitea_pass"):
s = s.replace(cfg["gitea_pass"], "***")
return s
state = {"enabled": True, "syncing": False, "last_check": None, "checks": 0,
"last_result": "尚未运行", "loop_last": None}
stats = {"total_checks": 0, "total_pushes": 0, "total_failures": 0}
op_lock = threading.Lock()
# ---------------- 基础工具 ----------------
def log(msg):
try:
DATA_DIR.mkdir(parents=True, exist_ok=True)
with open(LOG_FILE, "a", encoding="utf-8") as f:
f.write(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] {msg}\n")
except Exception:
pass
def write_gitcreds():
"""把 Gitea 凭据写成 git credential store 文件(600),供 clone/fetch 读取;
这样镜像仓库的 remote 地址里不保存密码。清除密码时删除该文件。"""
try:
if cfg.get("gitea_pass"):
user = quote(str(cfg["gitea_user"]), safe="")
pw = quote(str(cfg["gitea_pass"]), safe="")
GITCREDS_FILE.write_text(f"http://{user}:{pw}@127.0.0.1:3000\n",
encoding="utf-8")
os.chmod(GITCREDS_FILE, 0o600)
elif GITCREDS_FILE.exists():
GITCREDS_FILE.unlink()
except Exception:
pass
def git_creds_args():
if cfg.get("gitea_pass") and GITCREDS_FILE.exists():
return ["-c", f"credential.helper=store --file={GITCREDS_FILE}"]
return []
def git(args, timeout=300):
return subprocess.run(["git"] + git_creds_args() + args, capture_output=True,
text=True, env=ENV, timeout=timeout)
def load_stats():
global stats
try:
stats.update(json.loads(STATS_FILE.read_text(encoding="utf-8")))
except Exception:
pass
def save_stats():
try:
STATS_FILE.write_text(json.dumps(stats), encoding="utf-8")
except Exception:
pass
def load_repos():
"""同步项目列表(repos.json),首次运行播种默认 7 个"""
try:
data = json.loads(REPOS_FILE.read_text(encoding="utf-8"))
repos = [r for r in data.get("repos", []) if valid_name(r)]
if repos:
return repos
except Exception:
pass
save_repos(DEFAULT_REPOS)
return list(DEFAULT_REPOS)
def save_repos(repos):
try:
DATA_DIR.mkdir(parents=True, exist_ok=True)
REPOS_FILE.write_text(json.dumps({"repos": repos}, ensure_ascii=False, indent=1),
encoding="utf-8")
except Exception as e:
log(f"保存 repos.json 失败: {e}")
def valid_name(name):
return bool(name) and bool(NAME_RE.match(name)) and name not in (".", "..")
def gitea_api(path):
"""访问本地 Gitea API(配置了密码则带 Basic Auth,可读私有仓库),返回 (ok, json)"""
try:
cmd = ["curl", "-sS", "-m", "10"]
if cfg.get("gitea_pass"):
cmd += ["-u", f"{cfg['gitea_user']}:{cfg['gitea_pass']}"]
cmd.append(f"{GITEA_API}{path}")
r = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
return r.returncode == 0, json.loads(r.stdout or "{}")
except Exception:
return False, {}
def gitea_repo_exists(name):
ok, d = gitea_api(f"/repos/{cfg['gitea_user']}/{name}")
return ok and isinstance(d, dict) and d.get("full_name") == f"{cfg['gitea_user']}/{name}"
def gh_api(acc, path, method="GET", body=None, timeout=15):
"""调用 GitHub API(需 Token)。返回 (ok, http_status, json)"""
if not acc or not acc.get("token"):
return False, 0, {}
cmd = ["curl", "-sS", "-m", str(timeout), "-X", method,
"-H", f"Authorization: token {acc['token']}",
"-H", "Accept: application/vnd.github+json",
"-H", "Content-Type: application/json", "-w", "\n%{http_code}"]
if body is not None:
cmd += ["-d", json.dumps(body, ensure_ascii=False)]
cmd.append(f"https://api.github.com{path}")
try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout + 5)
out, _, code = r.stdout.rpartition("\n")
status = int(code) if code.strip().isdigit() else 0
try:
d = json.loads(out or "{}")
except Exception:
d = {}
return (200 <= status < 300), status, d
except Exception:
return False, 0, {}
def gh_verify_token(token):
"""验证 Token 有效性,返回 (ok, login, msg)"""
token = (token or "").strip()
if not token:
return False, "", "Token 不能为空"
try:
r = subprocess.run(
["curl", "-sS", "-m", "15", "-w", "\n%{http_code}",
"-H", f"Authorization: token {token}",
"-H", "Accept: application/vnd.github+json",
"https://api.github.com/user"],
capture_output=True, text=True, timeout=20)
out, _, code = r.stdout.rpartition("\n")
status = int(code) if code.strip().isdigit() else 0
if 200 <= status < 300:
d = json.loads(out or "{}")
return True, (d.get("login") or ""), ""
if status == 401:
return False, "", "Token 无效或已过期(请重新生成)"
return False, "", f"GitHub API 返回 HTTP {status}"
except Exception as e:
return False, "", f"网络错误: {e}"
def gh_push_url(acc, name):
"""当前账号的 GitHub 推送地址:有 Token 走 HTTPS,否则回退 SSH 私钥"""
if acc.get("token"):
return f"https://x-access-token:{acc['token']}@github.com/{acc['gh_user']}/{name}.git"
return f"git@github.com:{acc['gh_user']}/{name}.git"
def github_repo_exists(acc, name):
if acc.get("token"):
ok, status, d = gh_api(acc, f"/repos/{acc['gh_user']}/{name}")
return ok and status == 200 and isinstance(d, dict) \
and d.get("full_name") == f"{acc['gh_user']}/{name}"
try:
r = git(["ls-remote", gh_push_url(acc, name), "HEAD"], timeout=30)
return r.returncode == 0
except Exception:
return False
def github_create_repo(acc, name):
"""在 GitHub 自动创建仓库(需 Token)。返回 (ok, msg)"""
if not acc.get("token"):
return False, "当前账号未配置 Token,无法自动创建(请到设置页添加带 Token 的 GitHub 账号)"
ok, status, d = gh_api(acc, "/user/repos", "POST", {
"name": name, "private": bool(acc.get("private", True)),
"description": f"镜像自飞牛 Gitea 仓库 {name}(GiteaHubSync 自动创建)"})
if ok and status == 201:
return True, "已创建"
if status == 422 and isinstance(d, dict) and "errors" in d:
return True, "已存在" # 重名等情况,按已存在继续推送
msg = d.get("message") if isinstance(d, dict) else str(d)
return False, f"HTTP {status} {msg}"
def list_gitea_repos():
ok, d = gitea_api(f"/users/{cfg['gitea_user']}/repos?limit=100")
if ok and isinstance(d, list):
return [x.get("name") for x in d if x.get("name")]
return []
# ---------------- 同步逻辑 ----------------
def fingerprint(repo):
r = git(["-C", str(repo), "for-each-ref", "--format=%(refname) %(objectname)"])
if r.returncode != 0:
return None
import hashlib
return hashlib.sha1(r.stdout.encode()).hexdigest()
def sync_repo(name):
"""返回 'changed' / 'same' / 'error:...' """
if not valid_name(name):
return "error: 非法仓库名"
acc = active_account()
if not acc:
return "error: 没有可用的 GitHub 账号,请到设置页添加"
repo = REPO_DIR / f"{name}.git"
try:
if not repo.exists():
REPO_DIR.mkdir(parents=True, exist_ok=True)
r = git(["clone", "--quiet", "--mirror", f"{gitea_base()}/{name}.git", str(repo)])
if r.returncode != 0:
return "error: 克隆失败 " + scrub((r.stderr or "")[:120], acc)
log(f"{name}: 首次克隆完成")
r = git(["-C", str(repo), "fetch", "--quiet", "--prune", "origin"])
if r.returncode != 0:
return "error: fetch 失败 " + scrub((r.stderr or "")[:120], acc)
cur = fingerprint(repo)
if cur is None:
return "error: 读取 refs 失败"
st = state_path(acc, name)
prev = st.read_text().strip() if st.exists() else ""
if cur == prev:
return "same"
# 推送前确保 GitHub 仓库存在;不存在时按设置自动创建
if not github_repo_exists(acc, name):
if acc.get("token") and cfg.get("auto_create", True):
ok, msg = github_create_repo(acc, name)
if not ok:
return f"error: 自动创建 GitHub 仓库失败: {msg}"
log(f"{name}: GitHub 仓库不存在,已自动创建"
f"({'私有' if acc.get('private', True) else '公开'})")
else:
hint = ("当前账号无 Token 或未开启自动创建,请到设置页配置"
if not acc.get("token") else
"自动创建已关闭,请到设置页开启或在 GitHub 手动创建")
return f"error: GitHub 上不存在 {acc['gh_user']}/{name},{hint}"
p = git(["-C", str(repo), "push", "--mirror", gh_push_url(acc, name)])
if p.returncode != 0:
return "error: 推送失败 " + scrub((p.stderr or p.stdout or "")[:120], acc)
st.parent.mkdir(parents=True, exist_ok=True)
st.write_text(cur)
detail = " ".join(l.strip() for l in p.stdout.splitlines()
if any(k in l for k in ("new branch", "new tag", "deleted", "forced", "->")))
log(f"{name}: 已同步到 GitHub({acc['gh_user']})"
+ (f" | {scrub(detail, acc)}" if detail else ""))
return "changed"
except Exception as e:
return f"error: {scrub(str(e), acc)}"
def run_sync(repos=None):
with op_lock:
if state["syncing"]:
return False, "已有同步任务正在进行"
state["syncing"] = True
try:
repos = repos if repos is not None else load_repos()
errs, changed = [], 0
for r in repos:
res = sync_repo(r)
if res.startswith("error"):
errs.append(f"{r} {res}")
stats["total_failures"] += 1
elif res == "changed":
changed += 1
stats["total_pushes"] += 1
state["last_check"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
state["checks"] += 1
stats["total_checks"] += 1
save_stats()
if errs:
state["last_result"] = "失败: " + "; ".join(errs)[:200]
return False, state["last_result"]
state["last_result"] = "成功"
return True, ("成功" if changed == 0 else f"成功(同步了 {changed} 个仓库)")
except Exception as e:
state["last_result"] = f"异常: {e}"
return False, state["last_result"]
finally:
state["syncing"] = False
def loop():
while True:
state["loop_last"] = time.time()
if state["enabled"]:
run_sync()
time.sleep(INTERVAL)
def load_panel_state():
try:
state["enabled"] = bool(json.loads(PANEL_STATE.read_text(encoding="utf-8")).get("enabled", True))
except Exception:
pass
def save_panel_state():
try:
PANEL_STATE.write_text(json.dumps({"enabled": state["enabled"]}, ensure_ascii=False),
encoding="utf-8")
except Exception:
pass
# ---------------- 状态详情 ----------------
def repo_detail(name):
repo = REPO_DIR / f"{name}.git"
st = state_path(active_account(), name)
d = {"name": name, "cloned": repo.exists(), "branches": 0, "tags": 0,
"head": None, "last_commit": None, "size_mb": 0.0,
"status": "未同步", "last_push": None}
if st.exists():
d["status"] = "已同步"
d["last_push"] = datetime.fromtimestamp(st.stat().st_mtime).strftime("%m-%d %H:%M:%S")
if not repo.exists():
return d
try:
r = git(["-C", str(repo), "for-each-ref", "--format=%(refname)"], timeout=20)
if r.returncode == 0:
refs = r.stdout.split()
d["branches"] = sum(1 for x in refs if x.startswith("refs/heads/"))
d["tags"] = sum(1 for x in refs if x.startswith("refs/tags/"))
h = git(["-C", str(repo), "symbolic-ref", "--quiet", "HEAD"], timeout=10)
if h.returncode == 0 and h.stdout.strip():
d["head"] = h.stdout.strip().replace("refs/heads/", "")
if d["head"]:
c = git(["-C", str(repo), "log", "-1",
"--format=%h\t%s\t%an\t%ad", "--date=format:%m-%d %H:%M", d["head"]],
timeout=10)
if c.returncode == 0 and c.stdout.strip():
p = c.stdout.strip().split("\t", 3)
d["last_commit"] = {"hash": p[0],
"msg": p[1] if len(p) > 1 else "",
"author": p[2] if len(p) > 2 else "",
"time": p[3] if len(p) > 3 else ""}
d["size_mb"] = round(sum(f.stat().st_size for f in repo.rglob("*") if f.is_file()) / 1048576, 1)
except Exception:
pass
return d
def fmt_uptime(sec):
sec = int(sec)
d, rem = divmod(sec, 86400)
h, rem = divmod(rem, 3600)
m, s = divmod(rem, 60)
return (f"{d}天 " if d else "") + f"{h:02d}:{m:02d}:{s:02d}"
def account_public(acc):
"""账号信息(脱敏后可下发到前端)"""
if not acc:
return None
return {"id": acc["id"], "label": acc.get("label") or acc["gh_user"],
"gh_user": acc["gh_user"], "token": mask_token(acc.get("token")),
"has_token": bool(acc.get("token")),
"private": bool(acc.get("private", True)),
"method": "Token(HTTPS)" if acc.get("token") else "SSH 私钥"}
def global_status():
repos = load_repos()
details = [repo_detail(r) for r in repos]
now = time.time()
next_in = None
if state["enabled"] and state["loop_last"]:
next_in = max(0, int(state["loop_last"] + INTERVAL - now))
gitea_all = list_gitea_repos()
acc = active_account()
return {
"enabled": state["enabled"],
"syncing": state["syncing"],
"uptime": fmt_uptime(now - START_TS),
"interval": INTERVAL,
"last_check": state["last_check"],
"next_in": next_in,
"checks": state["checks"],
"stats": stats,
"last_result": state["last_result"],
"repo_count": len(repos),
"synced_count": sum(1 for x in details if x["status"] == "已同步"),
"total_size_mb": round(sum(x["size_mb"] for x in details), 1),
"repos": details,
"gitea_url": GITEA_UI,
"gitea_user": cfg["gitea_user"],
"gitea_online": bool(gitea_all),
"available": [r for r in gitea_all if r not in repos],
"account": account_public(acc),
"auto_create": bool(cfg.get("auto_create", True)),
"gh_url": f"https://github.com/{acc['gh_user']}?tab=repositories" if acc else "",
}
def tail_log(n=80):
try:
return LOG_FILE.read_text(encoding="utf-8", errors="replace").splitlines()[-n:]
except Exception:
return []
# ---------------- 页面 ----------------
CSS = """
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;background:#f0f2f5;color:#1f2329;min-height:100vh}
.wrap{width:100%;padding:20px 20px 60px}
.top{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:10px;margin-bottom:14px}
.top h1{font-size:20px;display:flex;align-items:center;gap:8px;min-width:0}
.pill{font-size:12px;padding:4px 12px;border-radius:999px;font-weight:600;white-space:nowrap;flex:none}
.pill.on{background:#e3f5e9;color:#0a7d34}.pill.off{background:#fee;color:#c0392b}
nav{display:flex;gap:6px;margin-bottom:16px;background:#fff;border-radius:12px;padding:6px;box-shadow:0 1px 4px rgba(0,0,0,.06);flex-wrap:wrap}
nav a{padding:8px 16px;border-radius:8px;text-decoration:none;color:#646a73;font-size:14px;font-weight:600;white-space:nowrap}
nav a.active{background:#e8f0fe;color:#1f6feb}
.card{background:#fff;border-radius:14px;padding:18px;margin-bottom:14px;box-shadow:0 1px 4px rgba(0,0,0,.06);min-width:0}
.row{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:14px}
.switchbox{display:flex;align-items:center;gap:14px;min-width:0}
.switch{position:relative;width:64px;height:34px;flex:none}
.switch input{opacity:0;width:0;height:0}
.slider{position:absolute;inset:0;background:#cbd2d9;border-radius:34px;cursor:pointer;transition:.25s}
.slider:before{content:"";position:absolute;width:26px;height:26px;left:4px;top:4px;background:#fff;border-radius:50%;transition:.25s;box-shadow:0 1px 3px rgba(0,0,0,.25)}
input:checked+.slider{background:#0a7d34}
input:checked+.slider:before{transform:translateX(30px)}
.swlabel{font-size:16px;font-weight:600}.swsub{font-size:12px;color:#646a73;margin-top:2px}
.btns{display:flex;gap:10px;flex-wrap:wrap}
button,a.btn{border:none;border-radius:10px;padding:11px 18px;font-size:14px;font-weight:600;cursor:pointer;text-decoration:none;display:inline-flex;align-items:center;gap:6px;transition:.15s;white-space:nowrap}
button:active,a.btn:active{transform:scale(.97)}
.b-sync{background:#0a7d34;color:#fff}.b-sync:hover{background:#086b2c}
.b-gitea{background:#1f6feb;color:#fff}.b-gitea:hover{background:#1a5fd0}
.b-gh{background:#24292f;color:#fff}.b-gh:hover{background:#000}
.b-add{background:#7c3aed;color:#fff}.b-add:hover{background:#6d28d9}
.b-del{background:#fee2e2;color:#b91c1c;padding:6px 12px;font-size:12px}
.b-del:hover{background:#fecaca}
.b-mini{background:#eef2ff;color:#4338ca;padding:6px 12px;font-size:12px}
button:disabled{background:#cbd2d9;color:#fff;cursor:not-allowed}
.stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:12px}
.stat{background:#f6f8fa;border-radius:10px;padding:12px 14px;min-width:0}
.stat .k{font-size:12px;color:#646a73}
.stat .v{font-size:15px;font-weight:700;margin-top:4px;word-break:break-word}
.tablewrap{overflow-x:auto;-webkit-overflow-scrolling:touch}
table{width:100%;border-collapse:collapse;font-size:13px}
th,td{padding:9px 8px;text-align:left;border-bottom:1px solid #eef0f3;vertical-align:top}
th{color:#646a73;font-weight:600;font-size:12px;white-space:nowrap}
td{word-break:break-word}
td.ok{color:#0a7d34;font-weight:600;white-space:nowrap}
td.warn{color:#b45309;font-weight:600;white-space:nowrap}
.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px}
.commit{color:#646a73;font-size:12px;margin-top:2px;line-height:1.5}
h2{font-size:15px;margin-bottom:12px;color:#1f2329}
pre{background:#0d1117;color:#c9d1d9;border-radius:10px;padding:14px;font-size:12px;line-height:1.6;max-height:300px;overflow:auto;white-space:pre-wrap;word-break:break-all}
.tip{font-size:12px;color:#646a73;margin-top:10px;line-height:1.6}
.addrow{display:flex;gap:10px;flex-wrap:wrap}
.addrow input{flex:1;min-width:180px;border:1px solid #d0d5dd;border-radius:10px;padding:11px 14px;font-size:14px;outline:none}
.addrow input:focus{border-color:#7c3aed}
.avail{display:flex;gap:8px;flex-wrap:wrap;margin-top:6px}
.chip{border:1px dashed #7c3aed;color:#7c3aed;border-radius:999px;padding:6px 14px;font-size:13px;cursor:pointer;background:#faf5ff;white-space:nowrap}
.chip:hover{background:#f3e8ff}
.msg{margin-top:10px;font-size:13px;padding:10px 14px;border-radius:8px;display:none;line-height:1.6;word-break:break-word}
.msg.err{display:block;background:#fee;color:#b91c1c}
.msg.ok{display:block;background:#e3f5e9;color:#0a7d34}
input[type=checkbox]{accent-color:#7c3aed;cursor:pointer}
.addrow input[type=password]{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.5px}
td .pill{font-size:12px}
/* ---------- 窄屏(手机 / 飞牛桌面小窗口):表格转卡片 ---------- */
@media (max-width:760px){
.wrap{padding:14px 10px 40px}
.top h1{font-size:16px}
nav{gap:4px}
nav a{padding:8px 12px;font-size:13px;flex:1;text-align:center}
.stats{grid-template-columns:repeat(2,1fr);gap:8px}
.stat{padding:10px}
.stat .v{font-size:13px}
.card{padding:14px;border-radius:12px}
button,a.btn{padding:10px 14px;font-size:13px}
.btns{width:100%}
.btns button,.btns a.btn{flex:1;justify-content:center}
.addrow{flex-direction:column}
.addrow input{min-width:0;width:100%}
.addrow button{width:100%;justify-content:center}
table.resp{min-width:0}
table.resp thead{display:none}
table.resp tbody tr{display:block;border:1px solid #e5e8ec;border-radius:12px;margin-bottom:10px;padding:10px 12px;background:#fbfcfd}
table.resp tbody td{display:flex;justify-content:space-between;align-items:baseline;gap:6px 14px;border:none;padding:5px 0;flex-wrap:wrap}
table.resp tbody td::before{content:attr(data-label);color:#646a73;font-size:12px;font-weight:600;flex:none}
table.resp tbody td .val{flex:1;text-align:right;min-width:0;word-break:break-word}
table.resp tbody td:has(button){justify-content:flex-end}
table.resp tbody td:has(button)::before{margin-right:auto}
pre{font-size:11px;padding:10px}
}
"""
NAV = """<nav>
<a href="./" __A__>🎛 控制面板</a>
<a href="status" __B__>📊 项目状态</a>
<a href="manage" __C__>🧩 项目管理</a>
<a href="settings" __D__>⚙️ 设置</a>
</nav>"""
def page(title, body, active):
nav = (NAV.replace("__A__", 'class="active"' if active == "home" else "")
.replace("__B__", 'class="active"' if active == "status" else "")
.replace("__C__", 'class="active"' if active == "manage" else "")
.replace("__D__", 'class="active"' if active == "settings" else ""))
return f"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{title} · GiteaHubSync</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🔄</text></svg>">
<style>{CSS}</style>
</head>
<body>
<div class="wrap">
<div class="top"><h1>🔄 {title}</h1><span id="pill" class="pill on">运行中</span></div>
{nav}
{body}
</div>
</body>
</html>
"""
PAGE_HOME = """
<div class="card">
<div class="row">
<div class="switchbox">
<label class="switch"><input type="checkbox" id="sw" onchange="toggle()"><span class="slider"></span></label>
<div>
<div class="swlabel" id="swlabel">自动同步:开</div>
<div class="swsub">开启后每 10 秒检查一次 Gitea,有新提交自动推送到 GitHub</div>
</div>
</div>
<div class="btns">
<button class="b-sync" id="btnSync" onclick="syncNow()">⚡ 立即同步一次</button>
<a class="btn b-gitea" href="__GITEA__" target="_blank">🗂️ 打开 Gitea(3000端口)</a>
<a class="btn b-gh" id="btnGh" href="__GH__" target="_blank">🐙 GitHub 仓库列表</a>
</div>
</div>
</div>
<div class="card">
<div class="stats">
<div class="stat"><div class="k">自动同步</div><div class="v" id="sEnabled">—</div></div>
<div class="stat"><div class="k">GitHub 目标账号</div><div class="v" id="sAccount">—</div></div>
<div class="stat"><div class="k">同步项目</div><div class="v" id="sRepos">—</div></div>
<div class="stat"><div class="k">守护运行时长</div><div class="v" id="sUptime">—</div></div>
<div class="stat"><div class="k">最近实际同步</div><div class="v" id="sLast">—</div></div>
</div>
</div>
<div class="card">
<h2>同步日志(最近 80 行)</h2>
<pre id="log">加载中…</pre>
<div class="tip">日志文件:__LOGPATH__ · 面板每 3 秒自动刷新</div>
</div>
<script>
async function jget(u){const r=await fetch(u);return r.json()}
async function refresh(){
try{
const s=await jget('api/state');
document.getElementById('sw').checked=s.enabled;
document.getElementById('swlabel').textContent='自动同步:'+(s.enabled?'开':'关');
const p=document.getElementById('pill');
p.textContent=s.enabled?'运行中':'已停止';p.className='pill '+(s.enabled?'on':'off');
document.getElementById('sEnabled').textContent=s.enabled?'开启(10秒/次)':'已停止';
document.getElementById('sAccount').textContent=s.account?(s.account.gh_user+'('+s.account.method+')'):'未配置';
document.getElementById('sRepos').textContent=s.repo_count+' 个(已同步 '+s.synced_count+')';
document.getElementById('sUptime').textContent=s.uptime;
document.getElementById('sLast').textContent=s.last_real;
const l=await jget('api/log');
document.getElementById('log').textContent=l.lines.join('\\n')||'(暂无日志)';
}catch(e){document.getElementById('log').textContent='刷新失败: '+e}
}
async function toggle(){await fetch('api/toggle',{method:'POST'});refresh();}
async function syncNow(){
const b=document.getElementById('btnSync');b.disabled=true;b.textContent='⏳ 同步中…';
try{const r=await (await fetch('api/sync',{method:'POST'})).json();
if(!r.ok)alert('同步结果: '+(r.msg||JSON.stringify(r)));
}catch(e){alert('同步请求失败: '+e)}
finally{b.disabled=false;b.textContent='⚡ 立即同步一次';refresh();}
}
refresh();setInterval(refresh,3000);
</script>
"""
PAGE_STATUS = """
<div class="card">
<div class="stats">
<div class="stat"><div class="k">同步项目</div><div class="v" id="sCount">—</div></div>
<div class="stat"><div class="k">已同步</div><div class="v" id="sSynced">—</div></div>
<div class="stat"><div class="k">守护运行时长</div><div class="v" id="sUptime">—</div></div>
<div class="stat"><div class="k">距下次检查</div><div class="v" id="sNext">—</div></div>
<div class="stat"><div class="k">累计检查轮数</div><div class="v" id="sChecks">—</div></div>
<div class="stat"><div class="k">累计实际同步</div><div class="v" id="sPushes">—</div></div>
<div class="stat"><div class="k">累计失败次数</div><div class="v" id="sFails">—</div></div>
<div class="stat"><div class="k">镜像总占用</div><div class="v" id="sSize">—</div></div>
</div>
</div>
<div class="card">
<div class="row">
<h2 style="margin:0">项目明细</h2>
<span class="tip" id="sTarget"></span>
</div>
<div style="overflow-x:auto">
<table class="resp">
<thead><tr><th>项目</th><th>状态</th><th>默认分支</th><th>分支/标签</th><th>最近推送同步</th><th>最新提交</th><th>大小</th><th></th></tr></thead>
<tbody id="tb"></tbody>
</table>
</div>
</div>
<script>
async function jget(u){const r=await fetch(u);return r.json()}
function esc(s){const d=document.createElement('div');d.textContent=s;return d.innerHTML}
async function refresh(){
try{
const s=await jget('api/status');
const p=document.getElementById('pill');
p.textContent=s.enabled?'运行中':'已停止';p.className='pill '+(s.enabled?'on':'off');
document.getElementById('sCount').textContent=s.repo_count+' 个';
document.getElementById('sSynced').textContent=s.synced_count+' / '+s.repo_count;
document.getElementById('sUptime').textContent=s.uptime;
document.getElementById('sNext').textContent=s.enabled?(s.next_in==null?'—':s.next_in+' 秒'):'已暂停';
document.getElementById('sChecks').textContent=s.stats.total_checks;
document.getElementById('sPushes').textContent=s.stats.total_pushes;
document.getElementById('sFails').textContent=s.stats.total_failures;
document.getElementById('sSize').textContent=s.total_size_mb+' MB';
document.getElementById('sTarget').textContent='同步目标: '+(s.account?s.account.gh_user+'('+s.account.method+')':'未配置')+' · Gitea '+(s.gitea_online?'在线 ✓':'离线 ✗');
document.getElementById('tb').innerHTML=s.repos.map(r=>{
const st=r.status==='已同步'?'<td class="ok">✓ 已同步</td>':'<td class="warn">⏳ 未同步</td>';
const c=r.last_commit?`<div class="commit">${esc(r.last_commit.hash)} ${esc(r.last_commit.msg)} · ${esc(r.last_commit.author)} · ${esc(r.last_commit.time)}</div>`:'<div class="commit">—</div>';
return `<tr><td data-label="项目"><b>${esc(r.name)}</b></td><td data-label="状态" class="${r.status==='已同步'?'ok':'warn'}">${r.status==='已同步'?'✓ 已同步':'⏳ 未同步'}</td><td data-label="默认分支">${esc(r.head||'—')}</td><td data-label="分支/标签">${r.branches} / ${r.tags}</td><td data-label="最近推送同步">${r.last_push||'—'}</td><td data-label="最新提交">${c}</td><td data-label="大小">${r.size_mb} MB</td><td data-label="操作"><button class="b-mini" onclick="syncOne('${esc(r.name)}')">同步</button></td></tr>`;
}).join('')||'<tr><td colspan="8" style="color:#646a73">还没有同步项目,去<a href="manage">项目管理</a>添加</td></tr>';
}catch(e){document.getElementById('tb').innerHTML='<tr><td colspan="8">刷新失败: '+e+'</td></tr>'}
}
async function syncOne(n){
const r=await (await fetch('api/repos/sync',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:n})})).json();
if(!r.ok)alert(n+': '+(r.msg||JSON.stringify(r)));
refresh();
}
refresh();setInterval(refresh,5000);
</script>
"""
PAGE_MANAGE = """
<div class="card">
<h2>快捷添加项目</h2>
<div class="addrow">
<input id="repoName" placeholder="输入 Gitea 上的仓库名,如 ESP32_COMM" onkeydown="if(event.key==='Enter')addRepo()">
<button class="b-add" id="btnAdd" onclick="addRepo()">➕ 添加并同步</button>
</div>
<div class="tip">下面可直接点击你 Gitea 上还未同步的仓库(GitHub 上不存在同名仓库时会按设置自动创建):</div>
<div class="avail" id="avail"></div>
<div class="msg" id="msg"></div>
</div>
<div class="card">
<h2>当前同步项目(<span id="cnt">0</span>)</h2>
<table class="resp">
<thead><tr><th>项目</th><th>状态</th><th>最近推送同步</th><th>操作</th></tr></thead>
<tbody id="tb"></tbody>
</table>
<div class="tip">移除只是停止同步,GitHub 上的仓库和本地镜像数据都会保留,以后可以随时加回来。</div>
</div>
<script>
async function jget(u){const r=await fetch(u);return r.json()}
function esc(s){const d=document.createElement('div');d.textContent=s;return d.innerHTML}
function showMsg(t,ok){const m=document.getElementById('msg');m.textContent=t;m.className='msg '+(ok?'ok':'err');}
async function refresh(){
try{
const s=await jget('api/status');
document.getElementById('cnt').textContent=s.repo_count;
document.getElementById('avail').innerHTML=(s.available||[]).map(n=>
`<span class="chip" onclick="addRepo('${esc(n)}')">+ ${esc(n)}</span>`).join('')||'<span class="tip">Gitea 上的仓库都已加入同步</span>';
document.getElementById('tb').innerHTML=s.repos.map(r=>
`<tr><td data-label="项目"><b>${esc(r.name)}</b></td><td data-label="状态" class="${r.status==='已同步'?'ok':'warn'}">${r.status}</td><td data-label="最近推送同步">${r.last_push||'—'}</td><td data-label="操作"><button class="b-del" onclick="delRepo('${esc(r.name)}')">移除</button></td></tr>`).join('')
||'<tr><td colspan="4" style="color:#646a73">暂无项目</td></tr>';
}catch(e){showMsg('刷新失败: '+e,false)}
}
async function addRepo(n){
const name=(n||document.getElementById('repoName').value||'').trim();
if(!name){showMsg('请输入仓库名',false);return;}
const b=document.getElementById('btnAdd');b.disabled=true;b.textContent='⏳ 处理中…';
try{
const r=await (await fetch('api/repos/add',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name})})).json();
showMsg(r.msg||JSON.stringify(r),!!r.ok);
if(r.ok)document.getElementById('repoName').value='';
}catch(e){showMsg('请求失败: '+e,false)}
finally{b.disabled=false;b.textContent='➕ 添加并同步';refresh();}
}
async function delRepo(n){
if(!confirm('确定把 '+n+' 从同步列表移除?\\n(GitHub 仓库和本地数据保留)'))return;
const r=await (await fetch('api/repos/remove',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:n})})).json();
showMsg(r.msg||JSON.stringify(r),!!r.ok);
refresh();
}
refresh();setInterval(refresh,5000);
</script>
"""
PAGE_SETTINGS = """
<div class="card">
<h2>GitHub 账号(推送目标)</h2>
<div class="tablewrap">
<table class="resp">
<thead><tr><th>备注</th><th>GitHub 用户名</th><th>Token</th><th>推送方式</th><th>新建仓库</th><th>操作</th></tr></thead>
<tbody id="tbAcc"></tbody>
</table>
</div>
<div class="tip">切换账号后,所有项目改推到该账号名下;每个账号独立记录同步进度,切回原账号不会重复推送。无 Token 的旧账号通过 SSH 私钥推送,无法自动创建仓库。</div>
</div>
<div class="card">
<h2>添加 / 更新 GitHub 账号</h2>
<div class="addrow">
<input id="accLabel" placeholder="备注名(如:我的主账号)" style="max-width:220px">
<input id="accUser" placeholder="GitHub 用户名(留空则按 Token 自动识别)" style="max-width:300px">
</div>
<div class="addrow" style="margin-top:10px">
<input id="accToken" type="password" placeholder="Personal Access Token(ghp_ 开头)">
</div>
<div class="addrow" style="margin-top:10px;align-items:center">
<label style="display:flex;align-items:center;gap:8px;font-size:14px;cursor:pointer">
<input type="checkbox" id="accPrivate" checked style="width:18px;height:18px"> 自动创建的仓库设为私有
</label>
</div>
<div class="btns" style="margin-top:12px">
<button class="b-add" id="btnAccAdd" onclick="addAccount()">✓ 验证并添加</button>
</div>
<div class="msg" id="msgAcc"></div>
<div class="tip">
Token 获取:GitHub → Settings → Developer settings → Personal access tokens →
<b>Tokens (classic)</b> → Generate new token,勾选 <b>repo</b> 权限(即可自动创建仓库并推送)。<br>
链接:<a href="https://github.com/settings/tokens" target="_blank">github.com/settings/tokens</a> ·
Token 只保存在 NAS 本地(accounts.json,权限 600),不会上传到任何地方。
</div>
</div>
<div class="card">
<h2>Gitea 与同步设置</h2>
<div class="addrow">
<input id="giteaUser" placeholder="Gitea 用户名" style="max-width:300px">
<input id="giteaPass" type="password" placeholder="Gitea 密码(私有仓库必填)" style="max-width:300px">
<button class="b-mini" id="btnGitea" onclick="saveGitea()">保存</button>
</div>
<div class="tip">面板按此用户名列出你可添加同步的 Gitea 仓库(本地 Gitea 地址 __GITEAURL__)。
密码只保存在 NAS 本地,仅用于读取私有仓库;仓库全部公开可留空,留空保存可清除已存密码。</div>
<div style="margin-top:16px;display:flex;align-items:center;gap:10px;flex-wrap:wrap">
<label class="switch"><input type="checkbox" id="swAuto" onchange="saveAuto()"><span class="slider"></span></label>
<div>
<div class="swlabel" style="font-size:14px">GitHub 上不存在同名仓库时自动创建</div>
<div class="swsub">需要当前账号已配置 Token;关闭后添加不存在的仓库会提示手动创建</div>
</div>
</div>
<div class="msg" id="msgCfg"></div>
</div>
<script>
async function jget(u){const r=await fetch(u);return r.json()}
function esc(s){const d=document.createElement('div');d.textContent=s==null?'':String(s);return d.innerHTML}
function showMsg(id,t,ok){const m=document.getElementById(id);m.textContent=t;m.className='msg '+(ok?'ok':'err');}
async function refresh(){
try{
const s=await jget('api/settings');
document.getElementById('giteaUser').value=s.gitea_user||'';
document.getElementById('giteaPass').placeholder=s.gitea_pass_set
?'Gitea 密码(已设置;留空保存可清除)':'Gitea 密码(私有仓库必填)';
document.getElementById('swAuto').checked=!!s.auto_create;
document.getElementById('tbAcc').innerHTML=(s.accounts||[]).map(a=>{
const cur=a.id===s.active;
const ops=cur
? '<span class="pill on" style="font-size:12px">当前使用</span>'
: `<button class="b-mini" onclick="switchAcc('${esc(a.id)}')">切换到此账号</button>`;
const verify=a.has_token?`<button class="b-mini" onclick="verifyAcc('${esc(a.id)}')">验证</button>`:'';
const del=a.has_token||(s.accounts||[]).length>1
? `<button class="b-del" onclick="delAcc('${esc(a.id)}')">删除</button>`:'';
return `<tr><td data-label="备注"><b>${esc(a.label)}</b></td><td data-label="用户名">${esc(a.gh_user)}</td>`
+`<td data-label="Token" class="mono">${esc(a.token||'(未配置)')}</td>`
+`<td data-label="推送方式">${a.has_token?'Token(HTTPS)':'SSH 私钥'}</td>`
+`<td data-label="新建仓库">${a.has_token?(a.private?'私有':'公开'):'不支持'}</td>`
+`<td data-label="操作" style="white-space:nowrap">${ops} ${verify} ${del}</td></tr>`;
}).join('')||'<tr><td colspan="6" style="color:#646a73">暂无账号</td></tr>';
}catch(e){showMsg('msgAcc','刷新失败: '+e,false)}
}
async function addAccount(){
const label=document.getElementById('accLabel').value.trim();
const gh_user=document.getElementById('accUser').value.trim();
const token=document.getElementById('accToken').value.trim();
const priv=document.getElementById('accPrivate').checked;
if(!token){showMsg('msgAcc','请填写 Personal Access Token',false);return;}
const b=document.getElementById('btnAccAdd');b.disabled=true;b.textContent='⏳ 验证中…';
try{
const r=await (await fetch('api/accounts/add',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({label,gh_user,token,private:priv})})).json();
showMsg('msgAcc',r.msg||JSON.stringify(r),!!r.ok);
if(r.ok){document.getElementById('accLabel').value='';document.getElementById('accUser').value='';document.getElementById('accToken').value='';}
}catch(e){showMsg('msgAcc','请求失败: '+e,false)}
finally{b.disabled=false;b.textContent='✓ 验证并添加';refresh();}
}
async function switchAcc(id){
const r=await (await fetch('api/accounts/switch',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id})})).json();
showMsg('msgAcc',r.msg||JSON.stringify(r),!!r.ok);refresh();
}
async function verifyAcc(id){
const r=await (await fetch('api/accounts/verify',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id})})).json();
showMsg('msgAcc',r.msg||JSON.stringify(r),!!r.ok);refresh();
}
async function delAcc(id){
if(!confirm('确定删除该 GitHub 账号?\\n(只从本应用移除,GitHub 上的仓库不受影响)'))return;
const r=await (await fetch('api/accounts/remove',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id})})).json();
showMsg('msgAcc',r.msg||JSON.stringify(r),!!r.ok);refresh();
}
async function saveGitea(){
const gitea_user=document.getElementById('giteaUser').value.trim();
const gitea_pass=document.getElementById('giteaPass').value;
const r=await (await fetch('api/settings/save',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({gitea_user,gitea_pass})})).json();
showMsg('msgCfg',r.msg||JSON.stringify(r),!!r.ok);
document.getElementById('giteaPass').value='';
refresh();
}
async function saveAuto(){
const auto_create=document.getElementById('swAuto').checked;
const r=await (await fetch('api/settings/save',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({auto_create})})).json();
showMsg('msgCfg',r.msg||JSON.stringify(r),!!r.ok);refresh();
}
refresh();
</script>
"""
# ---------------- HTTP 服务 ----------------
class Handler(BaseHTTPRequestHandler):
def log_message(self, *a):
pass
def _json(self, obj, code=200):
body = json.dumps(obj, ensure_ascii=False).encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _html(self, html):
body = html.encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _body(self):
try:
n = int(self.headers.get("Content-Length", 0) or 0)
return json.loads(self.rfile.read(n) or b"{}")
except Exception:
return {}
def do_GET(self):
path = self.path.split("?")[0].rstrip("/") or "/"
if path == "/":
acc = active_account()
body = (PAGE_HOME.replace("__GITEA__", GITEA_UI)
.replace("__GH__", f"https://github.com/{acc['gh_user']}?tab=repositories" if acc else "")
.replace("__LOGPATH__", str(LOG_FILE)))
self._html(page("控制面板", body, "home"))
elif path == "/status":
self._html(page("项目状态", PAGE_STATUS, "status"))
elif path == "/manage":
self._html(page("项目管理", PAGE_MANAGE, "manage"))
elif path == "/settings":
body = PAGE_SETTINGS.replace("__GITEAURL__", GITEA_UI)
self._html(page("设置", body, "settings"))
elif path == "/api/state":
repos = load_repos()
acc = active_account()
self._json({"enabled": state["enabled"], "syncing": state["syncing"],
"last_check": state["last_check"], "checks": state["checks"],
"last_result": state["last_result"],
"repo_count": len(repos),
"synced_count": sum(1 for r in repos if state_path(acc, r).exists()),
"uptime": fmt_uptime(time.time() - START_TS),
"account": account_public(acc),
"last_real": last_real_sync()})
elif path == "/api/status":
self._json(global_status())
elif path == "/api/settings":
self._json({"gitea_user": cfg["gitea_user"],
"gitea_pass": mask_token(cfg.get("gitea_pass")),
"gitea_pass_set": bool(cfg.get("gitea_pass")),
"auto_create": bool(cfg.get("auto_create", True)),
"gitea_url": GITEA_UI,
"active": active_id,
"accounts": [account_public(a) for a in accounts]})
elif path == "/api/log":
self._json({"lines": tail_log(80)})
else:
self._json({"error": "not found"}, 404)
def do_POST(self):
global active_id
path = self.path.split("?")[0].rstrip("/") or "/"
data = self._body()
if path == "/api/toggle":
state["enabled"] = not state["enabled"]
save_panel_state()
self._json({"ok": True, "enabled": state["enabled"]})
elif path == "/api/sync":
ok, msg = run_sync()
self._json({"ok": ok, "msg": msg})
elif path == "/api/settings/save":
new_user = new_pass = None
if "gitea_user" in data:
u = (data.get("gitea_user") or "").strip()
if not valid_name(u):
self._json({"ok": False, "msg": "Gitea 用户名不合法(仅限字母/数字/._-)"})
return
new_user = u
if "gitea_pass" in data:
new_pass = str(data.get("gitea_pass") or "") # 空串 = 清除密码
# 预校验:涉及 Gitea 账号的改动,先用新凭据调一次 /user 验证
trial = dict(cfg)
if new_user is not None:
trial["gitea_user"] = new_user
if new_pass is not None:
trial["gitea_pass"] = new_pass
if (new_user is not None or new_pass is not None) and trial["gitea_pass"]:
try:
r = subprocess.run(
["curl", "-sS", "-m", "10", "-w", "\n%{http_code}",
"-u", f"{trial['gitea_user']}:{trial['gitea_pass']}",
f"{GITEA_API}/user"], capture_output=True, text=True, timeout=15)
out, _, code = r.stdout.rpartition("\n")
st = int(code) if code.strip().isdigit() else 0
if not (200 <= st < 300):
self._json({"ok": False, "msg": f"Gitea 账号验证失败(HTTP {st}):请检查用户名/密码"})
return
login = (json.loads(out or "{}") or {}).get("login")
if login and login != trial["gitea_user"]:
self._json({"ok": False, "msg": f"该密码对应的 Gitea 用户是 {login},与填写的 {trial['gitea_user']} 不一致"})
return
except Exception as e:
self._json({"ok": False, "msg": f"Gitea 账号验证出错: {e}"})
return
if new_user is not None:
cfg["gitea_user"] = new_user
if new_pass is not None:
cfg["gitea_pass"] = new_pass
log("Gitea 密码已" + ("更新" if new_pass else "清除"))
if "auto_create" in data:
cfg["auto_create"] = bool(data.get("auto_create"))
save_settings()
if cfg.get("gitea_pass"):
msg = "已保存(Gitea 密码验证通过 ✓,可同步私有仓库)"
else:
ok, d = gitea_api(f"/users/{cfg['gitea_user']}")
if ok and isinstance(d, dict) and d.get("login"):
msg = "已保存(未配置 Gitea 密码,只能同步公开仓库;仓库为私有时请填写密码)"
else:
msg = f"已保存(Gitea 上找不到用户 {cfg['gitea_user']},请检查)"
self._json({"ok": True, "msg": msg})
elif path == "/api/accounts/add":
token = (data.get("token") or "").strip()
label = (data.get("label") or "").strip()
gh_user = (data.get("gh_user") or "").strip()
private = bool(data.get("private", True))
if not token:
self._json({"ok": False, "msg": "Token 不能为空"})
return
ok, login, err = gh_verify_token(token)
if not ok:
self._json({"ok": False, "msg": f"Token 验证失败: {err}"})
return
if gh_user and gh_user != login:
self._json({"ok": False, "msg": f"Token 对应的账号是 {login},与填写的 {gh_user} 不一致"})
return
existing = next((a for a in accounts if a["gh_user"] == login), None)
if existing:
existing["token"] = token
existing["private"] = private
if label:
existing["label"] = label
active_id = existing["id"]
save_accounts()
log(f"GitHub 账号 {login} 的 Token 已更新")
self._json({"ok": True, "msg": f"账号 {login} 已存在,Token 已更新并切换为当前账号"})
return
acc = {"id": uuid.uuid4().hex[:8], "label": label or login,
"gh_user": login, "token": token, "private": private,
"created": datetime.now().strftime("%Y-%m-%d %H:%M:%S")}
accounts.append(acc)
# 旧版默认账号(无 Token)存在时,添加第一个 Token 账号后自动切过去
cur = active_account()
if (not cur) or (not cur.get("token")):
active_id = acc["id"]
save_accounts()
log(f"新增 GitHub 账号 {login}({'私有' if private else '公开'}仓库)")
self._json({"ok": True, "msg": f"账号 {login} 验证通过,已添加"
+ ("并切换为当前账号" if active_id == acc["id"] else "")})
elif path == "/api/accounts/switch":
aid = (data.get("id") or "").strip()
if not find_account(aid):
self._json({"ok": False, "msg": "账号不存在"})
return
active_id = aid
save_accounts()
acc = find_account(aid)
log(f"已切换 GitHub 账号: {acc['label']}({acc['gh_user']})")
self._json({"ok": True, "msg": f"已切换到 {acc['label']}({acc['gh_user']})"})
elif path == "/api/accounts/remove":
aid = (data.get("id") or "").strip()
acc = find_account(aid)
if not acc:
self._json({"ok": False, "msg": "账号不存在"})
return
if len(accounts) <= 1:
self._json({"ok": False, "msg": "至少保留一个账号"})
return
accounts.remove(acc)
if active_id == aid:
active_id = accounts[0]["id"]
save_accounts()
log(f"已移除 GitHub 账号 {acc['gh_user']}")
self._json({"ok": True, "msg": f"账号 {acc['gh_user']} 已移除(GitHub 仓库不受影响)"})
elif path == "/api/accounts/verify":
aid = (data.get("id") or "").strip()
acc = find_account(aid)
if not acc:
self._json({"ok": False, "msg": "账号不存在"})
return
if not acc.get("token"):
self._json({"ok": True, "msg": f"{acc['gh_user']}:SSH 私钥推送(无需 Token)"})
return
ok, login, err = gh_verify_token(acc["token"])
if ok:
self._json({"ok": True, "msg": f"Token 有效 ✓({login})"})
else:
self._json({"ok": False, "msg": f"Token 校验失败: {err}"})
elif path == "/api/repos/sync":
name = data.get("name", "")
if not valid_name(name):
self._json({"ok": False, "msg": "非法仓库名"})
return
with op_lock:
if state["syncing"]:
self._json({"ok": False, "msg": "已有同步任务正在进行"})
return
state["syncing"] = True
try:
res = sync_repo(name)
state["last_check"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
if res.startswith("error"):
stats["total_failures"] += 1
save_stats()
self._json({"ok": False, "msg": f"{name} {res}"})
elif res == "changed":
stats["total_pushes"] += 1
save_stats()
self._json({"ok": True, "msg": f"{name} 已同步到 GitHub"})
else:
self._json({"ok": True, "msg": f"{name} 已是最新,无需同步"})
finally:
state["syncing"] = False
elif path == "/api/repos/add":
name = (data.get("name") or "").strip()
if not valid_name(name):
self._json({"ok": False, "msg": "仓库名不合法(仅限字母/数字/._-)"})
return
repos = load_repos()
if name in repos:
self._json({"ok": False, "msg": f"{name} 已在同步列表中"})
return
if not gitea_repo_exists(name):
self._json({"ok": False, "msg": f"Gitea 上不存在仓库 {cfg['gitea_user']}/{name},请检查名字"})
return
acc = active_account()
# GitHub 上不存在同名仓库时,按设置自动创建(需 Token)
if not github_repo_exists(acc, name):
if not (acc.get("token") and cfg.get("auto_create", True)):
self._json({"ok": False, "msg": f"GitHub 上不存在 {acc['gh_user']}/{name},且当前账号无法自动创建(需在设置页添加带 Token 的账号并开启自动创建)"})
return
ok, msg = github_create_repo(acc, name)
if not ok:
self._json({"ok": False, "msg": f"自动创建 GitHub 仓库失败: {msg}"})
return
log(f"{name}: GitHub 仓库不存在,添加时已自动创建({'私有' if acc.get('private', True) else '公开'})")
repos.append(name)
save_repos(repos)
log(f"{name}: 已加入同步列表")
res = sync_repo(name)
if res.startswith("error"):
self._json({"ok": False, "msg": f"已加入列表,但首次同步失败:{res}"})
elif res == "changed":
stats["total_pushes"] += 1
save_stats()
self._json({"ok": True, "msg": f"{name} 添加成功,已完成首次同步"})
else:
self._json({"ok": True, "msg": f"{name} 添加成功,内容已是最新"})
elif path == "/api/repos/remove":
name = (data.get("name") or "").strip()
repos = load_repos()
if name not in repos:
self._json({"ok": False, "msg": f"{name} 不在同步列表中"})
return
repos = [r for r in repos if r != name]
save_repos(repos)
log(f"{name}: 已从同步列表移除")
self._json({"ok": True, "msg": f"{name} 已移除(GitHub 仓库与本地数据保留)"})
else:
self._json({"error": "not found"}, 404)
def last_real_sync():
try:
for line in reversed(LOG_FILE.read_text(encoding="utf-8", errors="replace").splitlines()):
if "已同步到 GitHub" in line:
return line.strip()
except Exception:
pass
return "尚无记录"
def write_pidfile():
try:
DATA_DIR.mkdir(parents=True, exist_ok=True)
PID_FILE.write_text(str(os.getpid()))
except Exception:
pass
def main():
signal.signal(signal.SIGHUP, signal.SIG_IGN)
DATA_DIR.mkdir(parents=True, exist_ok=True)
(DATA_DIR / "logs").mkdir(exist_ok=True)
# fnOS 解包可能把密钥权限放宽,ssh 会拒绝;守护进程即属主,启动时自愈
try:
if SSH_KEY.exists():
os.chmod(SSH_KEY, 0o600)
except Exception:
pass
write_pidfile()
atexit.register(lambda: PID_FILE.exists() and PID_FILE.unlink())
load_settings()
load_accounts()
load_panel_state()
load_stats()
load_repos()
acc = active_account()
log(f"GiteaHubSync 守护进程启动 (pid {os.getpid()}, 自动同步={'开' if state['enabled'] else '关'}, "
f"项目 {len(load_repos())} 个, GitHub 账号={acc['label'] if acc else '无'}"
f"({acc['gh_user'] if acc else '-'}/{'Token' if acc and acc.get('token') else 'SSH'}), "
f"Gitea 用户={cfg['gitea_user']}, 自动创建={'开' if cfg.get('auto_create') else '关'})")
threading.Thread(target=loop, daemon=True).start()
server = ThreadingHTTPServer(("0.0.0.0", PANEL_PORT), Handler)
log(f"控制面板监听 :{PANEL_PORT}")
server.serve_forever()
if __name__ == "__main__":
main()