Files
2026-10-13 14:30:13 +08:00

408 lines
15 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# coding: utf-8
"""GitHub account authorization and repository listing for the website Git manager.
"""
import json
import os
import secrets
import time
import urllib.error
import urllib.parse
import urllib.request
import public
from git_auth import decrypt_git_token, encrypt_git_token
GITHUB_API_BASE = "https://api.github.com"
# nonce 存文件(防 CSRF,官网原样带回)。
# SameSite=Lax 下浏览器不带面板 cookie,session 读不到,nonce 校验和登录检查都会失败。
GITHUB_NONCE_FILE = "/www/server/panel/data/github_oauth_nonce"
def _official_base():
"""官网地址"""
return str(public.OfficialApiBase() or "").rstrip("/")
def _github_row():
return public.M("github_oauth").order("id DESC").find()
def _github_access_token():
"""返回 (access_token, row);账号未连接时抛 ValueError。"""
row = _github_row()
if not row or not row.get("access_token"):
raise ValueError("GitHub account is not connected")
try:
token = decrypt_git_token(row["access_token"])
except Exception:
raise ValueError("The saved GitHub access token cannot be decrypted")
if not token:
raise ValueError("GitHub account is not connected")
return token, row
def _github_expiry(row):
"""判断 access_token 是否过期。
返回 (expired, expires_at)。expires_in 为空(GitHub 未开启 token 过期)视为
长期不过期;expires_at 为过期时间戳(秒),无法判断时返回 None。
"""
expires_in = str(row.get("expires_in", "") or "").strip()
updated_at = str(row.get("updated_at", "") or "").strip()
if not expires_in or not updated_at:
return False, None
try:
updated_ts = int(time.mktime(time.strptime(updated_at, "%Y-%m-%d %H:%M:%S")))
expires_at = updated_ts + int(expires_in)
return time.time() > expires_at, expires_at
except Exception:
return False, None
def _refresh_via_official(grant_id, grant_secret):
"""用 grant_id + grant_secret 调官网 POST /github/panel/token 换新短时 access_token。
grant_secret 为明文(官网用库里的 refresh_token 向 GitHub 轮转,不回传 refresh_token)。
返回 (access_token, token_type, scope, expires_in);失败抛 ValueError。
"""
url = "{}/github/panel/token".format(_official_base())
payload = json.dumps({
"grant_id": grant_id,
"grant_secret": grant_secret,
}).encode("utf-8")
request = urllib.request.Request(url, data=payload, method="POST")
request.add_header("Content-Type", "application/json")
request.add_header("User-Agent", "aaPanel")
try:
with urllib.request.urlopen(request, timeout=30) as response:
data = json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
body = ""
try:
body = exc.read().decode("utf-8")[:2000]
except Exception:
pass
raise ValueError("GitHub token renew failed: {}".format(body or str(exc)))
except urllib.error.URLError as exc:
raise ValueError("GitHub token renew request failed: {}".format(exc))
if not isinstance(data, dict):
raise ValueError("GitHub token renew failed: invalid response")
# 官网业务错误:refresh_unavailable(无 refresh_token,需重新授权)/ invalid_grant
if data.get("error"):
raise ValueError("GitHub token renew failed: {}".format(data.get("error")))
access_token = str(data.get("access_token", "") or "").strip()
if not access_token:
raise ValueError("GitHub token renew failed: no access_token in response")
return (
access_token,
str(data.get("token_type", "") or "").strip(),
str(data.get("scope", "") or "").strip(),
str(data.get("expires_in", "") or "").strip(),
)
def _renew_github_token():
"""用落库的 grant_id + grant_secret 向官网续期并回写 access_token。
返回续期后的信息 dict;未连接 / 无 grant 凭证时抛 ValueError。
"""
row = _github_row()
if not row or not row.get("access_token"):
raise ValueError("GitHub account is not connected")
grant_id = str(row.get("grant_id", "") or "").strip()
encrypted_secret = str(row.get("grant_secret", "") or "").strip()
if not grant_id or not encrypted_secret:
raise ValueError("No grant credential saved, please re-authorize")
try:
grant_secret = decrypt_git_token(encrypted_secret)
except Exception:
raise ValueError("The saved grant_secret cannot be decrypted")
if not grant_secret:
raise ValueError("No grant credential saved, please re-authorize")
access_token, token_type, scope, expires_in = _refresh_via_official(
grant_id, grant_secret,
)
encrypted_token = encrypt_git_token(access_token)
now = public.getDate()
update_fields = {
"access_token": encrypted_token,
"updated_at": now,
}
if token_type:
update_fields["token_type"] = token_type
if scope:
update_fields["scope"] = scope
if expires_in:
update_fields["expires_in"] = expires_in
public.M("github_oauth").where("id=?", (row["id"],)).update(update_fields)
return {
"login": row.get("login", ""),
"renewed_at": now,
"expires_in": expires_in or row.get("expires_in", ""),
"expired": False,
}
def resolve_github_credentials():
"""返回 (login, access_token) 供 clone/查询复用 token 鉴权;未连接时抛 ValueError。"""
token, row = _github_access_token()
return row.get("login", "") or "", token
def get_github_info():
"""返回 GitHub 账号授权状态,供设置页等非 /git 场景展示连接状态与解绑入口复用。
"""
try:
row = _github_row()
if not isinstance(row, dict) or not row.get("access_token"):
return {"connected": False}
expired, expires_at = _github_expiry(row)
return {
"connected": True,
"login": row.get("login", "") or "",
"avatar_url": row.get("avatar_url", "") or "",
"scope": row.get("scope", "") or "",
"expired": expired,
"renewed_at": row.get("updated_at", "") or "",
"expires_in": row.get("expires_in", "") or "",
"expires_at": expires_at,
}
except Exception:
return {"connected": False}
def _github_api_request(path, token):
url = GITHUB_API_BASE + path
request = urllib.request.Request(url)
request.add_header("Authorization", "Bearer {}".format(token))
request.add_header("Accept", "application/vnd.github+json")
request.add_header("User-Agent", "aaPanel")
try:
with urllib.request.urlopen(request, timeout=30) as response:
return json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
body = ""
try:
body = exc.read().decode("utf-8")[:2000]
except Exception:
pass
raise ValueError(
"GitHub API error {}: {}".format(exc.code, body or str(exc))
)
except urllib.error.URLError as exc:
raise ValueError("GitHub API request failed: {}".format(exc))
def get_github_oauth_url(self, get):
"""生成官网 GitHub 授权跳转 URL,返回 {authorize_url}。
前端拿到 authorize_url 后浏览器跳转;nonce 写文件供回调校验。
"""
try:
nonce = secrets.token_urlsafe(24) # A-Za-z0-9_- ,最长 128
public.writeFile(GITHUB_NONCE_FILE, nonce)
panel_callback = "{}/github/oauth_callback".format(public.getPanelAddr())
authorize_url = "{}/github/panel/authorize?panel_callback={}&nonce={}".format(
_official_base(),
urllib.parse.quote(panel_callback, safe=""),
urllib.parse.quote(nonce, safe=""),
)
return public.return_message(0, 0, {"authorize_url": authorize_url})
except Exception as exc:
return public.return_message(-1, 0, str(exc))
def handle_github_callback(get):
"""处理官网 POST 表单回调(/github/oauth_callback 路由调用)。
成功返回 (True, login),失败抛 ValueError。
"""
nonce = str(get.get("nonce", "") or "").strip()
expected = str(public.readFile(GITHUB_NONCE_FILE) or "").strip()
public.print_log(
"[github_oauth] callback nonce_in={!r} expected={!r}".format(nonce, expected)
)
if expected:
try:
os.remove(GITHUB_NONCE_FILE)
except Exception:
pass
if not expected or not nonce or nonce != expected:
raise ValueError("Invalid OAuth nonce")
# 官网透传的 GitHub 拒绝/错误
error = str(get.get("error", "") or "").strip()
if error:
raise ValueError(
"GitHub authorization failed: {} {}".format(
error, str(get.get("error_description", "") or "").strip()
)
)
access_token = str(get.get("access_token", "") or "").strip()
grant_id = str(get.get("grant_id", "") or "").strip()
grant_secret = str(get.get("grant_secret", "") or "").strip()
token_type = str(get.get("token_type", "") or "").strip()
scope = str(get.get("scope", "") or "").strip()
expires_in = str(get.get("expires_in", "") or "").strip()
public.print_log(
"[github_oauth] callback fields access_token_len={} head={} token_type={!r} "
"scope={!r} grant_id={!r} grant_secret_len={} expires_in={!r}".format(
len(access_token), access_token[:6], token_type, scope, grant_id,
len(grant_secret), expires_in,
)
)
if not access_token:
raise ValueError("Missing GitHub access token in callback")
# 官网不回传 login,用 access_token 调 GitHub /user 补登
login = ""
avatar_url = ""
try:
user = _github_api_request("/user", access_token)
login = str(user.get("login", "") or "")
avatar_url = str(user.get("avatar_url", "") or "")
except Exception:
# 拿不到用户信息不阻断授权,后续 get_github_repos 可再补
pass
now = public.getDate()
encrypted_token = encrypt_git_token(access_token)
encrypted_secret = encrypt_git_token(grant_secret) if grant_secret else ""
row = _github_row()
if row:
ret = public.M("github_oauth").where("id=?", (row["id"],)).update({
"login": login,
"access_token": encrypted_token,
"token_type": token_type,
"scope": scope,
"avatar_url": avatar_url,
"grant_id": grant_id,
"grant_secret": encrypted_secret,
"expires_in": expires_in,
"updated_at": now,
})
else:
ret = public.M("github_oauth").add(
"login,access_token,token_type,scope,avatar_url,"
"grant_id,grant_secret,expires_in,created_at,updated_at",
(
login, encrypted_token, token_type, scope, avatar_url,
grant_id, encrypted_secret, expires_in, now, now,
),
)
public.print_log(
"[github_oauth] callback saved login={!r} scope={!r} updated={} db_ret={!r}".format(
login, scope, bool(row), ret
)
)
return True, login
def get_github_account(self, get):
try:
row = _github_row()
if not row or not row.get("access_token"):
return public.return_message(0, 0, {"connected": False})
expired, expires_at = _github_expiry(row)
return public.return_message(0, 0, {
"connected": True,
"login": row.get("login", ""),
"avatar_url": row.get("avatar_url", ""),
"scope": row.get("scope", ""),
"expired": expired,
"renewed_at": row.get("updated_at", "") or "",
"expires_in": row.get("expires_in", "") or "",
"expires_at": expires_at,
})
except Exception as exc:
public.print_log(public.get_error_info())
return public.return_message(-1, 0, str(exc))
def renew_github_token(self, get):
"""手动续签 GitHub access_token(POST 官网 /github/panel/token)。"""
try:
info = _renew_github_token()
return public.return_message(0, 0, public.lang("Reset successfully"))
except Exception as exc:
return public.return_message(-1, 0, str(exc))
def get_github_repos(self, get):
try:
token, _ = _github_access_token()
repos = {}
page = 1
while page <= 10:
items = _github_api_request(
"/user/repos?per_page=100&page={}&sort=updated".format(page),
token,
)
if not items:
break
for item in items:
repos[item.get("full_name", "")] = item
if len(items) < 100:
break
page += 1
# 组织项目
try:
orgs = _github_api_request("/user/orgs?per_page=100", token)
for org in orgs:
org_login = org.get("login", "")
if not org_login:
continue
org_repos = _github_api_request(
"/orgs/{}/repos?per_page=100&sort=updated".format(
urllib.parse.quote(org_login, safe="")
),
token,
)
for item in org_repos:
repos[item.get("full_name", "")] = item
except Exception:
pass
result = []
for item in repos.values():
if not item.get("full_name"):
continue
owner = (item.get("owner") or {}).get("login", "")
name = item.get("name", "")
html_url = item.get("html_url", "")
# 部署时用 https clone 地址走 token 鉴权
clone_url = item.get("clone_url") or (
"{}.git".format(html_url.rstrip("/")) if html_url else ""
)
result.append({
"owner": owner,
"name": name,
"full_name": item.get("full_name", ""),
"private": bool(item.get("private")),
"default_branch": item.get("default_branch", ""),
"clone_url": clone_url,
"updated_at": item.get("updated_at", ""),
})
result.sort(key=lambda x: x["full_name"].lower())
return public.return_message(0, 0, result)
except Exception as exc:
return public.return_message(-1, 0, str(exc))
def disconnect_github(self, get):
try:
public.M("github_oauth").execute("DELETE FROM github_oauth", ())
try:
os.remove(GITHUB_NONCE_FILE)
except Exception:
pass
return public.return_message(0, 0, "Deleted successfully")
except Exception as exc:
return public.return_message(-1, 0, str(exc))