-from fastapi import FastAPI, HTTPException, Response
+from fastapi import FastAPI, HTTPException, Response, Request
+from fastapi.responses import JSONResponse
from fastapi.middleware.cors import CORSMiddleware
import sqlite3
import os
import datetime
+import hmac
+import hashlib
+import base64
import urllib.request
import urllib.parse
import urllib.error
allow_headers=["*"],
)
+# --- 登录鉴权中间件:除 /api/auth/login 外,所有 /api/* 必须带有效 token ---
+# token 来源:Authorization: Bearer <token>(axios 请求)或 ?token=<token>(<img>/<Audio> 媒体)
+@app.middleware("http")
+async def auth_guard(request: Request, call_next):
+ path = request.url.path
+ if path.startswith("/api/") and path != "/api/auth/login":
+ auth_header = request.headers.get("Authorization", "")
+ token = auth_header[7:] if auth_header.startswith("Bearer ") else request.query_params.get("token")
+ if not verify_token(token):
+ return JSONResponse(status_code=401, content={"detail": "未登录或登录已过期"})
+ return await call_next(request)
+
# --- 路径自动修复逻辑 ---
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
DATABASE_PATH = os.path.join(BASE_DIR, "a", "bcd.db")
if not os.path.exists(DATABASE_PATH):
DATABASE_PATH = os.path.join(BASE_DIR, "db", "bcd.db")
+# --- 简单登录鉴权(单用户;账密可用环境变量覆盖,默认 Simba / Simba+0322)---
+# 前端登录后持 token;所有 /api/*(除登录接口)必须带有效 token,否则 401。
+# 改密码:设置环境变量 WORDS_PASSWORD 或告诉我直接在后端改默认值。
+AUTH_USERNAME = os.environ.get("WORDS_USERNAME", "Simba")
+AUTH_PASSWORD = os.environ.get("WORDS_PASSWORD", "Simba+0322")
+AUTH_SECRET = os.environ.get("WORDS_AUTH_SECRET", "words-fixed-secret-change-in-prod-7f3a9c")
+TOKEN_TTL_SECONDS = 30 * 24 * 3600 # 30 天
+
+
+def _b64url(b: bytes) -> str:
+ return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
+
+
+def _b64url_decode(s: str) -> bytes:
+ s = s + "=" * (-len(s) % 4)
+ return base64.urlsafe_b64decode(s.encode())
+
+
+def create_token(username: str) -> str:
+ """签发 HMAC 签名 token:payload(username:expiry).sig,secret 固定故重启不失效。"""
+ expiry = int(datetime.datetime.now().timestamp()) + TOKEN_TTL_SECONDS
+ payload = f"{username}:{expiry}".encode()
+ sig = hmac.new(AUTH_SECRET.encode(), payload, hashlib.sha256).digest()
+ return f"{_b64url(payload)}.{_b64url(sig)}"
+
+
+def verify_token(token: str):
+ """校验 token 签名与过期,返回 username 或 None。"""
+ if not token:
+ return None
+ try:
+ payload_b64, sig_b64 = token.split(".")
+ payload = _b64url_decode(payload_b64)
+ sig = _b64url_decode(sig_b64)
+ expected = hmac.new(AUTH_SECRET.encode(), payload, hashlib.sha256).digest()
+ if not hmac.compare_digest(sig, expected):
+ return None
+ username, expiry = payload.decode().split(":")
+ if int(expiry) < datetime.datetime.now().timestamp():
+ return None
+ return username
+ except Exception:
+ return None
+
# --- 数据模型 ---
class HomeData(BaseModel):
pictures_count: int
def coerce_group_id(cls, v):
return str(v)
+
+class LoginRequest(BaseModel):
+ username: str
+ password: str
+
class SyncPushRequest(BaseModel):
events: List[SyncEvent]
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
+@app.post("/api/auth/login")
+async def auth_login(data: LoginRequest):
+ # 单用户:账密匹配才签发 token。错误统一 401,不透露具体原因。
+ if data.username == AUTH_USERNAME and data.password == AUTH_PASSWORD:
+ return {"status": "success", "token": create_token(data.username)}
+ raise HTTPException(status_code=401, detail="用户名或密码错误")
+
+
@app.get("/")
async def root():
return {"message": "English Lexicon API is running"}
-import React from 'react';
+import React, { useState } from 'react';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import Home from './components/Home';
import StudyMode from './components/StudyMode';
import GroupView from './components/GroupView';
import StudyCalendar from './components/StudyCalendar';
import SyncNotifications from './components/SyncNotifications';
+import Login from './components/Login';
+import { isLoggedIn } from './auth';
function App() {
+ const [loggedIn, setLoggedIn] = useState(isLoggedIn());
+ // 未登录:只显示登录页,应用其余部分完全不加载
+ if (!loggedIn) {
+ return <Login onLogin={() => setLoggedIn(true)} />;
+ }
return (
<Router>
<SyncNotifications />
import axios from 'axios';
+import { getToken, clearToken } from './auth';
// 后端 API 基址。PWA 离线方案中所有组件统一走此出口,便于 Service Worker 拦截与未来切换。
// 默认走跨域 IP:端口(:55 现有 build 行为不变);HTTPS 同源 build 通过 REACT_APP_API_BASE="" 覆盖为相对路径。
timeout: 8000,
});
+// 登录鉴权拦截器:自动带 token;遇 401(未登录/过期)清 token 并跳回登录页
+http.interceptors.request.use((config) => {
+ const t = getToken();
+ if (t) config.headers.Authorization = `Bearer ${t}`;
+ return config;
+});
+http.interceptors.response.use(
+ (res) => res,
+ (err) => {
+ if (err.response && err.response.status === 401) {
+ clearToken();
+ window.location.href = '/';
+ }
+ return Promise.reject(err);
+ }
+);
+
// 生成中国时间(Asia/Shanghai, UTC+8)时间戳 "YYYY-MM-DD HH:MM:SS"。
// 注意: 必须用固定 +8h 偏移,不能用 toISOString()(那是 UTC),否则
// 凌晨学习会被记到前一天(时区 bug)。与服务器 Asia/Shanghai 保持一致。
headers: { 'Content-Type': 'multipart/form-data' },
}),
// 图片与 TTS 走 <img src>/new Audio(),不走 axios;SW 仍能拦截缓存
- pictureUrl: (picId) => `${API_BASE}/api/picture/${picId}`,
+ // 需带 ?token= 以通过后端鉴权中间件(不登录无法取媒体)
+ pictureUrl: (picId) => `${API_BASE}/api/picture/${picId}?token=${getToken()}`,
// 走查询参数 w=,避免词内字面 "/"(如 "in the/a form of")与 URL 路径分隔符冲突导致 404
- ttsUrl: (word) => `${API_BASE}/api/tts?w=${encodeURIComponent(word)}`,
+ ttsUrl: (word) => `${API_BASE}/api/tts?w=${encodeURIComponent(word)}&token=${getToken()}`,
};
--- /dev/null
+// 简单登录态管理(单用户)。token 存 localStorage(保持登录,30 天过期)。
+// 后端在 main.py 校验账密并签发 HMAC token;所有 /api/* 需携带该 token。
+import { API_BASE } from './api';
+
+const TOKEN_KEY = 'words_auth_token';
+
+export function getToken() {
+ return localStorage.getItem(TOKEN_KEY) || '';
+}
+
+export function setToken(t) {
+ localStorage.setItem(TOKEN_KEY, t);
+}
+
+export function clearToken() {
+ localStorage.removeItem(TOKEN_KEY);
+}
+
+export function isLoggedIn() {
+ return !!getToken();
+}
+
+export async function login(username, password) {
+ const r = await fetch(`${API_BASE}/api/auth/login`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ username, password }),
+ });
+ if (!r.ok) {
+ let msg = '登录失败';
+ try { msg = (await r.json()).detail || msg; } catch (e) {}
+ throw new Error(msg);
+ }
+ const data = await r.json();
+ if (data && data.token) {
+ setToken(data.token);
+ return true;
+ }
+ throw new Error('未返回 token');
+}
+
+export function logout() {
+ clearToken();
+}
+
padding: 8px 16px;
font-size: 13px;
}
+ }
+
+ /* 头部右侧:统计信息 + 退出按钮 */
+ .header-right {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+ }
+ .logout-btn {
+ border: 1px solid #c9d4e3;
+ background: #fff;
+ color: #4a6fa5;
+ border-radius: 8px;
+ padding: 6px 14px;
+ font-size: 13px;
+ cursor: pointer;
+ }
+ .logout-btn:hover {
+ background: #eef3fb;
}
\ No newline at end of file
import { useOnlineStatus } from '../hooks/useOnlineStatus';
import { getAllWordsFromIDB, getAllProgress, isInitialized } from '../db';
import { triggerAutoSync, subscribeSyncStatus } from '../sync';
+import { logout } from '../auth';
import './Home.css';
function Home() {
<div className="main-app">
<header className="header">
<h1>单词学习进度记录</h1>
- <p className="stats-info">
- 图片总数: {stats.pictures_count} | 单词总数: {stats.words_count}
- </p>
+ <div className="header-right">
+ <p className="stats-info">
+ 图片总数: {stats.pictures_count} | 单词总数: {stats.words_count}
+ </p>
+ <button className="logout-btn" onClick={() => { logout(); window.location.reload(); }}>退出</button>
+ </div>
</header>
{needInit && (
--- /dev/null
+.login-wrap {
+ min-height: 100vh;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: #f3f5f9;
+ padding: 16px;
+}
+
+.login-box {
+ width: 320px;
+ max-width: 100%;
+ background: #fff;
+ border-radius: 12px;
+ box-shadow: 0 6px 24px rgba(0, 0, 0, 0.08);
+ padding: 28px 24px;
+ display: flex;
+ flex-direction: column;
+}
+
+.login-title {
+ margin: 0;
+ font-size: 22px;
+ text-align: center;
+ color: #1f2d3d;
+}
+
+.login-sub {
+ margin: 4px 0 20px;
+ text-align: center;
+ color: #8a94a6;
+ font-size: 13px;
+}
+
+.login-label {
+ font-size: 13px;
+ color: #5a6677;
+ margin-bottom: 6px;
+}
+
+.login-input {
+ height: 40px;
+ border: 1px solid #d8dee9;
+ border-radius: 8px;
+ padding: 0 12px;
+ font-size: 14px;
+ margin-bottom: 14px;
+ outline: none;
+}
+
+.login-input:focus {
+ border-color: #4a90e2;
+}
+
+.login-error {
+ background: #fdecea;
+ color: #c0392b;
+ font-size: 13px;
+ padding: 8px 10px;
+ border-radius: 6px;
+ margin-bottom: 12px;
+}
+
+.login-btn {
+ height: 42px;
+ border: none;
+ border-radius: 8px;
+ background: #4a90e2;
+ color: #fff;
+ font-size: 15px;
+ cursor: pointer;
+ margin-top: 4px;
+}
+
+.login-btn:disabled {
+ opacity: 0.6;
+ cursor: default;
+}
--- /dev/null
+import React, { useState } from 'react';
+import { login } from '../auth';
+import './Login.css';
+
+export default function Login({ onLogin }) {
+ const [username, setUsername] = useState('');
+ const [password, setPassword] = useState('');
+ const [error, setError] = useState('');
+ const [loading, setLoading] = useState(false);
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ setError('');
+ setLoading(true);
+ try {
+ await login(username.trim(), password);
+ setLoading(false);
+ onLogin();
+ } catch (err) {
+ setLoading(false);
+ setError(err.message || '登录失败');
+ }
+ };
+
+ return (
+ <div className="login-wrap">
+ <form className="login-box" onSubmit={handleSubmit}>
+ <h1 className="login-title">单词学习</h1>
+ <p className="login-sub">请登录后使用</p>
+ <label className="login-label">用户名</label>
+ <input
+ className="login-input"
+ type="text"
+ value={username}
+ autoFocus
+ onChange={(e) => setUsername(e.target.value)}
+ placeholder="用户名"
+ />
+ <label className="login-label">密码</label>
+ <input
+ className="login-input"
+ type="password"
+ value={password}
+ onChange={(e) => setPassword(e.target.value)}
+ placeholder="密码"
+ />
+ {error && <div className="login-error">{error}</div>}
+ <button className="login-btn" type="submit" disabled={loading}>
+ {loading ? '登录中…' : '登录'}
+ </button>
+ </form>
+ </div>
+ );
+}
import { useState, useEffect } from 'react';
import { API_BASE } from '../api';
+import { getToken } from '../auth';
// 返回当前是否联网
// navigator.onLine + online/offline 事件在 iOS Safari 不可靠 (飞行模式切换不触发)
try {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), 2500);
- const r = await fetch(`${API_BASE}/api/home`, { signal: ctrl.signal });
+ const headers = {};
+ const tok = getToken();
+ if (tok) headers.Authorization = `Bearer ${tok}`;
+ const r = await fetch(`${API_BASE}/api/home`, { signal: ctrl.signal, headers });
clearTimeout(t);
setIsOnline(r.ok);
} catch (e) {
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
+import { isLoggedIn } from './auth';
import reportWebVitals from './reportWebVitals';
const root = ReactDOM.createRoot(document.getElementById('root'));
navigator.serviceWorker.register('/sw.js').catch(err => console.error('SW registration failed:', err));
// SW 接管页面后, 再触发一次同步+预取, 确保预取请求被 SW 拦截并写入缓存
navigator.serviceWorker.ready.then(() => {
- import('./sync').then(m => m.triggerAutoSync()).catch(() => {});
+ if (isLoggedIn()) {
+ import('./sync').then(m => m.triggerAutoSync()).catch(() => {});
+ }
}).catch(() => {});
});
}