Security hardening, async TeachChat, rate limit UX, unthrottle
Security:
- admin.py: move api_key from URL query params to POST body (litellm/models, tts/voices) — prevents key logging
- admin.py: sanitize exception messages in voice discovery — log internally, return generic errors
- teach.py: log LLM errors server-side, show friendly message to user
- nextcloud.py: normalize path with posixpath.normpath to prevent ../ traversal
- auth.py: check_rate_limit now accepts user param — admins/moderators/unthrottled always exempt
Performance:
- teach.py: make /chat endpoint async, use litellm.acompletion() — no longer blocks a uvicorn thread per request
Features:
- users: add is_unthrottled column (DB migration in setup_pgvector)
- admin.py: PUT /users/{id}/unthrottle endpoint
- AdminPage.jsx: Unlimited/Throttle toggle button per user, shows "unlimited" badge
- Rate limit messages improved with user-friendly context
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
7f1f14537b
commit
4c48a5cf94
9 changed files with 110 additions and 37 deletions
|
|
@ -228,7 +228,8 @@ def setup_pgvector():
|
|||
UNIQUE(user_id, question_id)
|
||||
)
|
||||
"""))
|
||||
conn.commit()
|
||||
# Unthrottle flag for users (exempt from AI/TTS rate limits)
|
||||
conn.execute(text("ALTER TABLE users ADD COLUMN IF NOT EXISTS is_unthrottled INTEGER DEFAULT 0"))
|
||||
conn.commit()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ class User(Base):
|
|||
hashed_password = Column(String, nullable=False)
|
||||
name = Column(String, nullable=False)
|
||||
role = Column(String, default="user") # admin, moderator, user
|
||||
is_unthrottled = Column(Integer, default=0) # 1 = exempt from rate limits
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
documents = relationship("PDFDocument", back_populates="user")
|
||||
|
|
|
|||
|
|
@ -46,6 +46,23 @@ def update_user_role(
|
|||
return user
|
||||
|
||||
|
||||
@router.put("/users/{user_id}/unthrottle", response_model=UserResponse)
|
||||
def set_user_unthrottle(
|
||||
user_id: int,
|
||||
data: dict,
|
||||
db: Session = Depends(get_db),
|
||||
admin: User = Depends(require_admin),
|
||||
):
|
||||
"""Set or clear the unthrottle flag for a user — exempt from AI/TTS rate limits."""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
user.is_unthrottled = 1 if data.get("unthrottled") else 0
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@router.post("/users", response_model=UserResponse)
|
||||
def create_user(
|
||||
user_data: UserCreate,
|
||||
|
|
@ -99,34 +116,41 @@ def list_available_models(
|
|||
return result
|
||||
|
||||
|
||||
@router.get("/litellm/models")
|
||||
class LiteLLMSearchRequest(BaseModel):
|
||||
api_key: str | None = None
|
||||
api_base: str | None = None
|
||||
|
||||
|
||||
@router.post("/litellm/models")
|
||||
def search_litellm_models(
|
||||
api_key: str | None = None,
|
||||
api_base: str | None = None,
|
||||
data: LiteLLMSearchRequest,
|
||||
admin: User = Depends(require_admin),
|
||||
):
|
||||
"""Query available models from LiteLLM proxy or OpenAI-compatible API."""
|
||||
base = (api_base or settings.LITELLM_API_BASE or "").rstrip("/")
|
||||
key = api_key or settings.LITELLM_API_KEY
|
||||
import logging
|
||||
log = logging.getLogger(__name__)
|
||||
base = (data.api_base or settings.LITELLM_API_BASE or "").rstrip("/")
|
||||
key = data.api_key or settings.LITELLM_API_KEY
|
||||
|
||||
if base:
|
||||
try:
|
||||
headers = {"Authorization": f"Bearer {key}"} if key else {}
|
||||
resp = httpx.get(f"{base}/v1/models", headers=headers, timeout=10)
|
||||
resp = httpx.post(f"{base}/v1/models", headers=headers, timeout=10) if False else \
|
||||
httpx.get(f"{base}/v1/models", headers=headers, timeout=10)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
models = sorted([m["id"] for m in data.get("data", [])])
|
||||
models = sorted([m["id"] for m in resp.json().get("data", [])])
|
||||
return {"models": models, "source": base}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Failed to query models API: {e}")
|
||||
log.warning(f"LiteLLM model search failed: {e}")
|
||||
raise HTTPException(status_code=400, detail="Failed to query models API. Check the API base URL and key.")
|
||||
|
||||
# Fall back to LiteLLM's built-in model list
|
||||
try:
|
||||
import litellm
|
||||
models = sorted(litellm.utils.get_valid_models())
|
||||
return {"models": models, "source": "litellm-builtin"}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get LiteLLM models: {e}")
|
||||
log.warning(f"LiteLLM builtin model list failed: {e}")
|
||||
raise HTTPException(status_code=500, detail="Failed to retrieve LiteLLM built-in model list.")
|
||||
|
||||
|
||||
@router.get("/models", response_model=list[AIModelConfigResponse])
|
||||
|
|
@ -238,14 +262,23 @@ def test_model(
|
|||
raise HTTPException(status_code=502, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/tts/voices")
|
||||
class TTSVoiceSearchRequest(BaseModel):
|
||||
provider: str
|
||||
api_key: str | None = None
|
||||
region: str | None = None
|
||||
|
||||
|
||||
@router.post("/tts/voices")
|
||||
def search_tts_voices(
|
||||
provider: str,
|
||||
api_key: str | None = None,
|
||||
region: str | None = None,
|
||||
data: TTSVoiceSearchRequest,
|
||||
admin: User = Depends(require_admin),
|
||||
):
|
||||
"""Discover available TTS voices from ElevenLabs, AWS Polly, or return OpenAI hardcoded list."""
|
||||
import logging
|
||||
log = logging.getLogger(__name__)
|
||||
provider = data.provider
|
||||
api_key = data.api_key
|
||||
region = data.region
|
||||
|
||||
if provider == "elevenlabs":
|
||||
key = api_key or settings.ELEVENLABS_API_KEY
|
||||
|
|
@ -270,7 +303,8 @@ def search_tts_voices(
|
|||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"ElevenLabs API error: {e}")
|
||||
log.warning(f"ElevenLabs voice discovery failed: {e}")
|
||||
raise HTTPException(status_code=400, detail="ElevenLabs API error — check your API key and try again.")
|
||||
|
||||
elif provider == "polly":
|
||||
access_key = api_key or settings.AWS_ACCESS_KEY_ID
|
||||
|
|
@ -302,7 +336,8 @@ def search_tts_voices(
|
|||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"AWS Polly error: {e}")
|
||||
log.warning(f"AWS Polly voice discovery failed: {e}")
|
||||
raise HTTPException(status_code=400, detail="AWS Polly error — check your credentials and region.")
|
||||
|
||||
elif provider == "openai":
|
||||
voices = []
|
||||
|
|
|
|||
|
|
@ -31,8 +31,10 @@ class NCRequest(BaseModel):
|
|||
|
||||
|
||||
def _dav_url(server: str, username: str, path: str) -> str:
|
||||
import posixpath
|
||||
base = server.rstrip("/")
|
||||
p = path.lstrip("/")
|
||||
# Normalize to collapse any ../ sequences before building URL
|
||||
p = posixpath.normpath("/" + path).lstrip("/")
|
||||
return f"{base}/remote.php/dav/files/{username}/{p}"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -124,18 +124,19 @@ def list_teach_models(
|
|||
|
||||
|
||||
@router.post("/chat")
|
||||
def chat(
|
||||
async def chat(
|
||||
req: ChatRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Send a message to the teach AI with full question context."""
|
||||
# Rate limit: 30 AI chat messages per user per 10 minutes
|
||||
# Rate limit: 30 AI chat messages per user per 10 minutes (admins/unthrottled users exempt)
|
||||
check_rate_limit(
|
||||
key=f"teach_chat:{current_user.id}",
|
||||
max_calls=30,
|
||||
window_seconds=600,
|
||||
detail="Too many AI chat messages. Please wait a few minutes before continuing.",
|
||||
detail="You've sent too many messages to the AI tutor. Please wait a few minutes before continuing. If you need this limit raised, contact an admin.",
|
||||
user=current_user,
|
||||
)
|
||||
model_info = _get_teach_model(db, req.model_id)
|
||||
if not model_info:
|
||||
|
|
@ -175,8 +176,10 @@ def chat(
|
|||
kwargs["api_key"] = settings.LITELLM_API_KEY
|
||||
if settings.LITELLM_API_BASE:
|
||||
kwargs["api_base"] = settings.LITELLM_API_BASE
|
||||
response = litellm.completion(**kwargs)
|
||||
response = await litellm.acompletion(**kwargs)
|
||||
reply = response.choices[0].message.content.strip()
|
||||
return {"reply": reply}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=502, detail=f"AI model error: {str(e)}")
|
||||
import logging
|
||||
logging.getLogger(__name__).error(f"TeachChat error for user {current_user.id} model {model_id}: {e}")
|
||||
raise HTTPException(status_code=502, detail="The AI tutor is temporarily unavailable. Please try again in a moment.")
|
||||
|
|
|
|||
|
|
@ -51,12 +51,13 @@ def text_to_speech(
|
|||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Convert text to speech using configured or user-selected TTS model."""
|
||||
# Rate limit: 60 TTS requests per user per hour
|
||||
# Rate limit: 60 TTS requests per user per hour (admins/unthrottled users exempt)
|
||||
check_rate_limit(
|
||||
key=f"tts_speak:{current_user.id}",
|
||||
max_calls=60,
|
||||
window_seconds=3600,
|
||||
detail="TTS rate limit reached. You can generate up to 60 audio clips per hour.",
|
||||
detail="You've reached the audio limit (60 clips/hour). The limit resets automatically — try again shortly. Contact an admin if you need this raised.",
|
||||
user=current_user,
|
||||
)
|
||||
if not request.text.strip():
|
||||
raise HTTPException(status_code=400, detail="Text cannot be empty")
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ class UserResponse(BaseModel):
|
|||
email: str
|
||||
name: str
|
||||
role: str
|
||||
is_unthrottled: int = 0
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
|
|
|
|||
|
|
@ -119,8 +119,13 @@ class TokenRefreshMiddleware(BaseHTTPMiddleware):
|
|||
return response
|
||||
|
||||
|
||||
def check_rate_limit(key: str, max_calls: int, window_seconds: int, detail: str):
|
||||
"""Generic Redis-backed rate limiter. Raises 429 if limit exceeded. Degrades gracefully if Redis is down."""
|
||||
def check_rate_limit(key: str, max_calls: int, window_seconds: int, detail: str, user=None):
|
||||
"""Generic Redis-backed rate limiter. Admins and unthrottled users are always exempt.
|
||||
Degrades gracefully if Redis is down."""
|
||||
# Admins, moderators, and explicitly unthrottled users bypass all rate limits
|
||||
if user is not None:
|
||||
if getattr(user, 'role', '') in ('admin', 'moderator') or getattr(user, 'is_unthrottled', 0):
|
||||
return
|
||||
try:
|
||||
import redis as redis_lib
|
||||
r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True, socket_connect_timeout=1)
|
||||
|
|
|
|||
|
|
@ -87,6 +87,16 @@ export default function AdminPage() {
|
|||
}
|
||||
}
|
||||
|
||||
const toggleUnthrottle = async (userId, currentVal) => {
|
||||
try {
|
||||
await api.put(`/admin/users/${userId}/unthrottle`, { unthrottled: !currentVal })
|
||||
setSuccess(currentVal ? 'Rate limits restored for user' : 'Rate limits removed for user')
|
||||
loadData()
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.detail || 'Failed to update throttle setting')
|
||||
}
|
||||
}
|
||||
|
||||
const createModel = async (e) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
|
|
@ -151,10 +161,10 @@ export default function AdminPage() {
|
|||
setSearchResults([])
|
||||
setSearchLoading(true)
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
if (searchApiKey) params.set('api_key', searchApiKey)
|
||||
if (searchApiBase) params.set('api_base', searchApiBase)
|
||||
const res = await api.get(`/admin/litellm/models?${params}`)
|
||||
const res = await api.post('/admin/litellm/models', {
|
||||
api_key: searchApiKey || null,
|
||||
api_base: searchApiBase || null,
|
||||
})
|
||||
setSearchResults(res.data.models)
|
||||
} catch (err) {
|
||||
setSearchError(err.response?.data?.detail || 'Failed to query models')
|
||||
|
|
@ -168,7 +178,7 @@ export default function AdminPage() {
|
|||
setEmbedSearchResults([])
|
||||
setEmbedSearchLoading(true)
|
||||
try {
|
||||
const res = await api.get('/admin/litellm/models')
|
||||
const res = await api.post('/admin/litellm/models', {})
|
||||
const all = res.data.models || []
|
||||
// Filter to likely embedding models
|
||||
setEmbedSearchResults(all)
|
||||
|
|
@ -231,9 +241,10 @@ export default function AdminPage() {
|
|||
setTtsVoices([])
|
||||
setTtsVoicesLoading(true)
|
||||
try {
|
||||
const params = new URLSearchParams({ provider: ttsProvider })
|
||||
if (ttsSearchKey) params.set('api_key', ttsSearchKey)
|
||||
const res = await api.get(`/admin/tts/voices?${params}`)
|
||||
const res = await api.post('/admin/tts/voices', {
|
||||
provider: ttsProvider,
|
||||
api_key: ttsSearchKey || null,
|
||||
})
|
||||
setTtsVoices(res.data.voices)
|
||||
} catch (err) {
|
||||
setTtsVoicesError(err.response?.data?.detail || 'Failed to fetch voices')
|
||||
|
|
@ -578,10 +589,15 @@ export default function AdminPage() {
|
|||
<strong>{u.name}</strong>
|
||||
<div style={{ fontSize: '0.85rem', color: '#64748b' }}>{u.email} · joined {new Date(u.created_at).toLocaleDateString()}</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<span className={`badge ${u.role === 'admin' ? 'badge-error' : u.role === 'moderator' ? 'badge-processing' : 'badge-ready'}`}>
|
||||
{u.role}
|
||||
</span>
|
||||
{u.is_unthrottled ? (
|
||||
<span style={{ fontSize: '0.72rem', padding: '2px 7px', borderRadius: 10, background: '#fef9c3', color: '#92400e', fontWeight: 600 }}>
|
||||
unlimited
|
||||
</span>
|
||||
) : null}
|
||||
<select
|
||||
value={u.role}
|
||||
onChange={e => updateRole(u.id, e.target.value)}
|
||||
|
|
@ -591,6 +607,14 @@ export default function AdminPage() {
|
|||
<option value="moderator">moderator</option>
|
||||
<option value="admin">admin</option>
|
||||
</select>
|
||||
<button
|
||||
className="btn btn-secondary btn-sm"
|
||||
title={u.is_unthrottled ? 'Restore rate limits' : 'Remove rate limits for this user'}
|
||||
onClick={() => toggleUnthrottle(u.id, u.is_unthrottled)}
|
||||
style={{ fontSize: '0.75rem', padding: '3px 8px' }}
|
||||
>
|
||||
{u.is_unthrottled ? '🔒 Throttle' : '🚀 Unlimited'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
|
|
|||
Loading…
Reference in a new issue