fix(garmin): 两步验证账号同步报 EOFError,改用令牌登录
现象:网页触发同步报 "EOF when reading a line"。 原因:garth 的默认 MFA 提示是 input(),向 stdin 索取验证码。 gunicorn worker 没有 stdin,于是抛出 EOFError——错误信息本身 完全没提到 MFA,看不出该做什么。 方案:把"输验证码"和"日常同步"拆开。 - 新增 garmin_tokens 表存 garth 令牌(Client.dumps/loads 序列化) - garmin_login.py:在终端里跑一次,可正常输入验证码, 成功后令牌存库 - _connect() 优先加载令牌并 refresh_oauth2(),命中则完全跳过登录, 既不需要密码也不需要验证码(令牌有效期约一年) - 无令牌且密码登录撞上 MFA 时,抛 MFARequired 并给出具体该执行 哪条命令,而不是把 EOFError 原样抛给用户 接口: - GET /api/garmin/auth-status 返回是否已有令牌 - /api/garmin/sync 在已有令牌时不再强制要求密码 前端: - 有令牌时隐藏密码输入框,提示无需密码 - 同步返回 mfaRequired 时,展示需要在 NAS 上执行的具体命令 - 同步请求超时放宽到 180s(一周的天数 + 运动是多次上游调用) - 成功消息补上运动记录条数 tests (test_garmin_sync.py 新增 12 条,共 35): - 令牌存取、覆盖不累积、按用户隔离 - 有令牌时绝不调用 login() - MFA 的 EOFError 转成带操作指引的 MFARequired - 普通 401 不会被误标成 mfaRequired - 无令牌且无密码时给出明确拒绝 NAS 真机: 252 passed Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -84,6 +84,20 @@ CREATE TABLE IF NOT EXISTS sync_status (
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
-- Garmin OAuth tokens, obtained once through an interactive login.
|
||||
-- Garmin accounts with two-factor auth cannot be logged into unattended: the
|
||||
-- library asks for an MFA code on stdin, which a gunicorn worker does not
|
||||
-- have. Storing the resulting tokens lets every later sync skip the login
|
||||
-- entirely (they stay valid for roughly a year).
|
||||
CREATE TABLE IF NOT EXISTS garmin_tokens (
|
||||
user_id VARCHAR(64) PRIMARY KEY,
|
||||
token TEXT NOT NULL,
|
||||
garmin_email VARCHAR(255),
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
-- One cached LLM answer per user. Generating one takes minutes against a
|
||||
-- large reasoning model, which is far too slow to sit in a page load, so the
|
||||
-- result is stored and reused until the underlying data changes.
|
||||
|
||||
93
backend/garmin_login.py
Normal file
93
backend/garmin_login.py
Normal file
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
One-time interactive Garmin login.
|
||||
|
||||
Garmin accounts with two-factor auth cannot be logged into by the web service:
|
||||
the library asks for the MFA code on stdin, and a gunicorn worker has none —
|
||||
the attempt fails with "EOFError: EOF when reading a line".
|
||||
|
||||
This script does that login in a terminal, where a code can actually be typed,
|
||||
and stores the resulting OAuth tokens in the database. Every later sync loads
|
||||
those tokens and skips the login entirely. They stay valid for roughly a year;
|
||||
re-run this when a sync starts reporting an expired session.
|
||||
|
||||
Usage (on the NAS):
|
||||
cd ~/apps/garmin-health-lab/backend
|
||||
.venv/bin/python garmin_login.py
|
||||
|
||||
Add --email to pick an account when more than one is registered.
|
||||
"""
|
||||
import argparse
|
||||
import getpass
|
||||
import sys
|
||||
|
||||
import db
|
||||
from services import garmin as garmin_svc
|
||||
|
||||
|
||||
def pick_user(email=None):
|
||||
if email:
|
||||
row = db.query_one("SELECT id, email, garmin_email FROM users WHERE email = ?",
|
||||
[email])
|
||||
if not row:
|
||||
sys.exit(f"找不到账号: {email}")
|
||||
return row
|
||||
|
||||
rows = db.query_all("SELECT id, email, garmin_email FROM users ORDER BY created_at")
|
||||
if not rows:
|
||||
sys.exit("数据库里还没有账号,请先在网页上注册。")
|
||||
if len(rows) == 1:
|
||||
return rows[0]
|
||||
|
||||
print("有多个账号,请选择:")
|
||||
for i, r in enumerate(rows, 1):
|
||||
print(f" {i}) {r['email']} (Garmin: {r['garmin_email']})")
|
||||
choice = input("序号: ").strip()
|
||||
try:
|
||||
return rows[int(choice) - 1]
|
||||
except (ValueError, IndexError):
|
||||
sys.exit("选择无效")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="一次性 Garmin 登录,保存令牌")
|
||||
parser.add_argument("--email", help="要绑定的本站账号邮箱")
|
||||
parser.add_argument("--garmin-email", help="Garmin 账号邮箱(默认用注册时填的)")
|
||||
args = parser.parse_args()
|
||||
|
||||
db.init_db()
|
||||
user = pick_user(args.email)
|
||||
garmin_email = args.garmin_email or user["garmin_email"]
|
||||
|
||||
print(f"本站账号 : {user['email']}")
|
||||
print(f"Garmin : {garmin_email}")
|
||||
print()
|
||||
|
||||
if garmin_svc.has_token(user["id"]):
|
||||
if input("已存在登录令牌,要覆盖吗?[y/N] ").strip().lower() != "y":
|
||||
return
|
||||
|
||||
password = getpass.getpass("Garmin 密码: ")
|
||||
if not password:
|
||||
sys.exit("密码不能为空")
|
||||
|
||||
Garmin = garmin_svc._import_garmin()
|
||||
client = Garmin(email=garmin_email, password=password, is_cn=garmin_svc._is_cn())
|
||||
|
||||
print("\n正在登录……若账号开启了两步验证,稍后会提示输入验证码。")
|
||||
try:
|
||||
# No tokenstore: this is the interactive path that mints the tokens.
|
||||
# garth's default MFA prompt reads stdin, which works here.
|
||||
client.login()
|
||||
except Exception as e:
|
||||
sys.exit(f"\n登录失败: {type(e).__name__}: {e}")
|
||||
|
||||
garmin_svc.save_token(user["id"], client.garth.dumps(), garmin_email)
|
||||
|
||||
print(f"\n登录成功:{client.display_name}")
|
||||
print("令牌已保存到数据库,之后网页上的同步不再需要密码或验证码。")
|
||||
print("令牌大约一年后过期,届时重跑本脚本即可。")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -22,9 +22,9 @@ def sync():
|
||||
if user and user.get("garmin_email"):
|
||||
creds["garminEmail"] = user["garmin_email"]
|
||||
|
||||
# The stored Garmin password is only kept as a hash, so it cannot be
|
||||
# recovered. A live sync requires the plaintext password in the body.
|
||||
if not creds["garminPassword"]:
|
||||
# With stored OAuth tokens no password is needed at all. Without them the
|
||||
# plaintext password must come in the body, because only a hash is kept.
|
||||
if not creds["garminPassword"] and not garmin_svc.has_token(g.user_id):
|
||||
return (
|
||||
jsonify({
|
||||
"status": "error",
|
||||
@@ -39,6 +39,13 @@ def sync():
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@bp.route("/auth-status", methods=["GET"])
|
||||
@require_auth
|
||||
def auth_status():
|
||||
"""Whether a stored token exists, so the UI knows to ask for a password."""
|
||||
return jsonify({"hasToken": garmin_svc.has_token(g.user_id)})
|
||||
|
||||
|
||||
@bp.route("/status", methods=["GET"])
|
||||
@require_auth
|
||||
def status():
|
||||
|
||||
@@ -65,20 +65,84 @@ def get_sync_status(user_id):
|
||||
}
|
||||
|
||||
|
||||
def _connect(creds):
|
||||
"""Log in to Garmin Connect. Separated so tests can substitute a client."""
|
||||
class MFARequired(RuntimeError):
|
||||
"""Raised when a password login needs a code this process cannot obtain."""
|
||||
|
||||
|
||||
def _is_cn():
|
||||
# Selects Garmin's China service, a separate backend with separate
|
||||
# accounts. This project tracks an international account.
|
||||
return (os.environ.get("GARMIN_IS_CN") or "").lower() in ("1", "true", "yes")
|
||||
|
||||
|
||||
def _import_garmin():
|
||||
try:
|
||||
from garminconnect import Garmin
|
||||
except ImportError:
|
||||
raise RuntimeError(
|
||||
"GARMIN_LIB_MISSING: 请先运行 `pip install garminconnect` 以启用同步"
|
||||
)
|
||||
return Garmin
|
||||
|
||||
# is_cn selects Garmin's China service, which is a different backend with
|
||||
# separate accounts. This project tracks an international account.
|
||||
is_cn = (os.environ.get("GARMIN_IS_CN") or "").lower() in ("1", "true", "yes")
|
||||
client = Garmin(email=creds["garminEmail"], password=creds["garminPassword"], is_cn=is_cn)
|
||||
client.login()
|
||||
|
||||
def load_token(user_id):
|
||||
row = query_one("SELECT token FROM garmin_tokens WHERE user_id = ?", [user_id])
|
||||
return row["token"] if row else None
|
||||
|
||||
|
||||
def save_token(user_id, token, garmin_email=None):
|
||||
cols = ["user_id", "token", "garmin_email", "updated_at"]
|
||||
placeholders = ", ".join(["?"] * len(cols))
|
||||
if DB_TYPE == "mariadb":
|
||||
updates = ", ".join(f"{c}=VALUES({c})" for c in cols if c != "user_id")
|
||||
sql = (f"INSERT INTO garmin_tokens ({', '.join(cols)}) VALUES ({placeholders}) "
|
||||
f"ON DUPLICATE KEY UPDATE {updates}")
|
||||
else:
|
||||
updates = ", ".join(f"{c}=excluded.{c}" for c in cols if c != "user_id")
|
||||
sql = (f"INSERT INTO garmin_tokens ({', '.join(cols)}) VALUES ({placeholders}) "
|
||||
f"ON CONFLICT(user_id) DO UPDATE SET {updates}")
|
||||
execute(sql, [user_id, token, garmin_email,
|
||||
datetime.datetime.utcnow().isoformat(timespec="seconds")])
|
||||
|
||||
|
||||
def has_token(user_id):
|
||||
return load_token(user_id) is not None
|
||||
|
||||
|
||||
def _connect(creds, user_id=None):
|
||||
"""Obtain a logged-in Garmin client.
|
||||
|
||||
Prefers stored OAuth tokens: an account with two-factor auth cannot be
|
||||
logged into from a web worker, because the library asks for the code on
|
||||
stdin and there is none (the failure surfaces as
|
||||
"EOFError: EOF when reading a line"). Tokens are minted once by
|
||||
`garmin_login.py`, which runs in a terminal where a code can be typed.
|
||||
"""
|
||||
Garmin = _import_garmin()
|
||||
client = Garmin(is_cn=_is_cn())
|
||||
|
||||
token = load_token(user_id) if user_id else None
|
||||
if token:
|
||||
client.garth.loads(token)
|
||||
# Populates display_name/unit_system and proves the token still works.
|
||||
client.garth.refresh_oauth2()
|
||||
client.display_name = client.garth.profile["displayName"]
|
||||
return client
|
||||
|
||||
if not creds.get("garminPassword"):
|
||||
raise RuntimeError("缺少 Garmin 密码,且未找到已保存的登录令牌")
|
||||
|
||||
client.username = creds["garminEmail"]
|
||||
client.password = creds["garminPassword"]
|
||||
try:
|
||||
client.login()
|
||||
except EOFError as e:
|
||||
# garth's default MFA prompt calls input(); under gunicorn stdin is
|
||||
# closed, so it raises EOFError rather than anything descriptive.
|
||||
raise MFARequired(
|
||||
"该 Garmin 账号开启了两步验证,无法在服务端直接登录。"
|
||||
"请在 NAS 上执行一次 `python garmin_login.py` 完成验证并保存令牌。"
|
||||
) from e
|
||||
return client
|
||||
|
||||
|
||||
@@ -194,12 +258,17 @@ def sync_data(user_id, creds, days=None, client=None):
|
||||
_set_sync_status(user_id, "syncing", now, records_synced=0)
|
||||
|
||||
try:
|
||||
client = client or _connect(creds)
|
||||
client = client or _connect(creds, user_id)
|
||||
except Exception as e:
|
||||
message = str(e)
|
||||
_set_sync_status(user_id, "error", now, records_synced=0, last_error=message)
|
||||
return {"status": "error", "recordsSynced": 0, "message": message,
|
||||
"lastSyncTime": now}
|
||||
return {
|
||||
"status": "error",
|
||||
"recordsSynced": 0,
|
||||
"message": message,
|
||||
"mfaRequired": isinstance(e, MFARequired),
|
||||
"lastSyncTime": now,
|
||||
}
|
||||
|
||||
today = datetime.date.today()
|
||||
start_date = (today - datetime.timedelta(days=days - 1)).isoformat()
|
||||
|
||||
@@ -218,7 +218,7 @@ class TestPartialAndTotalFailure:
|
||||
assert out["recordsSynced"] == 0
|
||||
|
||||
def test_login_failure_is_reported(self, db, user, monkeypatch):
|
||||
def boom(_creds):
|
||||
def boom(_creds, _uid=None):
|
||||
raise RuntimeError("401 Unauthorized")
|
||||
|
||||
monkeypatch.setattr(garmin_svc, "_connect", boom)
|
||||
@@ -280,3 +280,131 @@ class TestEndpoint:
|
||||
r = client.get("/api/garmin/status", headers=auth)
|
||||
assert r.status_code == 200
|
||||
assert r.get_json()["status"] == "idle"
|
||||
|
||||
|
||||
class TestTokenStore:
|
||||
"""Tokens are what make unattended sync possible on an MFA-protected
|
||||
account: the web worker has no stdin to type a code into."""
|
||||
|
||||
def test_absent_before_any_login(self, db, user):
|
||||
assert garmin_svc.has_token(user["id"]) is False
|
||||
|
||||
def test_saved_token_round_trips(self, db, user):
|
||||
garmin_svc.save_token(user["id"], "token-blob", "g@example.com")
|
||||
assert garmin_svc.has_token(user["id"]) is True
|
||||
assert garmin_svc.load_token(user["id"]) == "token-blob"
|
||||
|
||||
def test_re_login_replaces_rather_than_accumulates(self, db, user):
|
||||
garmin_svc.save_token(user["id"], "first", "g@example.com")
|
||||
garmin_svc.save_token(user["id"], "second", "g@example.com")
|
||||
|
||||
rows = db.query_all(
|
||||
"SELECT * FROM garmin_tokens WHERE user_id = ?", [user["id"]]
|
||||
)
|
||||
assert len(rows) == 1
|
||||
assert garmin_svc.load_token(user["id"]) == "second"
|
||||
|
||||
def test_tokens_are_per_user(self, db, user, client):
|
||||
garmin_svc.save_token(user["id"], "mine", "g@example.com")
|
||||
other = client.post(
|
||||
"/api/auth/register",
|
||||
json={"email": "o@example.com", "garminEmail": "og@example.com",
|
||||
"garminPassword": "pw123456"},
|
||||
).get_json()
|
||||
assert garmin_svc.has_token(other["id"]) is False
|
||||
|
||||
|
||||
class TestMfaHandling:
|
||||
def test_eof_from_the_mfa_prompt_becomes_an_actionable_error(
|
||||
self, db, user, monkeypatch
|
||||
):
|
||||
"""garth's default MFA prompt calls input(); with no stdin that raises
|
||||
a bare EOFError, which says nothing about what to do about it."""
|
||||
class StubGarth:
|
||||
def loads(self, s): pass
|
||||
def refresh_oauth2(self): pass
|
||||
|
||||
class StubGarmin:
|
||||
def __init__(self, *a, **k):
|
||||
self.garth = StubGarth()
|
||||
self.username = None
|
||||
self.password = None
|
||||
|
||||
def login(self, *a, **k):
|
||||
raise EOFError("EOF when reading a line")
|
||||
|
||||
monkeypatch.setattr(garmin_svc, "_import_garmin", lambda: StubGarmin)
|
||||
with pytest.raises(garmin_svc.MFARequired, match="两步验证"):
|
||||
garmin_svc._connect(CREDS, user["id"])
|
||||
|
||||
def test_sync_flags_mfa_so_the_ui_can_explain(self, db, user, monkeypatch):
|
||||
def boom(_creds, _uid=None):
|
||||
raise garmin_svc.MFARequired("需要两步验证")
|
||||
|
||||
monkeypatch.setattr(garmin_svc, "_connect", boom)
|
||||
out = garmin_svc.sync_data(user["id"], CREDS, days=1)
|
||||
|
||||
assert out["status"] == "error"
|
||||
assert out["mfaRequired"] is True
|
||||
|
||||
def test_ordinary_failures_are_not_flagged_as_mfa(self, db, user, monkeypatch):
|
||||
def boom(_creds, _uid=None):
|
||||
raise RuntimeError("401 Unauthorized")
|
||||
|
||||
monkeypatch.setattr(garmin_svc, "_connect", boom)
|
||||
assert garmin_svc.sync_data(user["id"], CREDS, days=1)["mfaRequired"] is False
|
||||
|
||||
def test_stored_token_is_used_instead_of_logging_in(self, db, user, monkeypatch):
|
||||
garmin_svc.save_token(user["id"], "saved-blob", "g@example.com")
|
||||
loaded = {}
|
||||
|
||||
class StubGarth:
|
||||
profile = {"displayName": "Tester"}
|
||||
|
||||
def loads(self, s):
|
||||
loaded["blob"] = s
|
||||
|
||||
def refresh_oauth2(self):
|
||||
loaded["refreshed"] = True
|
||||
|
||||
class StubGarmin:
|
||||
def __init__(self, *a, **k):
|
||||
self.garth = StubGarth()
|
||||
|
||||
def login(self, *a, **k):
|
||||
raise AssertionError("must not log in when a token exists")
|
||||
|
||||
monkeypatch.setattr(garmin_svc, "_import_garmin", lambda: StubGarmin)
|
||||
garmin_svc._connect({}, user["id"])
|
||||
|
||||
assert loaded["blob"] == "saved-blob"
|
||||
assert loaded["refreshed"] is True
|
||||
|
||||
def test_no_token_and_no_password_is_refused_clearly(self, db, user, monkeypatch):
|
||||
class StubGarmin:
|
||||
def __init__(self, *a, **k):
|
||||
self.garth = None
|
||||
|
||||
monkeypatch.setattr(garmin_svc, "_import_garmin", lambda: StubGarmin)
|
||||
with pytest.raises(RuntimeError, match="缺少 Garmin 密码"):
|
||||
garmin_svc._connect({}, user["id"])
|
||||
|
||||
|
||||
class TestAuthStatusEndpoint:
|
||||
def test_requires_auth(self, client):
|
||||
assert client.get("/api/garmin/auth-status").status_code == 401
|
||||
|
||||
def test_reports_false_then_true(self, client, auth, user, db):
|
||||
assert client.get("/api/garmin/auth-status", headers=auth).get_json()[
|
||||
"hasToken"] is False
|
||||
garmin_svc.save_token(user["id"], "blob", "g@example.com")
|
||||
assert client.get("/api/garmin/auth-status", headers=auth).get_json()[
|
||||
"hasToken"] is True
|
||||
|
||||
def test_sync_without_password_allowed_once_a_token_exists(
|
||||
self, client, auth, user, db
|
||||
):
|
||||
"""The password field exists only because no token is stored yet."""
|
||||
garmin_svc.save_token(user["id"], "blob", "g@example.com")
|
||||
r = client.post("/api/garmin/sync", headers=auth, json={})
|
||||
assert r.status_code != 400
|
||||
|
||||
@@ -153,3 +153,15 @@
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.cmd {
|
||||
background: #2d2d33;
|
||||
color: #e6e6e6;
|
||||
padding: 0.9rem 1rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.7;
|
||||
overflow-x: auto;
|
||||
margin: 0;
|
||||
font-family: Menlo, Monaco, Consolas, 'Courier New', monospace;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import './DataSync.css';
|
||||
function DataSync() {
|
||||
const [syncStatus, setSyncStatus] = useState<SyncStatus | null>(null);
|
||||
const [garminPassword, setGarminPassword] = useState('');
|
||||
const [hasToken, setHasToken] = useState<boolean | null>(null);
|
||||
const [mfaNeeded, setMfaNeeded] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
@@ -21,6 +23,10 @@ function DataSync() {
|
||||
|
||||
useEffect(() => {
|
||||
loadSyncStatus();
|
||||
apiClient
|
||||
.getGarminAuthStatus()
|
||||
.then(setHasToken)
|
||||
.catch(() => setHasToken(false));
|
||||
}, [loadSyncStatus]);
|
||||
|
||||
const handleSync = async (e: React.FormEvent) => {
|
||||
@@ -28,18 +34,21 @@ function DataSync() {
|
||||
setError('');
|
||||
setMessage('');
|
||||
|
||||
if (!garminPassword) {
|
||||
if (!hasToken && !garminPassword) {
|
||||
setError('请输入 Garmin 密码');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setMfaNeeded(false);
|
||||
try {
|
||||
const result = await apiClient.syncGarminData(garminPassword);
|
||||
const result = await apiClient.syncGarminData(garminPassword || undefined);
|
||||
if (result.status === 'success') {
|
||||
setMessage(`同步完成,新增/更新 ${result.recordsSynced} 天数据`);
|
||||
const acts = result.activitiesSynced ?? 0;
|
||||
setMessage(`同步完成:${result.recordsSynced} 天数据、${acts} 条运动记录`);
|
||||
} else {
|
||||
setError(result.message);
|
||||
if (result.mfaRequired) setMfaNeeded(true);
|
||||
}
|
||||
// Clear the password as soon as the request is done — it is only ever
|
||||
// held in memory for the duration of the call.
|
||||
@@ -101,28 +110,51 @@ function DataSync() {
|
||||
</section>
|
||||
|
||||
<form className="sync-actions" onSubmit={handleSync}>
|
||||
<div className="form-group">
|
||||
<label htmlFor="garmin-password">Garmin 密码</label>
|
||||
<input
|
||||
id="garmin-password"
|
||||
type="password"
|
||||
value={garminPassword}
|
||||
onChange={(e) => setGarminPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
autoComplete="current-password"
|
||||
disabled={busy}
|
||||
/>
|
||||
{hasToken === false && (
|
||||
<div className="form-group">
|
||||
<label htmlFor="garmin-password">Garmin 密码</label>
|
||||
<input
|
||||
id="garmin-password"
|
||||
type="password"
|
||||
value={garminPassword}
|
||||
onChange={(e) => setGarminPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
autoComplete="current-password"
|
||||
disabled={busy}
|
||||
/>
|
||||
<p className="field-hint">
|
||||
密码在库中只以哈希形式保存、无法还原,因此每次同步都需要重新输入。
|
||||
它仅用于本次向 Garmin 登录,不会被再次存储。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasToken === true && (
|
||||
<p className="field-hint">
|
||||
密码在库中只以哈希形式保存、无法还原,因此每次同步都需要重新输入。
|
||||
它仅用于本次向 Garmin 登录,不会被再次存储。
|
||||
已保存 Garmin 登录令牌,同步无需再输入密码。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button type="submit" className="btn btn-primary btn-large" disabled={busy}>
|
||||
{busy ? '正在同步…' : '立即同步'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{mfaNeeded && (
|
||||
<div className="info-box">
|
||||
<h4>该账号开启了两步验证</h4>
|
||||
<p style={{ margin: '0 0 0.75rem', color: '#555', lineHeight: 1.8 }}>
|
||||
网页端无法接收验证码。请在 NAS 上执行一次下面的命令,按提示输入验证码,
|
||||
令牌保存后本页的同步就不再需要密码或验证码(有效期约一年):
|
||||
</p>
|
||||
<pre className="cmd">
|
||||
{`ssh -p 2222 ericwyuan@192.168.50.64
|
||||
cd ~/apps/garmin-health-lab/backend
|
||||
.venv/bin/python garmin_login.py`}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
{message && <div className="success-message">{message}</div>}
|
||||
|
||||
|
||||
@@ -32,7 +32,10 @@ export interface SyncStatus {
|
||||
export interface SyncResult {
|
||||
status: 'success' | 'error';
|
||||
recordsSynced: number;
|
||||
activitiesSynced?: number;
|
||||
message: string;
|
||||
/** Set when the account has two-factor auth and no token is stored yet. */
|
||||
mfaRequired?: boolean;
|
||||
lastSyncTime: string;
|
||||
}
|
||||
|
||||
@@ -164,17 +167,31 @@ class ApiClient {
|
||||
|
||||
// --- garmin ---
|
||||
/**
|
||||
* The backend stores only a hash of the Garmin password, so a live sync
|
||||
* needs the plaintext password supplied here each time.
|
||||
* With a stored OAuth token no password is needed. Without one, the
|
||||
* plaintext password must be supplied because only a hash is kept — and an
|
||||
* MFA-protected account cannot log in this way at all (see garmin_login.py).
|
||||
*/
|
||||
async syncGarminData(garminPassword: string, garminEmail?: string) {
|
||||
const { data } = await this.client.post<SyncResult>('/garmin/sync', {
|
||||
garminPassword,
|
||||
...(garminEmail ? { garminEmail } : {}),
|
||||
});
|
||||
async syncGarminData(garminPassword?: string, garminEmail?: string) {
|
||||
const { data } = await this.client.post<SyncResult>(
|
||||
'/garmin/sync',
|
||||
{
|
||||
...(garminPassword ? { garminPassword } : {}),
|
||||
...(garminEmail ? { garminEmail } : {}),
|
||||
},
|
||||
// Pulling a week of days plus activities is many upstream calls.
|
||||
{ timeout: 180_000 }
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Whether a stored Garmin token exists (then sync needs no password). */
|
||||
async getGarminAuthStatus() {
|
||||
const { data } = await this.client.get<{ hasToken: boolean }>(
|
||||
'/garmin/auth-status'
|
||||
);
|
||||
return data.hasToken;
|
||||
}
|
||||
|
||||
async getGarminSyncStatus() {
|
||||
const { data } = await this.client.get<SyncStatus>('/garmin/status');
|
||||
return data;
|
||||
|
||||
Reference in New Issue
Block a user