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 补充大众用法说明
This commit is contained in:
cbb
2026-10-13 19:30:18 +08:00
parent 4d6f71af5b
commit 6dcd246567
3 changed files with 618 additions and 42 deletions
+583 -35
View File
@@ -6,6 +6,10 @@
# 页面: / 控制面板(开关/立即同步/快捷入口) # 页面: / 控制面板(开关/立即同步/快捷入口)
# /status 项目状态(每个仓库的详细同步状态) # /status 项目状态(每个仓库的详细同步状态)
# /manage 项目管理(从 Gitea 快捷添加/移除同步项目) # /manage 项目管理(从 Gitea 快捷添加/移除同步项目)
# /settings 设置(GitHub 多账号/切换用户、Gitea 用户名、自动创建仓库)
# 多账号: accounts.json 保存多个 GitHub 账号(用户名 + Personal Access Token),
# 可随时切换「当前账号」;每个账号独立记录同步进度。带 Token 的账号走
# HTTPS 推送并支持自动创建 GitHub 仓库;无 Token 的旧账号走 SSH 私钥推送。
# ============================================================ # ============================================================
import atexit import atexit
import json import json
@@ -15,9 +19,11 @@ import signal
import subprocess import subprocess
import threading import threading
import time import time
import uuid
from datetime import datetime from datetime import datetime
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path from pathlib import Path
from urllib.parse import quote
APP_DIR = Path(__file__).resolve().parent.parent APP_DIR = Path(__file__).resolve().parent.parent
DATA_DIR = Path(os.environ.get("GHS_DATA_DIR", "/vol1/@appdata/GiteaHubSync")) 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" PANEL_STATE = DATA_DIR / "state.json"
REPOS_FILE = DATA_DIR / "repos.json" REPOS_FILE = DATA_DIR / "repos.json"
STATS_FILE = DATA_DIR / "stats.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" SSH_KEY = APP_DIR / "ssh" / "id_ed25519"
if (DATA_DIR / "id_ed25519").exists(): if (DATA_DIR / "id_ed25519").exists():
SSH_KEY = DATA_DIR / "id_ed25519" SSH_KEY = DATA_DIR / "id_ed25519"
KNOWN_HOSTS = DATA_DIR / "known_hosts" KNOWN_HOSTS = DATA_DIR / "known_hosts"
GITEA = "http://127.0.0.1:3000/cbb"
GITEA_API = "http://127.0.0.1:3000/api/v1" 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") 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", DEFAULT_REPOS = ["SMB_Android", "DCU_APP", "GD32E50X_BOOT", "DCU_DesktopController",
"Motor_v2", "DCU_ECAT_Master", "GD32_BOOT"] "Motor_v2", "DCU_ECAT_Master", "GD32_BOOT"]
LEGACY_GH_USER = "cbb2625274797" # 旧版硬编码账号,仅用于首次运行的迁移
LEGACY_GITEA_USER = "cbb" # 旧版硬编码的 Gitea 用户名,仅作默认值
INTERVAL = 10 INTERVAL = 10
PANEL_PORT = int(os.environ.get("PANEL_PORT", "8899")) PANEL_PORT = int(os.environ.get("PANEL_PORT", "8899"))
START_TS = time.time() 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}") f"-o UserKnownHostsFile={KNOWN_HOSTS}")
ENV["GIT_TERMINAL_PROMPT"] = "0" 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, state = {"enabled": True, "syncing": False, "last_check": None, "checks": 0,
"last_result": "尚未运行", "loop_last": None} "last_result": "尚未运行", "loop_last": None}
stats = {"total_checks": 0, "total_pushes": 0, "total_failures": 0} stats = {"total_checks": 0, "total_pushes": 0, "total_failures": 0}
@@ -66,9 +191,31 @@ def log(msg):
pass 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): def git(args, timeout=300):
return subprocess.run(["git"] + args, capture_output=True, text=True, return subprocess.run(["git"] + git_creds_args() + args, capture_output=True,
env=ENV, timeout=timeout) text=True, env=ENV, timeout=timeout)
def load_stats(): def load_stats():
@@ -113,30 +260,107 @@ def valid_name(name):
def gitea_api(path): def gitea_api(path):
"""访问本地 Gitea API,返回 (ok, json)""" """访问本地 Gitea API(配置了密码则带 Basic Auth,可读私有仓库),返回 (ok, json)"""
try: try:
r = subprocess.run(["curl", "-sS", "-m", "10", f"{GITEA_API}{path}"], cmd = ["curl", "-sS", "-m", "10"]
capture_output=True, text=True, timeout=15) 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 "{}") return r.returncode == 0, json.loads(r.stdout or "{}")
except Exception: except Exception:
return False, {} return False, {}
def gitea_repo_exists(name): def gitea_repo_exists(name):
ok, d = gitea_api(f"/repos/cbb/{name}") ok, d = gitea_api(f"/repos/{cfg['gitea_user']}/{name}")
return ok and isinstance(d, dict) and d.get("full_name") == f"cbb/{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: 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 return r.returncode == 0
except Exception: except Exception:
return False 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(): 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): if ok and isinstance(d, list):
return [x.get("name") for x in d if x.get("name")] return [x.get("name") for x in d if x.get("name")]
return [] return []
@@ -155,35 +379,52 @@ def sync_repo(name):
"""返回 'changed' / 'same' / 'error:...' """ """返回 'changed' / 'same' / 'error:...' """
if not valid_name(name): if not valid_name(name):
return "error: 非法仓库名" return "error: 非法仓库名"
acc = active_account()
if not acc:
return "error: 没有可用的 GitHub 账号,请到设置页添加"
repo = REPO_DIR / f"{name}.git" repo = REPO_DIR / f"{name}.git"
try: try:
if not repo.exists(): if not repo.exists():
REPO_DIR.mkdir(parents=True, exist_ok=True) 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: if r.returncode != 0:
return "error: 克隆失败 " + (r.stderr or "")[:120] return "error: 克隆失败 " + scrub((r.stderr or "")[:120], acc)
log(f"{name}: 首次克隆完成") log(f"{name}: 首次克隆完成")
r = git(["-C", str(repo), "fetch", "--quiet", "--prune", "origin"]) r = git(["-C", str(repo), "fetch", "--quiet", "--prune", "origin"])
if r.returncode != 0: if r.returncode != 0:
return "error: fetch 失败 " + (r.stderr or "")[:120] return "error: fetch 失败 " + scrub((r.stderr or "")[:120], acc)
cur = fingerprint(repo) cur = fingerprint(repo)
if cur is None: if cur is None:
return "error: 读取 refs 失败" return "error: 读取 refs 失败"
st = STATE_DIR / name st = state_path(acc, name)
prev = st.read_text().strip() if st.exists() else "" prev = st.read_text().strip() if st.exists() else ""
if cur == prev: if cur == prev:
return "same" 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: if p.returncode != 0:
return "error: 推送失败 " + (p.stderr or p.stdout or "")[:120] return "error: 推送失败 " + scrub((p.stderr or p.stdout or "")[:120], acc)
STATE_DIR.mkdir(parents=True, exist_ok=True) st.parent.mkdir(parents=True, exist_ok=True)
st.write_text(cur) st.write_text(cur)
detail = " ".join(l.strip() for l in p.stdout.splitlines() detail = " ".join(l.strip() for l in p.stdout.splitlines()
if any(k in l for k in ("new branch", "new tag", "deleted", "forced", "->"))) 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" return "changed"
except Exception as e: except Exception as e:
return f"error: {e}" return f"error: {scrub(str(e), acc)}"
def run_sync(repos=None): def run_sync(repos=None):
@@ -244,7 +485,7 @@ def save_panel_state():
# ---------------- 状态详情 ---------------- # ---------------- 状态详情 ----------------
def repo_detail(name): def repo_detail(name):
repo = REPO_DIR / f"{name}.git" 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, d = {"name": name, "cloned": repo.exists(), "branches": 0, "tags": 0,
"head": None, "last_commit": None, "size_mb": 0.0, "head": None, "last_commit": None, "size_mb": 0.0,
"status": "未同步", "last_push": None} "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}" 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(): def global_status():
repos = load_repos() repos = load_repos()
details = [repo_detail(r) for r in repos] details = [repo_detail(r) for r in repos]
@@ -294,6 +546,7 @@ def global_status():
if state["enabled"] and state["loop_last"]: if state["enabled"] and state["loop_last"]:
next_in = max(0, int(state["loop_last"] + INTERVAL - now)) next_in = max(0, int(state["loop_last"] + INTERVAL - now))
gitea_all = list_gitea_repos() gitea_all = list_gitea_repos()
acc = active_account()
return { return {
"enabled": state["enabled"], "enabled": state["enabled"],
"syncing": state["syncing"], "syncing": state["syncing"],
@@ -309,8 +562,12 @@ def global_status():
"total_size_mb": round(sum(x["size_mb"] for x in details), 1), "total_size_mb": round(sum(x["size_mb"] for x in details), 1),
"repos": details, "repos": details,
"gitea_url": GITEA_UI, "gitea_url": GITEA_UI,
"gitea_user": cfg["gitea_user"],
"gitea_online": bool(gitea_all), "gitea_online": bool(gitea_all),
"available": [r for r in gitea_all if r not in repos], "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{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.err{display:block;background:#fee;color:#b91c1c}
.msg.ok{display:block;background:#e3f5e9;color:#0a7d34} .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){ @media (max-width:760px){
.wrap{padding:14px 10px 40px} .wrap{padding:14px 10px 40px}
@@ -411,13 +671,15 @@ NAV = """<nav>
<a href="./" __A__>🎛 控制面板</a> <a href="./" __A__>🎛 控制面板</a>
<a href="status" __B__>📊 项目状态</a> <a href="status" __B__>📊 项目状态</a>
<a href="manage" __C__>🧩 项目管理</a> <a href="manage" __C__>🧩 项目管理</a>
<a href="settings" __D__>⚙️ 设置</a>
</nav>""" </nav>"""
def page(title, body, active): def page(title, body, active):
nav = (NAV.replace("__A__", 'class="active"' if active == "home" else "") nav = (NAV.replace("__A__", 'class="active"' if active == "home" else "")
.replace("__B__", 'class="active"' if active == "status" 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"""<!DOCTYPE html> return f"""<!DOCTYPE html>
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
@@ -451,13 +713,14 @@ PAGE_HOME = """
<div class="btns"> <div class="btns">
<button class="b-sync" id="btnSync" onclick="syncNow()">⚡ 立即同步一次</button> <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-gitea" href="__GITEA__" target="_blank">🗂️ 打开 Gitea(3000端口)</a>
<a class="btn b-gh" href="__GH__" target="_blank">🐙 GitHub 仓库列表</a> <a class="btn b-gh" id="btnGh" href="__GH__" target="_blank">🐙 GitHub 仓库列表</a>
</div> </div>
</div> </div>
</div> </div>
<div class="card"> <div class="card">
<div class="stats"> <div class="stats">
<div class="stat"><div class="k">自动同步</div><div class="v" id="sEnabled">—</div></div> <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="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="sUptime">—</div></div>
<div class="stat"><div class="k">最近实际同步</div><div class="v" id="sLast">—</div></div> <div class="stat"><div class="k">最近实际同步</div><div class="v" id="sLast">—</div></div>
@@ -478,6 +741,7 @@ async function refresh(){
const p=document.getElementById('pill'); const p=document.getElementById('pill');
p.textContent=s.enabled?'运行中':'已停止';p.className='pill '+(s.enabled?'on':'off'); p.textContent=s.enabled?'运行中':'已停止';p.className='pill '+(s.enabled?'on':'off');
document.getElementById('sEnabled').textContent=s.enabled?'开启(10秒/次)':'已停止'; 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('sRepos').textContent=s.repo_count+' 个(已同步 '+s.synced_count+')';
document.getElementById('sUptime').textContent=s.uptime; document.getElementById('sUptime').textContent=s.uptime;
document.getElementById('sLast').textContent=s.last_real; document.getElementById('sLast').textContent=s.last_real;
@@ -513,7 +777,7 @@ PAGE_STATUS = """
<div class="card"> <div class="card">
<div class="row"> <div class="row">
<h2 style="margin:0">项目明细</h2> <h2 style="margin:0">项目明细</h2>
<span class="tip" id="sGitea"></span> <span class="tip" id="sTarget"></span>
</div> </div>
<div style="overflow-x:auto"> <div style="overflow-x:auto">
<table class="resp"> <table class="resp">
@@ -538,7 +802,7 @@ async function refresh(){
document.getElementById('sPushes').textContent=s.stats.total_pushes; document.getElementById('sPushes').textContent=s.stats.total_pushes;
document.getElementById('sFails').textContent=s.stats.total_failures; document.getElementById('sFails').textContent=s.stats.total_failures;
document.getElementById('sSize').textContent=s.total_size_mb+' MB'; 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=>{ document.getElementById('tb').innerHTML=s.repos.map(r=>{
const st=r.status==='已同步'?'<td class="ok">✓ 已同步</td>':'<td class="warn">⏳ 未同步</td>'; 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>'; 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>';
@@ -562,7 +826,7 @@ PAGE_MANAGE = """
<input id="repoName" placeholder="输入 Gitea 上的仓库名,如 ESP32_COMM" onkeydown="if(event.key==='Enter')addRepo()"> <input id="repoName" placeholder="输入 Gitea 上的仓库名,如 ESP32_COMM" onkeydown="if(event.key==='Enter')addRepo()">
<button class="b-add" id="btnAdd" onclick="addRepo()">➕ 添加并同步</button> <button class="b-add" id="btnAdd" onclick="addRepo()">➕ 添加并同步</button>
</div> </div>
<div class="tip">下面可直接点击你 Gitea 上还未同步的仓库(需 GitHub 端已存在同名仓库):</div> <div class="tip">下面可直接点击你 Gitea 上还未同步的仓库(GitHub 上不存在同名仓库时会按设置自动创建):</div>
<div class="avail" id="avail"></div> <div class="avail" id="avail"></div>
<div class="msg" id="msg"></div> <div class="msg" id="msg"></div>
</div> </div>
@@ -611,6 +875,133 @@ refresh();setInterval(refresh,5000);
""" """
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 服务 ---------------- # ---------------- HTTP 服务 ----------------
class Handler(BaseHTTPRequestHandler): class Handler(BaseHTTPRequestHandler):
def log_message(self, *a): def log_message(self, *a):
@@ -642,31 +1033,46 @@ class Handler(BaseHTTPRequestHandler):
def do_GET(self): def do_GET(self):
path = self.path.split("?")[0].rstrip("/") or "/" path = self.path.split("?")[0].rstrip("/") or "/"
if path == "/": if path == "/":
acc = active_account()
body = (PAGE_HOME.replace("__GITEA__", GITEA_UI) 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))) .replace("__LOGPATH__", str(LOG_FILE)))
self._html(page("控制面板", body, "home")) self._html(page("控制面板", body, "home"))
elif path == "/status": elif path == "/status":
self._html(page("项目状态", PAGE_STATUS, "status")) self._html(page("项目状态", PAGE_STATUS, "status"))
elif path == "/manage": elif path == "/manage":
self._html(page("项目管理", PAGE_MANAGE, "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": elif path == "/api/state":
repos = load_repos() repos = load_repos()
acc = active_account()
self._json({"enabled": state["enabled"], "syncing": state["syncing"], self._json({"enabled": state["enabled"], "syncing": state["syncing"],
"last_check": state["last_check"], "checks": state["checks"], "last_check": state["last_check"], "checks": state["checks"],
"last_result": state["last_result"], "last_result": state["last_result"],
"repo_count": len(repos), "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), "uptime": fmt_uptime(time.time() - START_TS),
"account": account_public(acc),
"last_real": last_real_sync()}) "last_real": last_real_sync()})
elif path == "/api/status": elif path == "/api/status":
self._json(global_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": elif path == "/api/log":
self._json({"lines": tail_log(80)}) self._json({"lines": tail_log(80)})
else: else:
self._json({"error": "not found"}, 404) self._json({"error": "not found"}, 404)
def do_POST(self): def do_POST(self):
global active_id
path = self.path.split("?")[0].rstrip("/") or "/" path = self.path.split("?")[0].rstrip("/") or "/"
data = self._body() data = self._body()
if path == "/api/toggle": if path == "/api/toggle":
@@ -676,6 +1082,134 @@ class Handler(BaseHTTPRequestHandler):
elif path == "/api/sync": elif path == "/api/sync":
ok, msg = run_sync() ok, msg = run_sync()
self._json({"ok": ok, "msg": msg}) 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": elif path == "/api/repos/sync":
name = data.get("name", "") name = data.get("name", "")
if not valid_name(name): if not valid_name(name):
@@ -711,11 +1245,19 @@ class Handler(BaseHTTPRequestHandler):
self._json({"ok": False, "msg": f"{name} 已在同步列表中"}) self._json({"ok": False, "msg": f"{name} 已在同步列表中"})
return return
if not gitea_repo_exists(name): if not gitea_repo_exists(name):
self._json({"ok": False, "msg": f"Gitea 上不存在仓库 cbb/{name},请检查名字"}) self._json({"ok": False, "msg": f"Gitea 上不存在仓库 {cfg['gitea_user']}/{name},请检查名字"})
return
if not github_repo_exists(name):
self._json({"ok": False, "msg": f"GitHub 上还没有 {GH_USER}/{name},请先在 GitHub 建好同名仓库(建议私有)再添加"})
return 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) repos.append(name)
save_repos(repos) save_repos(repos)
log(f"{name}: 已加入同步列表") log(f"{name}: 已加入同步列表")
@@ -772,10 +1314,16 @@ def main():
pass pass
write_pidfile() write_pidfile()
atexit.register(lambda: PID_FILE.exists() and PID_FILE.unlink()) atexit.register(lambda: PID_FILE.exists() and PID_FILE.unlink())
load_settings()
load_accounts()
load_panel_state() load_panel_state()
load_stats() load_stats()
load_repos() 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() threading.Thread(target=loop, daemon=True).start()
server = ThreadingHTTPServer(("0.0.0.0", PANEL_PORT), Handler) server = ThreadingHTTPServer(("0.0.0.0", PANEL_PORT), Handler)
log(f"控制面板监听 :{PANEL_PORT}") log(f"控制面板监听 :{PANEL_PORT}")
+1 -1
View File
@@ -1,5 +1,5 @@
appname = GiteaHubSync appname = GiteaHubSync
version = 1.0.0 version = 1.1.0
display_name = Gitea→GitHub 同步助手 display_name = Gitea→GitHub 同步助手
desc = 每次推送到飞牛 Gitea 的代码,10 秒内自动镜像到 GitHub 的 7 个私有仓库。内置控制面板:自动同步开关、立即同步、仓库状态、同步日志,一键打开 Gitea。数据全部保存在 NAS 本地。 desc = 每次推送到飞牛 Gitea 的代码,10 秒内自动镜像到 GitHub 的 7 个私有仓库。内置控制面板:自动同步开关、立即同步、仓库状态、同步日志,一键打开 Gitea。数据全部保存在 NAS 本地。
platform = all platform = all
+34 -6
View File
@@ -1,13 +1,25 @@
# GiteaHubSync — Gitea → GitHub 镜像同步(飞牛原生应用) # GiteaHubSync — Gitea → GitHub 镜像同步(飞牛原生应用)
每次推送到飞牛 Gitea(3000 端口)的代码,**10 秒内自动镜像到 GitHub 的 7 个私有仓库** 每次推送到飞牛 Gitea(3000 端口)的代码,**10 秒内自动镜像到 GitHub**(默认 7 个仓库:
(SMB_Android / DCU_APP / GD32E50X_BOOT / DCU_DesktopController / Motor_v2 / DCU_ECAT_Master / GD32_BOOT)。 SMB_Android / DCU_APP / GD32E50X_BOOT / DCU_DesktopController / Motor_v2 / DCU_ECAT_Master / GD32_BOOT,
可任意增减)。
和「Agent 签到助手」一样,是标准的飞牛 `.fpk` 原生应用:**不进 Docker**,依赖已安装的 和「Agent 签到助手」一样,是标准的飞牛 `.fpk` 原生应用:**不进 Docker**,依赖已安装的
`python312` 运行时,安装后出现在应用中心已安装列表和桌面启动台,可 启动/停止/重启/卸载, `python312` 运行时,安装后出现在应用中心已安装列表和桌面启动台,可 启动/停止/重启/卸载,
NAS 重启自动恢复。控制面板带自动同步开关、立即同步、仓库状态、同步日志和 NAS 重启自动恢复。控制面板带自动同步开关、立即同步、仓库状态、同步日志和
「打开 Gitea(3000 端口)」按钮。 「打开 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` 下载到电脑 1. 把 NAS 上的 `fnos-gitea-sync/GiteaHubSync/GiteaHubSync.fpk` 下载到电脑
(通过飞牛文件管理/SMB,目录:`/vol1/@appshare/fn-deepseek-harness/fnos-gitea-sync/GiteaHubSync/`) (通过飞牛文件管理/SMB,目录:`/vol1/@appshare/fn-deepseek-harness/fnos-gitea-sync/GiteaHubSync/`)
2. 飞牛网页 → **应用中心** → 右上角 **手动安装** → 选择该 fpk 2. 飞牛网页 → **应用中心** → 右上角 **手动安装** → 选择该 fpk
3. 等待安装完成(依赖 python312 已满足),应用自动启动,约 1 分钟内完成 7 个仓库的首次同步 3. 等待安装完成(依赖 python312 已满足),应用自动启动,约 1 分钟内完成首次同步
### 方式二:命令行自动安装(和签到系统相同的方式) ### 方式二:命令行自动安装(和签到系统相同的方式)
@@ -37,6 +49,19 @@ FNOS_HOST=192.168.31.10 FNOS_USER=<管理员账号> FNOS_PASS=<密码> \
- **应用中心管理**:已安装 → GiteaHubSync → 启动/停止/重启/卸载 - **应用中心管理**:已安装 → GiteaHubSync → 启动/停止/重启/卸载
- **数据目录**:`/vol1/@appdata/GiteaHubSync`(镜像仓库、状态、日志;卸载应用不丢 GitHub 上的代码) - **数据目录**:`/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 ```bash
@@ -46,6 +71,9 @@ cd /vol1/@appshare/fn-deepseek-harness/fnos-gitea-sync
## 说明 ## 说明
- GitHub 推送走 SSH 22 端口直连(HTTPS 443 在本地网络被断),私钥随应用打包安装在 NAS 本地 - 带 Token 的账号走 HTTPS 推送(任何网络环境可用);无 Token 的旧账号走 SSH 22
- 真镜像语义:Gitea 删分支/标签,GitHub 同步删 端口直连,私钥随应用打包安装在 NAS 本地
- 推送失败下一轮自动重试;开关关掉后完全停止同步 - 从 v1.0.0 升级会自动迁移:旧的同步进度归入「默认(SSH 私钥)」账号,行为不变
- 数据文件:`accounts.json`(GitHub 账号,含 Token)、`settings.json`
(Gitea 用户名 / 自动创建开关)、`repos.json`(同步列表)、`.last-pushed/<账号>/<仓库>`
(每个账号的同步进度)