Files
PROJECT-CONTORL/backend/app/api/attachments/router.py
beabigegg 3bdc6ff1c9 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>
2026-01-10 22:13:43 +08:00

701 lines
25 KiB
Python

import uuid
import logging
from datetime import datetime
from io import BytesIO
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Request
from fastapi.responses import FileResponse, Response
from sqlalchemy.orm import Session
from typing import Optional
from app.core.database import get_db
from app.core.rate_limiter import limiter
from app.core.config import settings
from app.middleware.auth import get_current_user, check_task_access, check_task_edit_access
from app.models import User, Task, Project, Attachment, AttachmentVersion, EncryptionKey, AuditAction
from app.schemas.attachment import (
AttachmentResponse, AttachmentListResponse, AttachmentDetailResponse,
AttachmentVersionResponse, VersionHistoryResponse
)
from app.services.file_storage_service import file_storage_service
from app.services.audit_service import AuditService
from app.services.watermark_service import watermark_service
from app.services.encryption_service import (
encryption_service,
MasterKeyNotConfiguredError,
DecryptionError,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["attachments"])
def get_task_with_access_check(db: Session, task_id: str, current_user: User, require_edit: bool = False) -> Task:
"""Get task and verify access permissions."""
task = db.query(Task).filter(Task.id == task_id).first()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
# Get project for access check
project = db.query(Project).filter(Project.id == task.project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
# Check access permission
if not check_task_access(current_user, task, project):
raise HTTPException(status_code=403, detail="Access denied to this task")
# Check edit permission if required
if require_edit and not check_task_edit_access(current_user, task, project):
raise HTTPException(status_code=403, detail="Edit access denied to this task")
return task
def get_attachment_with_access_check(
db: Session, attachment_id: str, current_user: User, require_edit: bool = False
) -> Attachment:
"""Get attachment and verify access permissions."""
attachment = db.query(Attachment).filter(
Attachment.id == attachment_id,
Attachment.is_deleted == False
).first()
if not attachment:
raise HTTPException(status_code=404, detail="Attachment not found")
# Get task and project for access check
task = db.query(Task).filter(Task.id == attachment.task_id).first()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
project = db.query(Project).filter(Project.id == task.project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
# Check access permission
if not check_task_access(current_user, task, project):
raise HTTPException(status_code=403, detail="Access denied to this attachment")
# Check edit permission if required
if require_edit and not check_task_edit_access(current_user, task, project):
raise HTTPException(status_code=403, detail="Edit access denied to this attachment")
return attachment
def attachment_to_response(attachment: Attachment) -> AttachmentResponse:
"""Convert Attachment model to response."""
return AttachmentResponse(
id=attachment.id,
task_id=attachment.task_id,
filename=attachment.filename,
original_filename=attachment.original_filename,
mime_type=attachment.mime_type,
file_size=attachment.file_size,
current_version=attachment.current_version,
is_encrypted=attachment.is_encrypted,
uploaded_by=attachment.uploaded_by,
uploader_name=attachment.uploader.name if attachment.uploader else None,
created_at=attachment.created_at,
updated_at=attachment.updated_at
)
def version_to_response(version: AttachmentVersion) -> AttachmentVersionResponse:
"""Convert AttachmentVersion model to response."""
return AttachmentVersionResponse(
id=version.id,
version=version.version,
file_size=version.file_size,
checksum=version.checksum,
uploaded_by=version.uploaded_by,
uploader_name=version.uploader.name if version.uploader else None,
created_at=version.created_at
)
def should_encrypt_file(project: Project, db: Session) -> tuple[bool, Optional[EncryptionKey]]:
"""
Determine if a file should be encrypted based on project security level.
Returns:
Tuple of (should_encrypt, encryption_key)
Raises:
HTTPException: If project is confidential but encryption is not available
"""
# Only encrypt for confidential projects
if project.security_level != "confidential":
return False, None
# Check if encryption is available
if not encryption_service.is_encryption_available():
logger.error(
f"Project {project.id} is confidential but encryption is not configured. "
"Rejecting file upload to maintain security."
)
raise HTTPException(
status_code=400,
detail="Confidential project requires encryption. Please configure ENCRYPTION_MASTER_KEY environment variable."
)
# Get active encryption key
active_key = db.query(EncryptionKey).filter(
EncryptionKey.is_active == True
).first()
if not active_key:
logger.error(
f"Project {project.id} is confidential but no active encryption key exists. "
"Rejecting file upload to maintain security."
)
raise HTTPException(
status_code=400,
detail="Confidential project requires encryption. Please create an active encryption key first."
)
return True, active_key
@router.post("/tasks/{task_id}/attachments", response_model=AttachmentResponse)
@limiter.limit(settings.RATE_LIMIT_SENSITIVE)
async def upload_attachment(
request: Request,
task_id: str,
file: UploadFile = File(...),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Upload a file attachment to a task.
For confidential projects, files are automatically encrypted using AES-256-GCM.
Rate limited: 20 requests per minute (sensitive tier).
"""
task = get_task_with_access_check(db, task_id, current_user, require_edit=True)
# Get project to check security level
project = db.query(Project).filter(Project.id == task.project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
# Determine if encryption is needed
should_encrypt, encryption_key = should_encrypt_file(project, db)
# Check if attachment with same filename exists (for versioning)
existing = db.query(Attachment).filter(
Attachment.task_id == task_id,
Attachment.original_filename == file.filename,
Attachment.is_deleted == False
).first()
if existing:
# Create new version for existing attachment
new_version = existing.current_version + 1
if should_encrypt and encryption_key:
# Read and encrypt file content
file_content = await file.read()
await file.seek(0)
try:
# Decrypt the encryption key
raw_key = encryption_service.decrypt_key(encryption_key.key_data)
# Encrypt the file
encrypted_content = encryption_service.encrypt_bytes(file_content, raw_key)
# Create a new UploadFile-like object with encrypted content
encrypted_file = BytesIO(encrypted_content)
encrypted_file.seek(0)
# Save encrypted file using a modified approach
file_path, _, checksum = await file_storage_service.save_file(
file=file,
project_id=task.project_id,
task_id=task_id,
attachment_id=existing.id,
version=new_version
)
# Overwrite with encrypted content
with open(file_path, "wb") as f:
f.write(encrypted_content)
file_size = len(encrypted_content)
# Update existing attachment with encryption info
existing.is_encrypted = True
existing.encryption_key_id = encryption_key.id
# Audit log for encryption
AuditService.log_event(
db=db,
event_type="attachment.encrypt",
resource_type="attachment",
action=AuditAction.UPDATE,
user_id=current_user.id,
resource_id=existing.id,
changes=[
{"field": "is_encrypted", "old_value": False, "new_value": True},
{"field": "encryption_key_id", "old_value": None, "new_value": encryption_key.id},
],
request_metadata=getattr(request.state, "audit_metadata", None),
)
except Exception as e:
logger.error(f"Failed to encrypt file for attachment {existing.id}: {e}")
raise HTTPException(
status_code=500,
detail="Failed to encrypt file. Please try again."
)
else:
# Save file without encryption
file_path, file_size, checksum = await file_storage_service.save_file(
file=file,
project_id=task.project_id,
task_id=task_id,
attachment_id=existing.id,
version=new_version
)
# Create version record
version = AttachmentVersion(
id=str(uuid.uuid4()),
attachment_id=existing.id,
version=new_version,
file_path=file_path,
file_size=file_size,
checksum=checksum,
uploaded_by=current_user.id
)
db.add(version)
# Update attachment
existing.current_version = new_version
existing.file_size = file_size
existing.updated_at = version.created_at
# Audit log
AuditService.log_event(
db=db,
event_type="attachment.upload",
resource_type="attachment",
action=AuditAction.UPDATE,
user_id=current_user.id,
resource_id=existing.id,
changes=[{"field": "version", "old_value": new_version - 1, "new_value": new_version}],
request_metadata=getattr(request.state, "audit_metadata", None)
)
db.commit()
db.refresh(existing)
return attachment_to_response(existing)
# Create new attachment
attachment_id = str(uuid.uuid4())
is_encrypted = False
encryption_key_id = None
if should_encrypt and encryption_key:
# Read and encrypt file content
file_content = await file.read()
await file.seek(0)
try:
# Decrypt the encryption key
raw_key = encryption_service.decrypt_key(encryption_key.key_data)
# Encrypt the file
encrypted_content = encryption_service.encrypt_bytes(file_content, raw_key)
# Save file first to get path
file_path, _, checksum = await file_storage_service.save_file(
file=file,
project_id=task.project_id,
task_id=task_id,
attachment_id=attachment_id,
version=1
)
# Overwrite with encrypted content
with open(file_path, "wb") as f:
f.write(encrypted_content)
file_size = len(encrypted_content)
is_encrypted = True
encryption_key_id = encryption_key.id
except Exception as e:
logger.error(f"Failed to encrypt new file: {e}")
raise HTTPException(
status_code=500,
detail="Failed to encrypt file. Please try again."
)
else:
# Save file without encryption
file_path, file_size, checksum = await file_storage_service.save_file(
file=file,
project_id=task.project_id,
task_id=task_id,
attachment_id=attachment_id,
version=1
)
# Get mime type from file storage validation
extension = file_storage_service.get_extension(file.filename or "")
mime_type = file.content_type or "application/octet-stream"
# Create attachment record
attachment = Attachment(
id=attachment_id,
task_id=task_id,
filename=file.filename or "unnamed",
original_filename=file.filename or "unnamed",
mime_type=mime_type,
file_size=file_size,
current_version=1,
is_encrypted=is_encrypted,
encryption_key_id=encryption_key_id,
uploaded_by=current_user.id
)
db.add(attachment)
# Create version record
version = AttachmentVersion(
id=str(uuid.uuid4()),
attachment_id=attachment_id,
version=1,
file_path=file_path,
file_size=file_size,
checksum=checksum,
uploaded_by=current_user.id
)
db.add(version)
# Audit log
changes = [{"field": "filename", "old_value": None, "new_value": attachment.filename}]
if is_encrypted:
changes.append({"field": "is_encrypted", "old_value": None, "new_value": True})
AuditService.log_event(
db=db,
event_type="attachment.upload",
resource_type="attachment",
action=AuditAction.CREATE,
user_id=current_user.id,
resource_id=attachment.id,
changes=changes,
request_metadata=getattr(request.state, "audit_metadata", None)
)
db.commit()
db.refresh(attachment)
return attachment_to_response(attachment)
@router.get("/tasks/{task_id}/attachments", response_model=AttachmentListResponse)
async def list_task_attachments(
task_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""List all attachments for a task."""
task = get_task_with_access_check(db, task_id, current_user, require_edit=False)
attachments = db.query(Attachment).filter(
Attachment.task_id == task_id,
Attachment.is_deleted == False
).order_by(Attachment.created_at.desc()).all()
return AttachmentListResponse(
attachments=[attachment_to_response(a) for a in attachments],
total=len(attachments)
)
@router.get("/attachments/{attachment_id}", response_model=AttachmentDetailResponse)
async def get_attachment(
attachment_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get attachment details with version history."""
attachment = get_attachment_with_access_check(db, attachment_id, current_user, require_edit=False)
versions = db.query(AttachmentVersion).filter(
AttachmentVersion.attachment_id == attachment_id
).order_by(AttachmentVersion.version.desc()).all()
return AttachmentDetailResponse(
id=attachment.id,
task_id=attachment.task_id,
filename=attachment.filename,
original_filename=attachment.original_filename,
mime_type=attachment.mime_type,
file_size=attachment.file_size,
current_version=attachment.current_version,
is_encrypted=attachment.is_encrypted,
uploaded_by=attachment.uploaded_by,
uploader_name=attachment.uploader.name if attachment.uploader else None,
created_at=attachment.created_at,
updated_at=attachment.updated_at,
versions=[version_to_response(v) for v in versions]
)
@router.get("/attachments/{attachment_id}/download")
async def download_attachment(
attachment_id: str,
version: Optional[int] = None,
request: Request = None,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Download an attachment file with dynamic watermark.
For encrypted files, the file is automatically decrypted before returning.
"""
attachment = get_attachment_with_access_check(db, attachment_id, current_user, require_edit=False)
# Get version to download
target_version = version or attachment.current_version
version_record = db.query(AttachmentVersion).filter(
AttachmentVersion.attachment_id == attachment_id,
AttachmentVersion.version == target_version
).first()
if not version_record:
raise HTTPException(status_code=404, detail=f"Version {target_version} not found")
# Get file path
file_path = file_storage_service.get_file_by_path(version_record.file_path)
if not file_path:
raise HTTPException(status_code=404, detail="File not found on disk")
# Audit log
download_time = datetime.now()
AuditService.log_event(
db=db,
event_type="attachment.download",
resource_type="attachment",
action=AuditAction.UPDATE, # Using UPDATE as there's no DOWNLOAD action
user_id=current_user.id,
resource_id=attachment.id,
changes=[{"field": "downloaded_version", "old_value": None, "new_value": target_version}],
request_metadata=getattr(request.state, "audit_metadata", None) if request else None
)
db.commit()
# Read file content
with open(file_path, "rb") as f:
file_bytes = f.read()
# Decrypt if encrypted
if attachment.is_encrypted:
if not attachment.encryption_key_id:
raise HTTPException(
status_code=500,
detail="Encrypted file is missing encryption key reference"
)
encryption_key = db.query(EncryptionKey).filter(
EncryptionKey.id == attachment.encryption_key_id
).first()
if not encryption_key:
raise HTTPException(
status_code=500,
detail="Encryption key not found for this file"
)
try:
# Decrypt the encryption key
raw_key = encryption_service.decrypt_key(encryption_key.key_data)
# Decrypt the file
file_bytes = encryption_service.decrypt_bytes(file_bytes, raw_key)
# Audit log for decryption
AuditService.log_event(
db=db,
event_type="attachment.decrypt",
resource_type="attachment",
action=AuditAction.UPDATE,
user_id=current_user.id,
resource_id=attachment.id,
changes=[{"field": "decrypted_for_download", "old_value": None, "new_value": True}],
request_metadata=getattr(request.state, "audit_metadata", None) if request else None,
)
db.commit()
except DecryptionError as e:
logger.error(f"Failed to decrypt attachment {attachment_id}: {e}")
raise HTTPException(
status_code=500,
detail="Failed to decrypt file. The file may be corrupted."
)
except MasterKeyNotConfiguredError:
raise HTTPException(
status_code=500,
detail="Encryption is not configured. Cannot decrypt file."
)
except Exception as e:
logger.error(f"Unexpected error decrypting attachment {attachment_id}: {e}")
raise HTTPException(
status_code=500,
detail="Failed to decrypt file."
)
# Check if watermark should be applied
mime_type = attachment.mime_type or ""
if watermark_service.supports_watermark(mime_type):
try:
# Apply watermark based on file type
if watermark_service.is_supported_image(mime_type):
watermarked_bytes, output_format = watermark_service.add_image_watermark(
image_bytes=file_bytes,
user_name=current_user.name,
employee_id=current_user.employee_id,
download_time=download_time
)
# Update mime type based on output format
output_mime_type = f"image/{output_format}"
# Update filename extension if format changed
original_filename = attachment.original_filename
if output_format == "png" and not original_filename.lower().endswith(".png"):
original_filename = original_filename.rsplit(".", 1)[0] + ".png"
return Response(
content=watermarked_bytes,
media_type=output_mime_type,
headers={
"Content-Disposition": f'attachment; filename="{original_filename}"'
}
)
elif watermark_service.is_supported_pdf(mime_type):
watermarked_bytes = watermark_service.add_pdf_watermark(
pdf_bytes=file_bytes,
user_name=current_user.name,
employee_id=current_user.employee_id,
download_time=download_time
)
return Response(
content=watermarked_bytes,
media_type="application/pdf",
headers={
"Content-Disposition": f'attachment; filename="{attachment.original_filename}"'
}
)
except Exception as e:
# If watermarking fails, log the error but still return the file
logger.warning(
f"Watermarking failed for attachment {attachment_id}: {str(e)}. "
"Returning file without watermark."
)
# Return file (decrypted if needed, without watermark for unsupported types)
return Response(
content=file_bytes,
media_type=attachment.mime_type,
headers={
"Content-Disposition": f'attachment; filename="{attachment.original_filename}"'
}
)
@router.delete("/attachments/{attachment_id}")
async def delete_attachment(
attachment_id: str,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Soft delete an attachment."""
attachment = get_attachment_with_access_check(db, attachment_id, current_user, require_edit=True)
# Soft delete
attachment.is_deleted = True
# Audit log
AuditService.log_event(
db=db,
event_type="attachment.delete",
resource_type="attachment",
action=AuditAction.DELETE,
user_id=current_user.id,
resource_id=attachment.id,
changes=[{"field": "is_deleted", "old_value": False, "new_value": True}],
request_metadata=getattr(request.state, "audit_metadata", None)
)
db.commit()
return {"detail": "Attachment deleted", "id": attachment_id}
@router.get("/attachments/{attachment_id}/versions", response_model=VersionHistoryResponse)
async def get_version_history(
attachment_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get version history for an attachment."""
attachment = get_attachment_with_access_check(db, attachment_id, current_user, require_edit=False)
versions = db.query(AttachmentVersion).filter(
AttachmentVersion.attachment_id == attachment_id
).order_by(AttachmentVersion.version.desc()).all()
return VersionHistoryResponse(
attachment_id=attachment.id,
filename=attachment.filename,
versions=[version_to_response(v) for v in versions],
total=len(versions)
)
@router.post("/attachments/{attachment_id}/restore/{version}")
async def restore_version(
attachment_id: str,
version: int,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Restore an attachment to a specific version."""
attachment = get_attachment_with_access_check(db, attachment_id, current_user, require_edit=True)
version_record = db.query(AttachmentVersion).filter(
AttachmentVersion.attachment_id == attachment_id,
AttachmentVersion.version == version
).first()
if not version_record:
raise HTTPException(status_code=404, detail=f"Version {version} not found")
old_version = attachment.current_version
attachment.current_version = version
attachment.file_size = version_record.file_size
# Audit log
AuditService.log_event(
db=db,
event_type="attachment.restore",
resource_type="attachment",
action=AuditAction.RESTORE,
user_id=current_user.id,
resource_id=attachment.id,
changes=[{"field": "current_version", "old_value": old_version, "new_value": version}],
request_metadata=getattr(request.state, "audit_metadata", None)
)
db.commit()
return {"detail": f"Restored to version {version}", "current_version": version}