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:
@@ -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/<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}
|
||||
@@ -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 = """<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("__C__", 'class="active"' if active == "manage" else "")
|
||||
.replace("__D__", 'class="active"' if active == "settings" else ""))
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
@@ -451,13 +713,14 @@ PAGE_HOME = """
|
||||
<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" href="__GH__" target="_blank">🐙 GitHub 仓库列表</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>
|
||||
@@ -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 = """
|
||||
<div class="card">
|
||||
<div class="row">
|
||||
<h2 style="margin:0">项目明细</h2>
|
||||
<span class="tip" id="sGitea"></span>
|
||||
<span class="tip" id="sTarget"></span>
|
||||
</div>
|
||||
<div style="overflow-x:auto">
|
||||
<table class="resp">
|
||||
@@ -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==='已同步'?'<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>';
|
||||
@@ -562,7 +826,7 @@ PAGE_MANAGE = """
|
||||
<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="tip">下面可直接点击你 Gitea 上还未同步的仓库(GitHub 上不存在同名仓库时会按设置自动创建):</div>
|
||||
<div class="avail" id="avail"></div>
|
||||
<div class="msg" id="msg"></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 服务 ----------------
|
||||
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}")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/<账号>/<仓库>`
|
||||
(每个账号的同步进度)
|
||||
|
||||
Reference in New Issue
Block a user