feat: v1.2.0 — Gitea 地址可更改(默认 http://127.0.0.1:3000)

- 设置页新增「Gitea 地址」输入:同步引擎的 API/克隆/拉取全部改用该地址,
  适配 Gitea 换了端口或部署在别处的场景
- 地址规范化:自动补 http://、去尾部斜杠,校验 scheme/主机名,非法值明确拒绝
- 保存前探测连通性(/api/v1/version),连不上拒绝保存并提示
- 改地址后自动把已有镜像仓库的 origin 重指到新地址,无需重新克隆
- 「打开 Gitea」按钮随设置端口 + 访问面板的主机名自适应(不再写死 3000)
- 改地址/用户名不再误清 Gitea 密码(密码仅在密码行保存时提交)
- 启动日志记录当前 Gitea 地址,便于排障
- manifest 1.1.2 → 1.2.0
This commit is contained in:
cbb
2026-10-13 19:59:45 +08:00
parent 7260f402d3
commit 870612ff93
3 changed files with 150 additions and 34 deletions
+142 -31
View File
@@ -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 = """
</div>
<div class="btns">
<button class="b-sync" id="btnSync" onclick="syncNow()">⚡ 立即同步一次</button>
<a class="btn b-gitea" href="__GITEA__" target="_blank">🗂️ 打开 Gitea(3000端口)</a>
<a class="btn b-gitea" href="__GITEA__" target="_blank">🗂️ 打开 Gitea</a>
<a class="btn b-gh" id="btnGh" href="__GH__" target="_blank">🐙 GitHub 仓库列表</a>
</div>
</div>
@@ -961,17 +1039,22 @@ PAGE_SETTINGS = """
</div>
<div class="card">
<h2>Gitea 与同步设置</h2>
<label class="flabel">Gitea 地址<span class="sub">同步引擎访问的地址,默认 http://127.0.0.1:3000</span></label>
<div class="addrow">
<input id="giteaHost" placeholder="http://127.0.0.1:3000">
<button class="b-mini" id="btnGitea" onclick="saveGitea(false)">保存</button>
</div>
<label class="flabel">Gitea 用户名<span class="sub">面板按此用户名列出你可同步的仓库</span></label>
<div class="addrow">
<input id="giteaUser" placeholder="cbb">
<button class="b-mini" id="btnGitea" onclick="saveGitea()">保存</button>
<button class="b-mini" onclick="saveGitea(false)">保存</button>
</div>
<label class="flabel">Gitea 密码<span class="sub">仅仓库为私有时需要;留空保存可清除</span></label>
<div class="addrow">
<input id="giteaPass" type="password" placeholder="未设置">
<button class="b-mini" onclick="saveGitea()">保存</button>
<button class="b-mini" onclick="saveGitea(true)">保存</button>
</div>
<div class="tip">本地 Gitea 地址 __GITEAURL__;密码只保存在 NAS 本地,仅用于读取私有仓库。</div>
<div class="tip" id="giteaUrlTip"></div>
<div class="optbox">
<div class="optrow">
<label class="switch"><input type="checkbox" id="swAuto" onchange="saveAuto()"><span class="slider"></span></label>
@@ -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}")
+1 -1
View File
@@ -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
+7 -2
View File
@@ -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/<账号>/<仓库>`(每个账号的同步进度)