feat: implement 8 OpenSpec proposals for security, reliability, and UX improvements

## Security Enhancements (P0)
- Add input validation with max_length and numeric range constraints
- Implement WebSocket token authentication via first message
- Add path traversal prevention in file storage service

## Permission Enhancements (P0)
- Add project member management for cross-department access
- Implement is_department_manager flag for workload visibility

## Cycle Detection (P0)
- Add DFS-based cycle detection for task dependencies
- Add formula field circular reference detection
- Display user-friendly cycle path visualization

## Concurrency & Reliability (P1)
- Implement optimistic locking with version field (409 Conflict on mismatch)
- Add trigger retry mechanism with exponential backoff (1s, 2s, 4s)
- Implement cascade restore for soft-deleted tasks

## Rate Limiting (P1)
- Add tiered rate limits: standard (60/min), sensitive (20/min), heavy (5/min)
- Apply rate limits to tasks, reports, attachments, and comments

## Frontend Improvements (P1)
- Add responsive sidebar with hamburger menu for mobile
- Improve touch-friendly UI with proper tap target sizes
- Complete i18n translations for all components

## Backend Reliability (P2)
- Configure database connection pool (size=10, overflow=20)
- Add Redis fallback mechanism with message queue
- Add blocker check before task deletion

## API Enhancements (P3)
- Add standardized response wrapper utility
- Add /health/ready and /health/live endpoints
- Implement project templates with status/field copying

## Tests Added
- test_input_validation.py - Schema and path traversal tests
- test_concurrency_reliability.py - Optimistic locking and retry tests
- test_backend_reliability.py - Connection pool and Redis tests
- test_api_enhancements.py - Health check and template tests

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
beabigegg
2026-01-10 22:13:43 +08:00
parent 96210c7ad4
commit 3bdc6ff1c9
106 changed files with 9704 additions and 429 deletions

View File

