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,