diff --git a/GiteaHubSync/app/backend/panel.py b/GiteaHubSync/app/backend/panel.py index 5acf757..888b0d8 100644 --- a/GiteaHubSync/app/backend/panel.py +++ b/GiteaHubSync/app/backend/panel.py @@ -23,7 +23,7 @@ import uuid from datetime import datetime from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path -from urllib.parse import quote +from urllib.parse import quote, urlsplit APP_DIR = Path(__file__).resolve().parent.parent DATA_DIR = Path(os.environ.get("GHS_DATA_DIR", "/vol1/@appdata/GiteaHubSync")) @@ -41,8 +41,8 @@ SSH_KEY = APP_DIR / "ssh" / "id_ed25519" if (DATA_DIR / "id_ed25519").exists(): SSH_KEY = DATA_DIR / "id_ed25519" KNOWN_HOSTS = DATA_DIR / "known_hosts" -GITEA_API = "http://127.0.0.1:3000/api/v1" -GITEA_UI = os.environ.get("GITEA_URL", "http://192.168.31.10:3000") +GITEA_URL_ENV = os.environ.get("GITEA_URL") # “打开 Gitea”按钮地址覆盖,一般不设 +DEFAULT_GITEA_HOST = "http://127.0.0.1:3000" # 同步引擎访问的 Gitea 地址(设置页可改) DEFAULT_REPOS = ["SMB_Android", "DCU_APP", "GD32E50X_BOOT", "DCU_DesktopController", "Motor_v2", "DCU_ECAT_Master", "GD32_BOOT"] LEGACY_GH_USER = "cbb2625274797" # 旧版硬编码账号,仅用于首次运行的迁移 @@ -62,7 +62,46 @@ ENV["GIT_TERMINAL_PROMPT"] = "0" # ---------------- 应用设置(settings.json) ---------------- cfg = {"gitea_user": LEGACY_GITEA_USER, "gitea_pass": "", "auto_create": True, - "interval": DEFAULT_INTERVAL} + "interval": DEFAULT_INTERVAL, "gitea_host": DEFAULT_GITEA_HOST} + + +def norm_gitea_host(h): + """规范化 Gitea 地址:补 http://、去尾部斜杠;不合法返回 None""" + h = str(h or "").strip().rstrip("/") + if not h: + return None + if "://" not in h: + h = "http://" + h + try: + p = urlsplit(h) + host = p.hostname or "" + if p.scheme in ("http", "https") and host \ + and not p.path.strip("/") and not p.query and not p.fragment \ + and re.fullmatch(r"[A-Za-z0-9.\-:\[\]]+", host): + return h + except Exception: + pass + return None + + +def gitea_host(): + return cfg.get("gitea_host") or DEFAULT_GITEA_HOST + + +def gitea_ui(host_header=""): + """「打开 Gitea」按钮地址:优先环境变量;否则用设置里的地址, + 但把 127.0.0.1/localhost 替换成访问面板的主机名(浏览器打不开本机回环)""" + if GITEA_URL_ENV: + return GITEA_URL_ENV + base = gitea_host() + try: + p = urlsplit(base) + host = (host_header or "").split(":")[0].strip() + if host and p.hostname in ("127.0.0.1", "localhost", "0.0.0.0", "::1", ""): + return f"{p.scheme}://{host}" + (f":{p.port}" if p.port else "") + except Exception: + pass + return base def sync_interval(): @@ -90,6 +129,9 @@ def load_settings(): cfg["auto_create"] = bool(d.get("auto_create", True)) if valid_interval(d.get("interval")): cfg["interval"] = int(d["interval"]) + h = norm_gitea_host(d.get("gitea_host")) + if h: + cfg["gitea_host"] = h except Exception: pass write_gitcreds() @@ -107,8 +149,8 @@ def save_settings(): def gitea_base(): - """Gitea 上当前用户的仓库前缀""" - return f"http://127.0.0.1:3000/{cfg['gitea_user']}" + """Gitea 上当前用户的仓库前缀(地址可在设置页修改)""" + return f"{gitea_host()}/{cfg['gitea_user']}" # ---------------- GitHub 多账号(accounts.json) ---------------- @@ -217,9 +259,11 @@ def write_gitcreds(): 这样镜像仓库的 remote 地址里不保存密码。清除密码时删除该文件。""" try: if cfg.get("gitea_pass"): + p = urlsplit(gitea_host()) + netloc = p.hostname + (f":{p.port}" if p.port else "") 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", + GITCREDS_FILE.write_text(f"{p.scheme}://{user}:{pw}@{netloc}\n", encoding="utf-8") os.chmod(GITCREDS_FILE, 0o600) elif GITCREDS_FILE.exists(): @@ -228,6 +272,20 @@ def write_gitcreds(): pass +def repoint_mirror_remotes(): + """Gitea 地址变更后,把已有镜像仓库的 origin 指向新地址""" + n = 0 + try: + for repo in REPO_DIR.glob("*.git"): + r = git(["-C", str(repo), "remote", "set-url", "origin", + f"{gitea_base()}/{repo.name[:-4]}.git"], timeout=20) + if r.returncode == 0: + n += 1 + except Exception as e: + log(f"更新镜像 remote 失败: {e}") + return n + + def git_creds_args(): if cfg.get("gitea_pass") and GITCREDS_FILE.exists(): return ["-c", f"credential.helper=store --file={GITCREDS_FILE}"] @@ -286,13 +344,33 @@ def gitea_api(path): 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}") + cmd.append(f"{gitea_host()}/api/v1{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_probe(host=None): + """探测 Gitea 地址连通性,返回 (ok, msg)""" + host = host or gitea_host() + try: + r = subprocess.run(["curl", "-sS", "-m", "8", "-w", "\n%{http_code}", + f"{host}/api/v1/version"], + capture_output=True, text=True, timeout=12) + out, _, code = r.stdout.rpartition("\n") + st = int(code) if code.strip().isdigit() else 0 + if 200 <= st < 300: + try: + ver = json.loads(out or "{}").get("version", "?") + except Exception: + ver = "?" + return True, f"连接成功,Gitea 版本 {ver}" + return False, f"地址连不上(HTTP {st or '超时'}),请检查后重试" + except Exception as e: + return False, f"地址连不上:{e}" + + def gitea_repo_exists(name): ok, d = gitea_api(f"/repos/{cfg['gitea_user']}/{name}") return ok and isinstance(d, dict) and d.get("full_name") == f"{cfg['gitea_user']}/{name}" @@ -559,7 +637,7 @@ def account_public(acc): "method": "Token(HTTPS)" if acc.get("token") else "SSH 私钥"} -def global_status(): +def global_status(ui_url=None): repos = load_repos() details = [repo_detail(r) for r in repos] now = time.time() @@ -582,7 +660,7 @@ def global_status(): "synced_count": sum(1 for x in details if x["status"] == "已同步"), "total_size_mb": round(sum(x["size_mb"] for x in details), 1), "repos": details, - "gitea_url": GITEA_UI, + "gitea_url": ui_url or gitea_ui(), "gitea_user": cfg["gitea_user"], "gitea_online": bool(gitea_all), "available": [r for r in gitea_all if r not in repos], @@ -759,7 +837,7 @@ PAGE_HOME = """
- 🗂️ 打开 Gitea(3000端口) + 🗂️ 打开 Gitea 🐙 GitHub 仓库列表
@@ -961,17 +1039,22 @@ PAGE_SETTINGS = """

Gitea 与同步设置

+ +
+ + +
- +
- +
-
本地 Gitea 地址 __GITEAURL__;密码只保存在 NAS 本地,仅用于读取私有仓库。
+
@@ -998,9 +1081,11 @@ function showMsg(id,t,ok){const m=document.getElementById(id);m.textContent=t;m. async function refresh(){ try{ const s=await jget('api/settings'); + document.getElementById('giteaHost').value=s.gitea_host||'http://127.0.0.1:3000'; document.getElementById('giteaUser').value=s.gitea_user||''; document.getElementById('giteaPass').placeholder=s.gitea_pass_set ?'Gitea 密码(已设置;留空保存可清除)':'Gitea 密码(私有仓库必填)'; + document.getElementById('giteaUrlTip').textContent='「打开 Gitea」按钮地址:'+(s.gitea_url||'—')+'(随访问地址自动适配);密码只保存在 NAS 本地,仅用于读取私有仓库。'; document.getElementById('swAuto').checked=!!s.auto_create; const iv=document.getElementById('syncInterval'); iv.value=s.interval||10;iv.min=s.min_interval||5;iv.max=s.max_interval||86400; @@ -1073,12 +1158,14 @@ async function delAcc(id){ 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(); +async function saveGitea(withPass){ + // withPass=false 时不发送密码字段,避免改地址/用户名时误清密码 + const body={gitea_host:document.getElementById('giteaHost').value.trim(), + gitea_user:document.getElementById('giteaUser').value.trim()}; + if(withPass)body.gitea_pass=document.getElementById('giteaPass').value; + const r=await (await fetch('api/settings/save',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)})).json(); showMsg('msgCfg',r.msg||JSON.stringify(r),!!r.ok); - document.getElementById('giteaPass').value=''; + if(withPass)document.getElementById('giteaPass').value=''; refresh(); } async function saveAuto(){ @@ -1138,7 +1225,7 @@ class Handler(BaseHTTPRequestHandler): path = self.path.split("?")[0].rstrip("/") or "/" if path == "/": acc = active_account() - body = (PAGE_HOME.replace("__GITEA__", GITEA_UI) + body = (PAGE_HOME.replace("__GITEA__", gitea_ui(self.headers.get("Host"))) .replace("__GH__", f"https://github.com/{acc['gh_user']}?tab=repositories" if acc else "") .replace("__LOGPATH__", str(LOG_FILE))) self._html(page("控制面板", body, "home")) @@ -1147,8 +1234,7 @@ class Handler(BaseHTTPRequestHandler): elif path == "/manage": self._html(page("项目管理", PAGE_MANAGE, "manage")) elif path == "/settings": - body = PAGE_SETTINGS.replace("__GITEAURL__", GITEA_UI) - self._html(page("设置", body, "settings")) + self._html(page("设置", PAGE_SETTINGS, "settings")) elif path == "/api/state": repos = load_repos() acc = active_account() @@ -1171,7 +1257,8 @@ class Handler(BaseHTTPRequestHandler): "interval": sync_interval(), "min_interval": MIN_INTERVAL, "max_interval": MAX_INTERVAL, - "gitea_url": GITEA_UI, + "gitea_host": gitea_host(), + "gitea_url": gitea_ui(self.headers.get("Host")), "active": active_id, "accounts": [account_public(a) for a in accounts]}) elif path == "/api/log": @@ -1191,7 +1278,7 @@ class Handler(BaseHTTPRequestHandler): ok, msg = run_sync() self._json({"ok": ok, "msg": msg}) elif path == "/api/settings/save": - new_user = new_pass = None + new_user = new_pass = new_host = None if "gitea_user" in data: u = (data.get("gitea_user") or "").strip() if not valid_name(u): @@ -1200,22 +1287,35 @@ class Handler(BaseHTTPRequestHandler): new_user = u if "gitea_pass" in data: new_pass = str(data.get("gitea_pass") or "") # 空串 = 清除密码 + if "gitea_host" in data: + h = norm_gitea_host(data.get("gitea_host")) + if not h: + self._json({"ok": False, "msg": "Gitea 地址不合法,应类似 http://127.0.0.1:3000"}) + return + new_host = h if "interval" in data: if not valid_interval(data.get("interval")): self._json({"ok": False, "msg": f"同步间隔须为 {MIN_INTERVAL} ~ {MAX_INTERVAL} 之间的整数(秒)"}) return - # 预校验:涉及 Gitea 账号的改动,先用新凭据调一次 /user 验证 + # 预校验:新地址先探连通性;涉及账号的改动再用新凭据调一次 /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_host is not None: + trial["gitea_host"] = new_host + ok, msg = gitea_probe(new_host) + if not ok: + self._json({"ok": False, "msg": msg}) + return 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) + f"{trial['gitea_host']}/api/v1/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): @@ -1228,11 +1328,14 @@ class Handler(BaseHTTPRequestHandler): except Exception as e: self._json({"ok": False, "msg": f"Gitea 账号验证出错: {e}"}) return + host_changed = new_host is not None and new_host != gitea_host() 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 new_host is not None: + cfg["gitea_host"] = new_host if "auto_create" in data: cfg["auto_create"] = bool(data.get("auto_create")) if "interval" in data: @@ -1241,15 +1344,23 @@ class Handler(BaseHTTPRequestHandler): if cfg["interval"] != old_iv: log(f"同步间隔已调整: {old_iv}s -> {cfg['interval']}s") save_settings() + notes = [] + if new_host is not None: + ok, msg = gitea_probe() + notes.append(f"Gitea 地址已设为 {new_host}({msg})") + if host_changed: + n = repoint_mirror_remotes() + log(f"Gitea 地址变更: {n} 个镜像仓库的 origin 已更新") + notes.append(f"{n} 个已有镜像的拉取地址已更新") if cfg.get("gitea_pass"): - msg = "已保存(Gitea 密码验证通过 ✓,可同步私有仓库)" + notes.append("Gitea 密码验证通过 ✓,可同步私有仓库") else: ok, d = gitea_api(f"/users/{cfg['gitea_user']}") if ok and isinstance(d, dict) and d.get("login"): - msg = "已保存(未配置 Gitea 密码,只能同步公开仓库;仓库为私有时请填写密码)" + notes.append("未配置 Gitea 密码,只能同步公开仓库;仓库为私有时请填写密码") else: - msg = f"已保存(Gitea 上找不到用户 {cfg['gitea_user']},请检查)" - self._json({"ok": True, "msg": msg}) + notes.append(f"注意:在新地址上找不到用户 {cfg['gitea_user']},请检查") + self._json({"ok": True, "msg": "已保存(" + ";".join(notes) + ")"}) elif path == "/api/accounts/add": token = (data.get("token") or "").strip() label = (data.get("label") or "").strip() @@ -1440,7 +1551,7 @@ def main(): 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 '关'})") + f"Gitea 用户={cfg['gitea_user']}, 地址={gitea_host()}, 自动创建={'开' 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 029dbe3..5ebc27f 100644 --- a/GiteaHubSync/manifest +++ b/GiteaHubSync/manifest @@ -1,5 +1,5 @@ appname = GiteaHubSync -version = 1.1.2 +version = 1.2.0 display_name = Gitea→GitHub 同步助手 desc = 每次推送到飞牛 Gitea 的代码,10 秒内自动镜像到 GitHub 的 7 个私有仓库。内置控制面板:自动同步开关、立即同步、仓库状态、同步日志,一键打开 Gitea。数据全部保存在 NAS 本地。 platform = all diff --git a/README.md b/README.md index e14a65d..9c80f9d 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,8 @@ NAS 重启自动恢复。控制面板带自动同步开关、立即同步、仓 - **多 GitHub 账号**:设置页可添加多个 GitHub 账号(用户名 + Personal Access Token), 一键切换推送目标;每个账号独立记录同步进度,切回原账号不会重复推送 - **同步间隔可调**:设置页可改检查间隔(5 秒 ~ 1 天,默认 10 秒),改完立即生效 +- **Gitea 地址可改**:设置页可改同步引擎访问的 Gitea 地址(默认 `http://127.0.0.1:3000`), + 保存前自动探测连通性;改地址后已有镜像的拉取地址自动更新,「打开 Gitea」按钮随访问地址自适应 - **自动创建仓库**:Gitea 上的新仓库加入同步时,若 GitHub 上不存在同名仓库, 自动按账号设置为私有/公开创建(可开关) - **两种推送方式**:配置了 Token 的账号走 HTTPS 推送(通用,任何人都能用自己的 @@ -63,6 +65,9 @@ FNOS_HOST=192.168.31.10 FNOS_USER=<管理员账号> FNOS_PASS=<密码> \ > Token 只保存在 NAS 本地 `accounts.json`(权限 600),不上传任何地方; > 日志中的 Token 会自动打码。卸载应用不会动 GitHub 上的任何仓库。 +如果你的 Gitea 不在默认的 `127.0.0.1:3000`(比如换了端口),到设置页「Gitea 地址」一栏改掉即可, +保存时会自动探测连通性,并同步更新所有已有镜像的拉取地址。 + ## 重新构建 ```bash @@ -76,5 +81,5 @@ cd /vol1/@appshare/fn-deepseek-harness/fnos-gitea-sync 端口直连,私钥随应用打包安装在 NAS 本地 - 从 v1.0.0 升级会自动迁移:旧的同步进度归入「默认(SSH 私钥)」账号,行为不变 - 数据文件:`accounts.json`(GitHub 账号,含 Token)、`settings.json` - (Gitea 用户名 / 自动创建开关)、`repos.json`(同步列表)、`.last-pushed/<账号>/<仓库>` - (每个账号的同步进度) + (Gitea 地址/用户名/密码、自动创建开关、同步间隔)、`repos.json`(同步列表)、 + `.last-pushed/<账号>/<仓库>`(每个账号的同步进度)