@@ -1,6 +1,7 @@
import asyncio
import logging
import time
from typing import Optional
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query
from sqlalchemy.orm import Session
@@ -19,6 +20,9 @@ router = APIRouter(tags=["websocket"])
PING_INTERVAL = 60.0 # Send ping after this many seconds of no messages
PONG_TIMEOUT = 30.0 # Disconnect if no pong received within this time after ping
# Authentication timeout (10 seconds)
AUTH_TIMEOUT = 10.0
async def get_user_from_token(token: str) -> tuple[str | None, User | None]:
"""Validate token and return user_id and user object."""
@@ -47,6 +51,56 @@ async def get_user_from_token(token: str) -> tuple[str | None, User | None]:
db.close()
async def authenticate_websocket(
websocket: WebSocket,
query_token: Optional[str] = None
) -> tuple[str | None, User | None]:
"""
Authenticate WebSocket connection.
Supports two authentication methods:
1. First message authentication (preferred, more secure)
- Client sends: {"type": "auth", "token": "<jwt_token>"}
2. Query parameter authentication (deprecated, for backward compatibility)
- Client connects with: ?token=<jwt_token>
Returns (user_id, user) if authenticated, (None, None) otherwise.
"""
# If token provided via query parameter (backward compatibility)
if query_token:
logger.warning(
"WebSocket authentication via query parameter is deprecated. "
"Please use first-message authentication for better security."
)
return await get_user_from_token(query_token)
# Wait for authentication message with timeout
try:
data = await asyncio.wait_for(
websocket.receive_json(),
timeout=AUTH_TIMEOUT
)
msg_type = data.get("type")
if msg_type != "auth":
logger.warning("Expected 'auth' message type, got: %s", msg_type)
return None, None
token = data.get("token")
if not token:
logger.warning("No token provided in auth message")
return None, None
return await get_user_from_token(token)
except asyncio.TimeoutError:
logger.warning("WebSocket authentication timeout after %.1f seconds", AUTH_TIMEOUT)
return None, None
except Exception as e:
logger.error("Error during WebSocket authentication: %s", e)
return None, None
async def get_unread_notifications(user_id: str) -> list[dict]:
"""Query all unread notifications for a user."""
db = SessionLocal()
@@ -90,14 +144,22 @@ async def get_unread_count(user_id: str) -> int:
@router.websocket("/ws/notifications")
async def websocket_notifications(
websocket: WebSocket,
token: str = Query(..., description="JWT token for authentication"),
token: Optional[str] = Query(None, description="JWT token (deprecated, use first-message auth)"),
):
"""
WebSocket endpoint for real-time notifications.
Connect with: ws://host/ws/notifications?token=<jwt_token>
Authentication methods (in order of preference):
1. First message authentication (recommended):
- Connect without token: ws://host/ws/notifications
- Send: {"type": "auth", "token": "<jwt_token>"}
- Must authenticate within 10 seconds or connection will be closed
2. Query parameter (deprecated, for backward compatibility):
- Connect with: ws://host/ws/notifications?token=<jwt_token>
Messages sent by server:
- {"type": "auth_required"} - Sent when waiting for auth message
- {"type": "connected", "data": {"user_id": "...", "message": "..."}} - Connection success
- {"type": "unread_sync", "data": {"notifications": [...], "unread_count": N}} - All unread on connect
- {"type": "notification", "data": {...}} - New notification
@@ -106,9 +168,18 @@ async def websocket_notifications(
- {"type": "pong"} - Response to client ping
Messages accepted from client:
- {"type": "auth", "token": "..."} - Authentication (must be first message if no query token)
- {"type": "ping"} - Client keepalive ping
"""
user_id, user = await get_user_from_token(token)
# Accept WebSocket connection first
await websocket.accept()
# If no query token, notify client that auth is required
if not token:
await websocket.send_json({"type": "auth_required"})
# Authenticate
user_id, user = await authenticate_websocket(websocket, token)
if user_id is None:
await websocket.close(code=4001, reason="Invalid or expired token")
@@ -263,14 +334,22 @@ async def verify_project_access(user_id: str, project_id: str) -> tuple[bool, Pr
async def websocket_project_sync(
websocket: WebSocket,
project_id: str,
token: str = Query(..., description="JWT token for authentication"),
token: Optional[str] = Query(None, description="JWT token (deprecated, use first-message auth)"),
):
"""
WebSocket endpoint for project task real-time sync.
Connect with: ws://host/ws/projects/{project_id}?token=<jwt_token>
Authentication methods (in order of preference):
1. First message authentication (recommended):
- Connect without token: ws://host/ws/projects/{project_id}
- Send: {"type": "auth", "token": "<jwt_token>"}
- Must authenticate within 10 seconds or connection will be closed
2. Query parameter (deprecated, for backward compatibility):
- Connect with: ws://host/ws/projects/{project_id}?token=<jwt_token>
Messages sent by server:
- {"type": "auth_required"} - Sent when waiting for auth message
- {"type": "connected", "data": {"project_id": "...", "user_id": "..."}}
- {"type": "task_created", "data": {...}, "triggered_by": "..."}
- {"type": "task_updated", "data": {...}, "triggered_by": "..."}
@@ -280,10 +359,18 @@ async def websocket_project_sync(
- {"type": "ping"} / {"type": "pong"}
Messages accepted from client:
- {"type": "auth", "token": "..."} - Authentication (must be first message if no query token)
- {"type": "ping"} - Client keepalive ping
"""
# Accept WebSocket connection first
await websocket.accept()
# If no query token, notify client that auth is required
if not token:
await websocket.send_json({"type": "auth_required"})
# Authenticate user
user_id, user = await get_user_from_token(token)
user_id, user = await authenticate_websocket(websocket, token)
if user_id is None:
await websocket.close(code=4001, reason="Invalid or expired token")
@@ -300,8 +387,7 @@ async def websocket_project_sync(
await websocket.close(code=4004, reason="Project not found")
return
# Accept connection and join project room
await websocket.accept()
# Join project room
await manager.join_project(websocket, user_id, project_id)
# Create Redis subscriber for project task events