Document secure secrets and prune unused assets

This commit is contained in:
Simon
2025-12-13 01:35:56 +08:00
parent c4bb32b163
commit 1adf30d476
28 changed files with 157 additions and 1451 deletions

View File

@@ -86,6 +86,10 @@ async def init_default_config(db: AsyncSession):
"ai_provider": os.getenv("AI_PROVIDER", "openai"),
}
admin_password = os.getenv("ADMIN_PASSWORD")
if not admin_password or len(admin_password) < 12:
raise ValueError("ADMIN_PASSWORD must be set and at least 12 characters long")
for key, value in default_configs.items():
result = await db.execute(select(SystemConfig).where(SystemConfig.key == key))
existing = result.scalar_one_or_none()
@@ -99,15 +103,32 @@ async def init_default_config(db: AsyncSession):
result = await db.execute(select(User).where(User.username == "admin"))
admin = result.scalar_one_or_none()
default_admin_id = admin.id if admin else None
if not admin:
admin_user = User(
username="admin",
hashed_password=pwd_context.hash("admin123"), # Change this password!
hashed_password=pwd_context.hash(admin_password),
is_admin=True
)
db.add(admin_user)
print("✅ Created default admin user (username: admin, password: admin123)")
print("⚠️ IMPORTANT: Please change the admin password immediately!")
await db.commit()
await db.refresh(admin_user)
default_admin_id = admin_user.id
print("✅ Created default admin user (username: admin)")
else:
await db.commit()
if default_admin_id is not None:
result = await db.execute(
select(SystemConfig).where(SystemConfig.key == "default_admin_id")
)
default_admin_config = result.scalar_one_or_none()
if not default_admin_config:
db.add(SystemConfig(key="default_admin_id", value=str(default_admin_id)))
elif default_admin_config.value != str(default_admin_id):
default_admin_config.value = str(default_admin_id)
await db.commit()

View File

