[阶段5] 部署到 NAS + frp 公网映射,并加注册锁
部署 (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 <noreply@anthropic.com>
This commit is contained in:
@@ -4,11 +4,13 @@ Flask application factory for Garmin Health Lab.
|
|||||||
Run directly (`python app.py`) for development, or serve with Gunicorn:
|
Run directly (`python app.py`) for development, or serve with Gunicorn:
|
||||||
gunicorn wsgi:app -b 0.0.0.0:5000
|
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
|
from flask_cors import CORS
|
||||||
|
|
||||||
import db
|
import db
|
||||||
from config import CORS_ORIGINS, PORT
|
from config import CORS_ORIGINS, PORT, STATIC_DIR
|
||||||
from routes import auth, garmin, health, analysis
|
from routes import auth, garmin, health, analysis
|
||||||
|
|
||||||
|
|
||||||
@@ -19,8 +21,16 @@ def create_app():
|
|||||||
# Create tables once at startup (idempotent).
|
# Create tables once at startup (idempotent).
|
||||||
db.init_db()
|
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("/")
|
@app.route("/")
|
||||||
def index():
|
def index():
|
||||||
|
if has_ui:
|
||||||
|
return send_from_directory(STATIC_DIR, "index.html")
|
||||||
return jsonify({"name": "Garmin Health Lab API", "version": "1.0.0"})
|
return jsonify({"name": "Garmin Health Lab API", "version": "1.0.0"})
|
||||||
|
|
||||||
@app.route("/api/health/status")
|
@app.route("/api/health/status")
|
||||||
@@ -34,6 +44,16 @@ def create_app():
|
|||||||
|
|
||||||
@app.errorhandler(404)
|
@app.errorhandler(404)
|
||||||
def not_found(_e):
|
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
|
return jsonify({"error": "not found"}), 404
|
||||||
|
|
||||||
@app.errorhandler(500)
|
@app.errorhandler(500)
|
||||||
|
|||||||
@@ -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_SECRET = os.environ.get("JWT_SECRET") or "dev_secret_change_me"
|
||||||
JWT_EXPIRY_DAYS = int(os.environ.get("JWT_EXPIRY_DAYS") or 7)
|
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 -----------------------------------------------------------------
|
# --- Server -----------------------------------------------------------------
|
||||||
# BACKEND_PORT wins over PORT: `PORT` is set by many dev tools and PaaS
|
# 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
|
# runtimes for the *frontend*, and letting it through made Flask seize the
|
||||||
|
|||||||
@@ -3,14 +3,43 @@ import uuid
|
|||||||
|
|
||||||
from flask import Blueprint, request, g, jsonify
|
from flask import Blueprint, request, g, jsonify
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
from auth import hash_password, verify_password, sign_token, require_auth
|
from auth import hash_password, verify_password, sign_token, require_auth
|
||||||
|
import config
|
||||||
from db import execute, query_one
|
from db import execute, query_one
|
||||||
|
|
||||||
bp = Blueprint("auth", __name__)
|
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"])
|
@bp.route("/register", methods=["POST"])
|
||||||
def register():
|
def register():
|
||||||
|
if not registration_open():
|
||||||
|
return jsonify({"error": "注册已关闭:本实例已有账号"}), 403
|
||||||
|
|
||||||
data = request.get_json(silent=True) or {}
|
data = request.get_json(silent=True) or {}
|
||||||
email = (data.get("email") or "").strip()
|
email = (data.get("email") or "").strip()
|
||||||
garmin_email = (data.get("garminEmail") or "").strip()
|
garmin_email = (data.get("garminEmail") or "").strip()
|
||||||
|
|||||||
@@ -48,6 +48,10 @@ _AI_ENV_VARS = (
|
|||||||
def _isolate_ai_env(monkeypatch):
|
def _isolate_ai_env(monkeypatch):
|
||||||
for var in _AI_ENV_VARS:
|
for var in _AI_ENV_VARS:
|
||||||
monkeypatch.delenv(var, raising=False)
|
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
|
@pytest.fixture
|
||||||
|
|||||||
87
backend/tests/test_registration_policy.py
Normal file
87
backend/tests/test_registration_policy.py
Normal file
@@ -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
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { apiClient, errorMessage } from '../services/api';
|
import { apiClient, errorMessage } from '../services/api';
|
||||||
import './Login.css';
|
import './Login.css';
|
||||||
@@ -10,6 +10,16 @@ function Login() {
|
|||||||
const [activeTab, setActiveTab] = useState<TabType>('login');
|
const [activeTab, setActiveTab] = useState<TabType>('login');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string>('');
|
const [error, setError] = useState<string>('');
|
||||||
|
// 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
|
// Login form
|
||||||
const [loginEmail, setLoginEmail] = useState('');
|
const [loginEmail, setLoginEmail] = useState('');
|
||||||
@@ -112,6 +122,7 @@ function Login() {
|
|||||||
>
|
>
|
||||||
登录
|
登录
|
||||||
</button>
|
</button>
|
||||||
|
{canRegister && (
|
||||||
<button
|
<button
|
||||||
className={`tab-button ${activeTab === 'register' ? 'active' : ''}`}
|
className={`tab-button ${activeTab === 'register' ? 'active' : ''}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -121,6 +132,7 @@ function Login() {
|
|||||||
>
|
>
|
||||||
注册
|
注册
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <div className="error-message">{error}</div>}
|
{error && <div className="error-message">{error}</div>}
|
||||||
@@ -159,7 +171,7 @@ function Login() {
|
|||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'register' && (
|
{activeTab === 'register' && canRegister && (
|
||||||
<form onSubmit={handleRegister} className="login-form">
|
<form onSubmit={handleRegister} className="login-form">
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="reg-email">邮箱</label>
|
<label htmlFor="reg-email">邮箱</label>
|
||||||
|
|||||||
@@ -138,6 +138,14 @@ class ApiClient {
|
|||||||
return data;
|
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) {
|
async login(email: string, password: string) {
|
||||||
const { data } = await this.client.post<AuthResponse>('/auth/login', {
|
const { data } = await this.client.post<AuthResponse>('/auth/login', {
|
||||||
email,
|
email,
|
||||||
|
|||||||
Reference in New Issue
Block a user