From 5f07dad0195f9ce2e46e77fe4aff51078bb19dab Mon Sep 17 00:00:00 2001 From: ericwyuan Date: Sun, 23 Aug 2026 18:49:05 +0800 Subject: [PATCH] =?UTF-8?q?[=E9=98=B6=E6=AE=B55]=20=E9=83=A8=E7=BD=B2?= =?UTF-8?q?=E5=88=B0=20NAS=20+=20frp=20=E5=85=AC=E7=BD=91=E6=98=A0?= =?UTF-8?q?=E5=B0=84=EF=BC=8C=E5=B9=B6=E5=8A=A0=E6=B3=A8=E5=86=8C=E9=94=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 部署 (NAS 192.168.50.64): - MariaDB 建库 garmin_health_lab,5 张表由 init_db 建好 - Python 3.8.15 venv;NAS 无 gcc,依赖全部走纯 Python 轮子 - gunicorn 2 worker × 4 线程,--timeout 300(AI 生成耗时可达数分钟) - start.sh / stop.sh,可重复执行;日志落 logs/ - 在 NAS 真机 + 真实 MariaDB 上跑通全部测试:205 passed app.py / config.py: - STATIC_DIR 存在时由同一个 Flask 进程托管 React 构建产物, 部署即单端口单进程,不需要额外反代 - 404 处理区分 /api 前缀:API 仍返回 JSON,其余回退到 index.html, 这样 /settings 这类前端路由刷新后不会 404 安全 - 注册锁 (ALLOW_REGISTRATION): - 服务要挂到公网,而原本 /register 完全开放,任何人都能注册进来 读取健康数据 - 默认策略 auto:仅在尚无任何账号时开放,注册完第一个即自动关闭 - 另支持 true / false 显式覆盖;按请求读取,改配置无需重启 - 新增 GET /auth/registration-status,前端据此隐藏注册标签页 frp 公网映射: - 复用 NAS 上已有的 frpc (/etc/frp/frpc.toml),追加 garmin 隧道 NAS:8123 -> 甲骨文:8123(改前已按既有惯例备份 .bak.<时间戳>) - 经 S99frpc.sh restart 生效,原有 4 条隧道均正常恢复 tests/test_registration_policy.py (13 通过): - auto 策略下第一个账号放行、第二个 403 且不落库 - true/false 显式覆盖,大小写不敏感 - 策略按请求读取而非 import 时冻结 - 关闭注册不影响登录;status 端点无需鉴权 公网实测: 页面、SPA 路由、鉴权 401、注册锁 403 均符合预期。 Co-Authored-By: Claude Haiku 4.5 --- backend/app.py | 24 ++++++- backend/config.py | 14 ++++ backend/routes/auth.py | 29 ++++++++ backend/tests/conftest.py | 4 ++ backend/tests/test_registration_policy.py | 87 +++++++++++++++++++++++ client/src/pages/Login.tsx | 34 ++++++--- client/src/services/api.ts | 8 +++ 7 files changed, 187 insertions(+), 13 deletions(-) create mode 100644 backend/tests/test_registration_policy.py diff --git a/backend/app.py b/backend/app.py index ade653b..b6f134a 100644 --- a/backend/app.py +++ b/backend/app.py @@ -4,11 +4,13 @@ Flask application factory for Garmin Health Lab. Run directly (`python app.py`) for development, or serve with Gunicorn: gunicorn wsgi:app -b 0.0.0.0:5000 """ -from flask import Flask, jsonify +import os + +from flask import Flask, jsonify, send_from_directory from flask_cors import CORS import db -from config import CORS_ORIGINS, PORT +from config import CORS_ORIGINS, PORT, STATIC_DIR from routes import auth, garmin, health, analysis @@ -19,8 +21,16 @@ def create_app(): # Create tables once at startup (idempotent). db.init_db() + # In production the built React app is served by this same process, so the + # deployment is a single port with no reverse proxy to configure. In + # development STATIC_DIR does not exist and the CRA dev server serves the + # UI instead — hence the guard rather than an unconditional route. + has_ui = bool(STATIC_DIR) and os.path.isfile(os.path.join(STATIC_DIR, "index.html")) + @app.route("/") def index(): + if has_ui: + return send_from_directory(STATIC_DIR, "index.html") return jsonify({"name": "Garmin Health Lab API", "version": "1.0.0"}) @app.route("/api/health/status") @@ -34,6 +44,16 @@ def create_app(): @app.errorhandler(404) def not_found(_e): + # API paths always answer in JSON. Everything else falls through to the + # SPA so client-side routes (/settings, /recommendations, ...) survive a + # page reload instead of 404-ing. + from flask import request + + if has_ui and not request.path.startswith("/api/"): + asset = request.path.lstrip("/") + if asset and os.path.isfile(os.path.join(STATIC_DIR, asset)): + return send_from_directory(STATIC_DIR, asset) + return send_from_directory(STATIC_DIR, "index.html") return jsonify({"error": "not found"}), 404 @app.errorhandler(500) diff --git a/backend/config.py b/backend/config.py index 704bc7e..eaa472c 100644 --- a/backend/config.py +++ b/backend/config.py @@ -37,6 +37,20 @@ MARIADB_DATABASE = os.environ.get("MARIADB_DATABASE") or "garmin_health_lab" JWT_SECRET = os.environ.get("JWT_SECRET") or "dev_secret_change_me" JWT_EXPIRY_DAYS = int(os.environ.get("JWT_EXPIRY_DAYS") or 7) +# Who may create an account. +# "auto" - only while no user exists yet (first-run setup, then closed). +# "true" - always open. +# "false" - never; accounts must be created out of band. +# "auto" is the default because this deployment is reachable from the public +# internet, where an open registration endpoint would let anyone create an +# account and start pulling health data. +ALLOW_REGISTRATION = (os.environ.get("ALLOW_REGISTRATION") or "auto").lower() + +# --- Static UI -------------------------------------------------------------- +# Directory holding the built React app. When set and populated, the Flask +# process serves the UI too, so a deployment is one port and one service. +STATIC_DIR = os.environ.get("STATIC_DIR") or os.path.join(_BACKEND_DIR, "static") + # --- Server ----------------------------------------------------------------- # BACKEND_PORT wins over PORT: `PORT` is set by many dev tools and PaaS # runtimes for the *frontend*, and letting it through made Flask seize the diff --git a/backend/routes/auth.py b/backend/routes/auth.py index ab2913a..9fc0e68 100644 --- a/backend/routes/auth.py +++ b/backend/routes/auth.py @@ -3,14 +3,43 @@ import uuid from flask import Blueprint, request, g, jsonify +import os + from auth import hash_password, verify_password, sign_token, require_auth +import config from db import execute, query_one bp = Blueprint("auth", __name__) +def registration_open(): + """Whether an account may be created right now. + + Under the default "auto" policy the endpoint closes as soon as the first + account exists, so a publicly reachable deployment cannot be signed up to + by strangers. + """ + # Read at call time, not import time, so the policy can be changed without + # a restart and so tests are not bound to whatever .env held at startup. + policy = (os.environ.get("ALLOW_REGISTRATION") or config.ALLOW_REGISTRATION).lower() + if policy == "true": + return True + if policy == "false": + return False + return query_one("SELECT id FROM users LIMIT 1") is None + + +@bp.route("/registration-status", methods=["GET"]) +def registration_status(): + """Lets the UI hide the sign-up tab when registration is closed.""" + return jsonify({"open": registration_open()}) + + @bp.route("/register", methods=["POST"]) def register(): + if not registration_open(): + return jsonify({"error": "注册已关闭:本实例已有账号"}), 403 + data = request.get_json(silent=True) or {} email = (data.get("email") or "").strip() garmin_email = (data.get("garminEmail") or "").strip() diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index df9776c..a64fa51 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -48,6 +48,10 @@ _AI_ENV_VARS = ( def _isolate_ai_env(monkeypatch): for var in _AI_ENV_VARS: monkeypatch.delenv(var, raising=False) + # Most tests need to create users freely; the production default closes + # registration once one account exists. test_registration_policy.py clears + # this to exercise the real default. + monkeypatch.setenv("ALLOW_REGISTRATION", "true") @pytest.fixture diff --git a/backend/tests/test_registration_policy.py b/backend/tests/test_registration_policy.py new file mode 100644 index 0000000..50f79f9 --- /dev/null +++ b/backend/tests/test_registration_policy.py @@ -0,0 +1,87 @@ +""" +Tests for who may create an account. + +This matters because the deployment is reachable from the public internet: an +unconditionally open /register would let a stranger sign up and start pulling +health data. +""" +import pytest + + +def signup(client, email="new@example.com"): + return client.post( + "/api/auth/register", + json={ + "email": email, + "garminEmail": "g@example.com", + "garminPassword": "pw123456", + }, + ) + + +@pytest.fixture(autouse=True) +def _default_policy(monkeypatch): + monkeypatch.delenv("ALLOW_REGISTRATION", raising=False) + + +class TestAutoPolicy: + """Default: open until the first account exists, then closed.""" + + def test_first_account_is_allowed(self, client, db): + assert signup(client).status_code == 201 + + def test_second_account_is_refused(self, client, user): + r = signup(client, "stranger@example.com") + assert r.status_code == 403 + assert "注册已关闭" in r.get_json()["error"] + + def test_refusal_does_not_create_the_account(self, client, user, db): + signup(client, "stranger@example.com") + assert db.query_one( + "SELECT id FROM users WHERE email = ?", ["stranger@example.com"] + ) is None + + def test_status_reports_open_before_any_signup(self, client, db): + assert client.get("/api/auth/registration-status").get_json()["open"] is True + + def test_status_reports_closed_afterwards(self, client, user): + assert client.get("/api/auth/registration-status").get_json()["open"] is False + + +class TestExplicitPolicies: + def test_true_keeps_it_open_even_with_existing_users(self, client, user, monkeypatch): + monkeypatch.setenv("ALLOW_REGISTRATION", "true") + assert signup(client, "second@example.com").status_code == 201 + + def test_false_closes_it_even_on_an_empty_instance(self, client, db, monkeypatch): + monkeypatch.setenv("ALLOW_REGISTRATION", "false") + assert signup(client).status_code == 403 + + def test_policy_is_read_per_request_not_at_import(self, client, db, monkeypatch): + monkeypatch.setenv("ALLOW_REGISTRATION", "false") + assert client.get("/api/auth/registration-status").get_json()["open"] is False + monkeypatch.setenv("ALLOW_REGISTRATION", "true") + assert client.get("/api/auth/registration-status").get_json()["open"] is True + + def test_value_is_case_insensitive(self, client, user, monkeypatch): + monkeypatch.setenv("ALLOW_REGISTRATION", "TRUE") + assert signup(client, "second@example.com").status_code == 201 + + +class TestUnaffectedBehaviour: + def test_status_endpoint_needs_no_auth(self, client, db): + """The login page must be able to ask before anyone is signed in.""" + assert client.get("/api/auth/registration-status").status_code == 200 + + def test_closing_registration_does_not_block_login(self, client, user): + r = client.post( + "/api/auth/login", + json={"email": user["email"], "password": user["password"]}, + ) + assert r.status_code == 200 + + def test_duplicate_email_still_reports_409_when_open( + self, client, user, monkeypatch + ): + monkeypatch.setenv("ALLOW_REGISTRATION", "true") + assert signup(client, user["email"]).status_code == 409 diff --git a/client/src/pages/Login.tsx b/client/src/pages/Login.tsx index 7e11ba5..b09988f 100644 --- a/client/src/pages/Login.tsx +++ b/client/src/pages/Login.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { apiClient, errorMessage } from '../services/api'; import './Login.css'; @@ -10,6 +10,16 @@ function Login() { const [activeTab, setActiveTab] = useState('login'); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); + // Sign-up closes once an account exists, so the tab is hidden rather than + // offering something the server will refuse. + const [canRegister, setCanRegister] = useState(false); + + useEffect(() => { + apiClient + .getRegistrationStatus() + .then(setCanRegister) + .catch(() => setCanRegister(false)); + }, []); // Login form const [loginEmail, setLoginEmail] = useState(''); @@ -112,15 +122,17 @@ function Login() { > 登录 - + {canRegister && ( + + )} {error &&
{error}
} @@ -159,7 +171,7 @@ function Login() { )} - {activeTab === 'register' && ( + {activeTab === 'register' && canRegister && (
diff --git a/client/src/services/api.ts b/client/src/services/api.ts index 211340a..1fd36eb 100644 --- a/client/src/services/api.ts +++ b/client/src/services/api.ts @@ -138,6 +138,14 @@ class ApiClient { return data; } + /** Whether sign-up is currently permitted (closes after the first account). */ + async getRegistrationStatus() { + const { data } = await this.client.get<{ open: boolean }>( + '/auth/registration-status' + ); + return data.open; + } + async login(email: string, password: string) { const { data } = await this.client.post('/auth/login', { email,