From 6dcd24656756aad48a2b7a4231511ffc6761ee0d Mon Sep 17 00:00:00 2001 From: cbb Date: Tue, 13 Oct 2026 19:30:18 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20v1.1.0=20=E2=80=94=20GitHub=20=E5=A4=9A?= =?UTF-8?q?=E8=B4=A6=E5=8F=B7=E5=88=87=E6=8D=A2=20+=20=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E5=88=9B=E5=BB=BA=E4=BB=93=E5=BA=93=20+=20=E7=A7=81=E6=9C=89?= =?UTF-8?q?=20Gitea=20=E4=BB=93=E5=BA=93=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 面向大众使用的三项核心能力: 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 补充大众用法说明 --- GiteaHubSync/app/backend/panel.py | 618 ++++++++++++++++++++++++++++-- GiteaHubSync/manifest | 2 +- README.md | 40 +- 3 files changed, 618 insertions(+), 42 deletions(-) diff --git a/GiteaHubSync/app/backend/panel.py b/GiteaHubSync/app/backend/panel.py index 6a46ba5..b58da7f 100644 --- a/GiteaHubSync/app/backend/panel.py +++ b/GiteaHubSync/app/backend/panel.py @@ -6,6 +6,10 @@ # 页面: / 控制面板(开关/立即同步/快捷入口) # /status 项目状态(每个仓库的详细同步状态) # /manage 项目管理(从 Gitea 快捷添加/移除同步项目) +# /settings 设置(GitHub 多账号/切换用户、Gitea 用户名、自动创建仓库) +# 多账号: accounts.json 保存多个 GitHub 账号(用户名 + Personal Access Token), +# 可随时切换「当前账号」;每个账号独立记录同步进度。带 Token 的账号走 +# HTTPS 推送并支持自动创建 GitHub 仓库;无 Token 的旧账号走 SSH 私钥推送。 # ============================================================ import atexit import json @@ -15,9 +19,11 @@ 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")) @@ -28,17 +34,19 @@ 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 = "http://127.0.0.1:3000/cbb" GITEA_API = "http://127.0.0.1:3000/api/v1" -GH_USER = "cbb2625274797" GITEA_UI = os.environ.get("GITEA_URL", "http://192.168.31.10:3000") -GH_URL = f"https://github.com/{GH_USER}?tab=repositories" 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() @@ -50,6 +58,123 @@ ENV["GIT_SSH_COMMAND"] = (f"ssh -i {SSH_KEY} -o IdentitiesOnly=yes " 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/;新版按账号分目录 STATE_DIR//""" + 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} @@ -66,9 +191,31 @@ def log(msg): 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"] + args, capture_output=True, text=True, - env=ENV, timeout=timeout) + return subprocess.run(["git"] + git_creds_args() + args, capture_output=True, + text=True, env=ENV, timeout=timeout) def load_stats(): @@ -113,30 +260,107 @@ def valid_name(name): def gitea_api(path): - """访问本地 Gitea API,返回 (ok, json)""" + """访问本地 Gitea API(配置了密码则带 Basic Auth,可读私有仓库),返回 (ok, json)""" try: - r = subprocess.run(["curl", "-sS", "-m", "10", f"{GITEA_API}{path}"], - capture_output=True, text=True, timeout=15) + 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/cbb/{name}") - return ok and isinstance(d, dict) and d.get("full_name") == f"cbb/{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 github_repo_exists(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 = git(["ls-remote", f"git@github.com:{GH_USER}/{name}.git", "HEAD"], timeout=30) + 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("/users/cbb/repos?limit=100") + 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 [] @@ -155,35 +379,52 @@ 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}/{name}.git", str(repo)]) + r = git(["clone", "--quiet", "--mirror", f"{gitea_base()}/{name}.git", str(repo)]) if r.returncode != 0: - return "error: 克隆失败 " + (r.stderr or "")[:120] + 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 失败 " + (r.stderr or "")[:120] + return "error: fetch 失败 " + scrub((r.stderr or "")[:120], acc) cur = fingerprint(repo) if cur is None: return "error: 读取 refs 失败" - st = STATE_DIR / name + st = state_path(acc, name) prev = st.read_text().strip() if st.exists() else "" if cur == prev: return "same" - p = git(["-C", str(repo), "push", "--mirror", f"git@github.com:{GH_USER}/{name}.git"]) + # 推送前确保 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: 推送失败 " + (p.stderr or p.stdout or "")[:120] - STATE_DIR.mkdir(parents=True, exist_ok=True) + 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" + (f" | {detail}" if detail else "")) + log(f"{name}: 已同步到 GitHub({acc['gh_user']})" + + (f" | {scrub(detail, acc)}" if detail else "")) return "changed" except Exception as e: - return f"error: {e}" + return f"error: {scrub(str(e), acc)}" def run_sync(repos=None): @@ -244,7 +485,7 @@ def save_panel_state(): # ---------------- 状态详情 ---------------- def repo_detail(name): repo = REPO_DIR / f"{name}.git" - st = STATE_DIR / name + 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} @@ -286,6 +527,17 @@ def fmt_uptime(sec): 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] @@ -294,6 +546,7 @@ def global_status(): 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"], @@ -309,8 +562,12 @@ def global_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 "", } @@ -379,6 +636,9 @@ pre{background:#0d1117;color:#c9d1d9;border-radius:10px;padding:14px;font-size:1 .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} @@ -411,13 +671,15 @@ 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("__C__", 'class="active"' if active == "manage" else "") + .replace("__D__", 'class="active"' if active == "settings" else "")) return f""" @@ -451,13 +713,14 @@ PAGE_HOME = """
自动同步
—
+
GitHub 目标账号
—
同步项目
—
守护运行时长
—
最近实际同步
—
@@ -478,6 +741,7 @@ async function refresh(){ 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; @@ -513,7 +777,7 @@ PAGE_STATUS = """

项目明细

- +
@@ -538,7 +802,7 @@ async function refresh(){ 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('sGitea').textContent='Gitea '+(s.gitea_online?'在线 ✓':'离线 ✗'); + 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==='已同步'?'':''; const c=r.last_commit?`
${esc(r.last_commit.hash)} ${esc(r.last_commit.msg)} · ${esc(r.last_commit.author)} · ${esc(r.last_commit.time)}
`:'
—
'; @@ -562,7 +826,7 @@ PAGE_MANAGE = """ -
下面可直接点击你 Gitea 上还未同步的仓库(需 GitHub 端已存在同名仓库):
+
下面可直接点击你 Gitea 上还未同步的仓库(GitHub 上不存在同名仓库时会按设置自动创建):
@@ -611,6 +875,133 @@ refresh();setInterval(refresh,5000); """ +PAGE_SETTINGS = """ +
+

GitHub 账号(推送目标)

+
+
✓ 已同步⏳ 未同步
+ + +
备注GitHub 用户名Token推送方式新建仓库操作
+
+
切换账号后,所有项目改推到该账号名下;每个账号独立记录同步进度,切回原账号不会重复推送。无 Token 的旧账号通过 SSH 私钥推送,无法自动创建仓库。
+
+
+

添加 / 更新 GitHub 账号

+
+ + +
+
+ +
+
+ +
+
+ +
+
+
+ Token 获取:GitHub → Settings → Developer settings → Personal access tokens → + Tokens (classic) → Generate new token,勾选 repo 权限(即可自动创建仓库并推送)。
+ 链接:github.com/settings/tokens · + Token 只保存在 NAS 本地(accounts.json,权限 600),不会上传到任何地方。 +
+
+
+

Gitea 与同步设置

+
+ + + +
+
面板按此用户名列出你可添加同步的 Gitea 仓库(本地 Gitea 地址 __GITEAURL__)。 + 密码只保存在 NAS 本地,仅用于读取私有仓库;仓库全部公开可留空,留空保存可清除已存密码。
+
+ +
+
GitHub 上不存在同名仓库时自动创建
+
需要当前账号已配置 Token;关闭后添加不存在的仓库会提示手动创建
+
+
+
+
+ +""" + + # ---------------- HTTP 服务 ---------------- class Handler(BaseHTTPRequestHandler): def log_message(self, *a): @@ -642,31 +1033,46 @@ class Handler(BaseHTTPRequestHandler): 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__", GH_URL) + .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_DIR / r).exists()), + "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": @@ -676,6 +1082,134 @@ class Handler(BaseHTTPRequestHandler): 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): @@ -711,11 +1245,19 @@ class Handler(BaseHTTPRequestHandler): self._json({"ok": False, "msg": f"{name} 已在同步列表中"}) return if not gitea_repo_exists(name): - self._json({"ok": False, "msg": f"Gitea 上不存在仓库 cbb/{name},请检查名字"}) - return - if not github_repo_exists(name): - self._json({"ok": False, "msg": f"GitHub 上还没有 {GH_USER}/{name},请先在 GitHub 建好同名仓库(建议私有)再添加"}) + 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}: 已加入同步列表") @@ -772,10 +1314,16 @@ def main(): pass write_pidfile() atexit.register(lambda: PID_FILE.exists() and PID_FILE.unlink()) + load_settings() + load_accounts() load_panel_state() load_stats() load_repos() - log(f"GiteaHubSync 守护进程启动 (pid {os.getpid()}, 自动同步={'开' if state['enabled'] else '关'}, 项目 {len(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}") diff --git a/GiteaHubSync/manifest b/GiteaHubSync/manifest index 4b991ed..b5b2661 100644 --- a/GiteaHubSync/manifest +++ b/GiteaHubSync/manifest @@ -1,5 +1,5 @@ appname = GiteaHubSync -version = 1.0.0 +version = 1.1.0 display_name = Gitea→GitHub 同步助手 desc = 每次推送到飞牛 Gitea 的代码,10 秒内自动镜像到 GitHub 的 7 个私有仓库。内置控制面板:自动同步开关、立即同步、仓库状态、同步日志,一键打开 Gitea。数据全部保存在 NAS 本地。 platform = all diff --git a/README.md b/README.md index 430da40..19d8544 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,25 @@ # GiteaHubSync — Gitea → GitHub 镜像同步(飞牛原生应用) -每次推送到飞牛 Gitea(3000 端口)的代码,**10 秒内自动镜像到 GitHub 的 7 个私有仓库** -(SMB_Android / DCU_APP / GD32E50X_BOOT / DCU_DesktopController / Motor_v2 / DCU_ECAT_Master / GD32_BOOT)。 +每次推送到飞牛 Gitea(3000 端口)的代码,**10 秒内自动镜像到 GitHub**(默认 7 个仓库: +SMB_Android / DCU_APP / GD32E50X_BOOT / DCU_DesktopController / Motor_v2 / DCU_ECAT_Master / GD32_BOOT, +可任意增减)。 和「Agent 签到助手」一样,是标准的飞牛 `.fpk` 原生应用:**不进 Docker**,依赖已安装的 `python312` 运行时,安装后出现在应用中心已安装列表和桌面启动台,可 启动/停止/重启/卸载, NAS 重启自动恢复。控制面板带自动同步开关、立即同步、仓库状态、同步日志和 「打开 Gitea(3000 端口)」按钮。 +## 功能特性 + +- **多 GitHub 账号**:设置页可添加多个 GitHub 账号(用户名 + Personal Access Token), + 一键切换推送目标;每个账号独立记录同步进度,切回原账号不会重复推送 +- **自动创建仓库**:Gitea 上的新仓库加入同步时,若 GitHub 上不存在同名仓库, + 自动按账号设置为私有/公开创建(可开关) +- **两种推送方式**:配置了 Token 的账号走 HTTPS 推送(通用,任何人都能用自己的 + GitHub 账号使用);未配置 Token 的旧账号继续走 SSH 私钥推送 +- **真镜像语义**:Gitea 删分支/标签,GitHub 同步删 +- 推送失败下一轮自动重试;开关关掉后完全停止同步 + ## 安装(三选一) ### 方式一:飞牛网页手动安装(不需要给任何密码) @@ -15,7 +27,7 @@ NAS 重启自动恢复。控制面板带自动同步开关、立即同步、仓 1. 把 NAS 上的 `fnos-gitea-sync/GiteaHubSync/GiteaHubSync.fpk` 下载到电脑 (通过飞牛文件管理/SMB,目录:`/vol1/@appshare/fn-deepseek-harness/fnos-gitea-sync/GiteaHubSync/`) 2. 飞牛网页 → **应用中心** → 右上角 **手动安装** → 选择该 fpk -3. 等待安装完成(依赖 python312 已满足),应用自动启动,约 1 分钟内完成 7 个仓库的首次同步 +3. 等待安装完成(依赖 python312 已满足),应用自动启动,约 1 分钟内完成首次同步 ### 方式二:命令行自动安装(和签到系统相同的方式) @@ -37,6 +49,19 @@ FNOS_HOST=192.168.31.10 FNOS_USER=<管理员账号> FNOS_PASS=<密码> \ - **应用中心管理**:已安装 → GiteaHubSync → 启动/停止/重启/卸载 - **数据目录**:`/vol1/@appdata/GiteaHubSync`(镜像仓库、状态、日志;卸载应用不丢 GitHub 上的代码) +## 使用自己的 GitHub 账号(大众用法) + +1. 打开面板 → **⚙️ 设置** →「添加 / 更新 GitHub 账号」 +2. 到 [github.com/settings/tokens](https://github.com/settings/tokens) 生成 + **Tokens (classic)**,勾选 `repo` 权限 +3. 填回面板(用户名留空则按 Token 自动识别),点「验证并添加」——添加第一个 Token + 账号后会自动切换为当前账号 +4. 「Gitea 用户名」改成你自己的 Gitea 用户名(面板据此列出可同步的仓库) +5. 到 **🧩 项目管理** 添加要同步的仓库;GitHub 上不存在时会自动创建(默认私有) + +> Token 只保存在 NAS 本地 `accounts.json`(权限 600),不上传任何地方; +> 日志中的 Token 会自动打码。卸载应用不会动 GitHub 上的任何仓库。 + ## 重新构建 ```bash @@ -46,6 +71,9 @@ cd /vol1/@appshare/fn-deepseek-harness/fnos-gitea-sync ## 说明 -- GitHub 推送走 SSH 22 端口直连(HTTPS 443 在本地网络被断),私钥随应用打包安装在 NAS 本地 -- 真镜像语义:Gitea 删分支/标签,GitHub 同步删 -- 推送失败下一轮自动重试;开关关掉后完全停止同步 +- 带 Token 的账号走 HTTPS 推送(任何网络环境可用);无 Token 的旧账号走 SSH 22 + 端口直连,私钥随应用打包安装在 NAS 本地 +- 从 v1.0.0 升级会自动迁移:旧的同步进度归入「默认(SSH 私钥)」账号,行为不变 +- 数据文件:`accounts.json`(GitHub 账号,含 Token)、`settings.json` + (Gitea 用户名 / 自动创建开关)、`repos.json`(同步列表)、`.last-pushed/<账号>/<仓库>` + (每个账号的同步进度)