@@ -4,18 +4,28 @@ QQuiz FastAPI Application - 单容器模式(前后端整合)
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import HTMLResponse, FileResponse
from fastapi.responses import HTMLResponse, FileResponse, JSONResponse
from contextlib import asynccontextmanager
import os
from pathlib import Path
from dotenv import load_dotenv
from slowapi.errors import RateLimitExceeded
from slowapi.middleware import SlowAPIMiddleware
from database import init_db, init_default_config, get_db_context
from rate_limit import limiter
# Load environment variables
load_dotenv()
async def rate_limit_exceeded_handler(request: Request, exc: RateLimitExceeded):
return JSONResponse(
status_code=429,
content={"detail": "Rate limit exceeded. Please try again later."}
)
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan events"""
@@ -50,6 +60,10 @@ app = FastAPI(
lifespan=lifespan
)
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, rate_limit_exceeded_handler)
app.add_middleware(SlowAPIMiddleware)
# Configure CORS
cors_origins = os.getenv("CORS_ORIGINS", "http://localhost:3000").split(",")
app.add_middleware(

10
backend/rate_limit.py Normal file
View File

@@ -0,0 +1,10 @@
"""Rate limiting utilities for the FastAPI application."""
from slowapi import Limiter
from slowapi.util import get_remote_address
limiter = Limiter(key_func=get_remote_address)
__all__ = ["limiter"]

View File

@@ -12,6 +12,7 @@ python-multipart==0.0.6
passlib==1.7.4
bcrypt==4.0.1
python-jose[cryptography]==3.3.0
python-magic==0.4.27
aiofiles==23.2.1
httpx==0.26.0
openai==1.10.0
@@ -19,3 +20,4 @@ anthropic==0.8.1
python-docx==1.1.0
PyPDF2==3.0.1
openpyxl==3.1.2
slowapi==0.1.9

View File

@@ -24,6 +24,18 @@ router = APIRouter()
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
async def get_default_admin_id(db: AsyncSession) -> Optional[int]:
result = await db.execute(
select(SystemConfig).where(SystemConfig.key == "default_admin_id")
)
config = result.scalar_one_or_none()
if config and config.value.isdigit():
return int(config.value)
return None
@router.get("/config", response_model=SystemConfigResponse)
async def get_system_config(
current_admin: User = Depends(get_current_admin_user),
@@ -203,8 +215,10 @@ async def update_user(
detail="User not found"
)
protected_admin_id = await get_default_admin_id(db)
# 不允许修改默认管理员的管理员状态
if user.username == "admin" and user_data.is_admin is not None:
if protected_admin_id and user.id == protected_admin_id and user_data.is_admin is not None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot modify default admin user's admin status"
@@ -240,8 +254,10 @@ async def delete_user(
detail="User not found"
)
protected_admin_id = await get_default_admin_id(db)
# 不允许删除默认管理员
if user.username == "admin":
if protected_admin_id and user.id == protected_admin_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot delete default admin user"

View File

@@ -5,14 +5,17 @@ from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from datetime import timedelta
import logging
from database import get_db
from models import User, SystemConfig
from schemas import UserCreate, UserLogin, Token, UserResponse
from utils import hash_password, verify_password, create_access_token
from rate_limit import limiter
from services.auth_service import get_current_user
router = APIRouter()
logger = logging.getLogger(__name__)
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
@@ -61,6 +64,7 @@ async def register(
@router.post("/login", response_model=Token)
@limiter.limit("5/minute")
async def login(
user_data: UserLogin,
db: AsyncSession = Depends(get_db)
@@ -86,8 +90,7 @@ async def login(
data={"sub": str(user.id)} # JWT 'sub' must be a string
)
print(f"Login successful: user={user.username}, id={user.id}")
print(f"🔑 Generated token (first 50 chars): {access_token[:50]}...")
logger.info("Login successful", extra={"user_id": user.id})
return {
"access_token": access_token,

View File

@@ -10,6 +10,7 @@ from datetime import datetime, timedelta
import os
import aiofiles
import json
import magic
from database import get_db
from models import User, Exam, Question, ExamStatus, SystemConfig
@@ -24,8 +25,57 @@ from services.config_service import load_llm_config
from services.progress_service import progress_service
from utils import is_allowed_file, calculate_content_hash
from dedup_utils import is_duplicate_question
from rate_limit import limiter
router = APIRouter()
ALLOWED_MIME_TYPES = {
"text/plain",
"application/pdf",
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
}
async def validate_upload_file(file: UploadFile) -> None:
"""Validate uploaded file by extension and MIME type."""
if not file.filename or not is_allowed_file(file.filename):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid file type. Allowed: txt, pdf, doc, docx, xlsx, xls",
)
try:
sample = await file.read(2048)
except Exception:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Could not read uploaded file for validation",
)
if not sample:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Uploaded file is empty",
)
try:
mime_type = magic.from_buffer(sample, mime=True)
except Exception:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Could not determine file type",
)
finally:
file.file.seek(0)
if mime_type not in ALLOWED_MIME_TYPES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid file content type",
)
async def check_upload_limits(user_id: int, file_size: int, db: AsyncSession):
@@ -485,6 +535,7 @@ async def async_parse_and_save(
@router.post("/create", response_model=ExamUploadResponse, status_code=status.HTTP_201_CREATED)
@limiter.limit("10/minute")
async def create_exam_with_upload(
background_tasks: BackgroundTasks,
title: str = Form(...),
@@ -498,11 +549,7 @@ async def create_exam_with_upload(
"""
# Validate file
if not file.filename or not is_allowed_file(file.filename):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid file type. Allowed: txt, pdf, doc, docx, xlsx, xls"
)
await validate_upload_file(file)
# Read file content
file_content = await file.read()
@@ -538,6 +585,7 @@ async def create_exam_with_upload(
@router.post("/{exam_id}/append", response_model=ExamUploadResponse)
@limiter.limit("10/minute")
async def append_document_to_exam(
exam_id: int,
background_tasks: BackgroundTasks,
@@ -572,11 +620,7 @@ async def append_document_to_exam(
)
# Validate file
if not file.filename or not is_allowed_file(file.filename):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid file type. Allowed: txt, pdf, doc, docx, xlsx, xls"
)
await validate_upload_file(file)
# Read file content
file_content = await file.read()

View File

@@ -13,7 +13,10 @@ import os
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
# JWT settings
SECRET_KEY = os.getenv("SECRET_KEY", "your-secret-key-change-this")
SECRET_KEY = os.getenv("SECRET_KEY")
if not SECRET_KEY or len(SECRET_KEY) < 32:
raise ValueError("SECRET_KEY must be set and at least 32 characters long")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 days