Add comprehensive backend features and mobile UI improvements
Backend: - Add 2FA authentication with TOTP support - Add API keys management system - Add audit logging for security events - Add file upload/management system - Add notifications system with preferences - Add session management - Add webhooks integration - Add analytics endpoints - Add export functionality - Add password policy enforcement - Add new database migrations for core tables Frontend: - Add module position system (top/bottom sidebar sections) - Add search and notifications module configuration tabs - Add mobile logo replacing hamburger menu - Center page title absolutely when no tabs present - Align sidebar footer toggles with navigation items - Add lighter icon color in dark theme for mobile - Add API keys management page - Add notifications page with context - Add admin analytics and audit logs pages 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
377
backend/app/api/v1/files.py
Normal file
377
backend/app/api/v1/files.py
Normal file
@@ -0,0 +1,377 @@
|
||||
"""File storage endpoints."""
|
||||
|
||||
import json
|
||||
from typing import Any, List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Query
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.dependencies import get_db, get_current_user, get_current_superuser
|
||||
from app.models.user import User
|
||||
from app import crud
|
||||
from app.schemas.file import (
|
||||
StoredFile as StoredFileSchema,
|
||||
FileCreate,
|
||||
FileUpdate,
|
||||
FileUploadResponse,
|
||||
FileListResponse,
|
||||
ALLOWED_CONTENT_TYPES,
|
||||
MAX_FILE_SIZE,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def file_to_schema(db_file) -> dict:
|
||||
"""Convert a StoredFile model to schema dict."""
|
||||
return {
|
||||
"id": db_file.id,
|
||||
"original_filename": db_file.original_filename,
|
||||
"content_type": db_file.content_type,
|
||||
"size_bytes": db_file.size_bytes,
|
||||
"storage_type": db_file.storage_type,
|
||||
"description": db_file.description,
|
||||
"tags": json.loads(db_file.tags) if db_file.tags else None,
|
||||
"is_public": db_file.is_public,
|
||||
"uploaded_by": db_file.uploaded_by,
|
||||
"file_hash": db_file.file_hash,
|
||||
"created_at": db_file.created_at,
|
||||
"updated_at": db_file.updated_at,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/upload", response_model=FileUploadResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def upload_file(
|
||||
file: UploadFile = File(...),
|
||||
description: Optional[str] = None,
|
||||
tags: Optional[str] = None, # Comma-separated tags
|
||||
is_public: bool = False,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Upload a file.
|
||||
Returns file metadata with download URL.
|
||||
"""
|
||||
# Read file content
|
||||
content = await file.read()
|
||||
size = len(content)
|
||||
|
||||
# Validate upload
|
||||
is_valid, error = crud.file_storage.validate_upload(
|
||||
content_type=file.content_type,
|
||||
size_bytes=size
|
||||
)
|
||||
if not is_valid:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=error
|
||||
)
|
||||
|
||||
# Parse tags
|
||||
tag_list = None
|
||||
if tags:
|
||||
tag_list = [t.strip() for t in tags.split(",") if t.strip()]
|
||||
|
||||
# Create file metadata
|
||||
metadata = FileCreate(
|
||||
description=description,
|
||||
tags=tag_list,
|
||||
is_public=is_public
|
||||
)
|
||||
|
||||
# Reset file position for reading
|
||||
await file.seek(0)
|
||||
|
||||
# Save file
|
||||
import io
|
||||
stored_file = crud.file_storage.create(
|
||||
db,
|
||||
file=io.BytesIO(content),
|
||||
filename=file.filename,
|
||||
content_type=file.content_type,
|
||||
size_bytes=size,
|
||||
uploaded_by=current_user.id,
|
||||
metadata=metadata
|
||||
)
|
||||
|
||||
# Log the action
|
||||
crud.audit_log.log_action(
|
||||
db,
|
||||
user_id=current_user.id,
|
||||
username=current_user.username,
|
||||
action="upload",
|
||||
resource_type="file",
|
||||
resource_id=stored_file.id,
|
||||
details={"filename": file.filename, "size": size},
|
||||
status="success"
|
||||
)
|
||||
|
||||
return {
|
||||
"id": stored_file.id,
|
||||
"original_filename": stored_file.original_filename,
|
||||
"content_type": stored_file.content_type,
|
||||
"size_bytes": stored_file.size_bytes,
|
||||
"download_url": f"/api/v1/files/{stored_file.id}/download"
|
||||
}
|
||||
|
||||
|
||||
@router.get("/", response_model=FileListResponse)
|
||||
def list_files(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
mine_only: bool = False,
|
||||
is_public: Optional[bool] = None,
|
||||
content_type: Optional[str] = None,
|
||||
):
|
||||
"""
|
||||
List files with pagination and filtering.
|
||||
Regular users can only see their own files and public files.
|
||||
Superusers can see all files.
|
||||
"""
|
||||
skip = (page - 1) * page_size
|
||||
|
||||
# Filter by ownership for non-superusers
|
||||
if not current_user.is_superuser:
|
||||
if mine_only:
|
||||
uploaded_by = current_user.id
|
||||
is_public = None
|
||||
else:
|
||||
# Show user's files and public files
|
||||
own_files = crud.file_storage.get_multi(
|
||||
db,
|
||||
skip=0,
|
||||
limit=1000, # Get all for filtering
|
||||
uploaded_by=current_user.id
|
||||
)
|
||||
public_files = crud.file_storage.get_multi(
|
||||
db,
|
||||
skip=0,
|
||||
limit=1000,
|
||||
is_public=True
|
||||
)
|
||||
# Combine and deduplicate
|
||||
all_files = {f.id: f for f in own_files}
|
||||
all_files.update({f.id: f for f in public_files})
|
||||
files_list = list(all_files.values())
|
||||
# Sort by created_at desc
|
||||
files_list.sort(key=lambda x: x.created_at, reverse=True)
|
||||
# Paginate
|
||||
total = len(files_list)
|
||||
files = files_list[skip:skip + page_size]
|
||||
|
||||
return {
|
||||
"files": [file_to_schema(f) for f in files],
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size
|
||||
}
|
||||
|
||||
uploaded_by = current_user.id if mine_only else None
|
||||
else:
|
||||
uploaded_by = current_user.id if mine_only else None
|
||||
|
||||
files = crud.file_storage.get_multi(
|
||||
db,
|
||||
skip=skip,
|
||||
limit=page_size,
|
||||
uploaded_by=uploaded_by,
|
||||
is_public=is_public,
|
||||
content_type=content_type
|
||||
)
|
||||
|
||||
total = crud.file_storage.count(
|
||||
db,
|
||||
uploaded_by=uploaded_by,
|
||||
is_public=is_public
|
||||
)
|
||||
|
||||
return {
|
||||
"files": [file_to_schema(f) for f in files],
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{file_id}", response_model=StoredFileSchema)
|
||||
def get_file(
|
||||
file_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Get file metadata.
|
||||
Users can only access their own files or public files.
|
||||
"""
|
||||
stored_file = crud.file_storage.get(db, id=file_id)
|
||||
if not stored_file:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="File not found"
|
||||
)
|
||||
|
||||
# Check access
|
||||
if not stored_file.is_public and stored_file.uploaded_by != current_user.id and not current_user.is_superuser:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied"
|
||||
)
|
||||
|
||||
return file_to_schema(stored_file)
|
||||
|
||||
|
||||
@router.get("/{file_id}/download")
|
||||
def download_file(
|
||||
file_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Download a file.
|
||||
Users can only download their own files or public files.
|
||||
"""
|
||||
stored_file = crud.file_storage.get(db, id=file_id)
|
||||
if not stored_file:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="File not found"
|
||||
)
|
||||
|
||||
# Check access
|
||||
if not stored_file.is_public and stored_file.uploaded_by != current_user.id and not current_user.is_superuser:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied"
|
||||
)
|
||||
|
||||
# Get file path
|
||||
file_path = crud.file_storage.get_file_content(stored_file)
|
||||
if not file_path:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="File content not found"
|
||||
)
|
||||
|
||||
return FileResponse(
|
||||
path=file_path,
|
||||
filename=stored_file.original_filename,
|
||||
media_type=stored_file.content_type
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{file_id}", response_model=StoredFileSchema)
|
||||
def update_file(
|
||||
file_id: str,
|
||||
file_in: FileUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Update file metadata.
|
||||
Users can only update their own files.
|
||||
"""
|
||||
stored_file = crud.file_storage.get(db, id=file_id)
|
||||
if not stored_file:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="File not found"
|
||||
)
|
||||
|
||||
# Check ownership
|
||||
if stored_file.uploaded_by != current_user.id and not current_user.is_superuser:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied"
|
||||
)
|
||||
|
||||
updated = crud.file_storage.update(db, db_obj=stored_file, obj_in=file_in)
|
||||
|
||||
# Log the action
|
||||
crud.audit_log.log_action(
|
||||
db,
|
||||
user_id=current_user.id,
|
||||
username=current_user.username,
|
||||
action="update",
|
||||
resource_type="file",
|
||||
resource_id=file_id,
|
||||
details={"filename": stored_file.original_filename},
|
||||
status="success"
|
||||
)
|
||||
|
||||
return file_to_schema(updated)
|
||||
|
||||
|
||||
@router.delete("/{file_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_file(
|
||||
file_id: str,
|
||||
permanent: bool = False,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Delete a file.
|
||||
Users can only delete their own files.
|
||||
Superusers can permanently delete files.
|
||||
"""
|
||||
stored_file = crud.file_storage.get(db, id=file_id)
|
||||
if not stored_file:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="File not found"
|
||||
)
|
||||
|
||||
# Check ownership
|
||||
if stored_file.uploaded_by != current_user.id and not current_user.is_superuser:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied"
|
||||
)
|
||||
|
||||
# Log the action
|
||||
crud.audit_log.log_action(
|
||||
db,
|
||||
user_id=current_user.id,
|
||||
username=current_user.username,
|
||||
action="delete",
|
||||
resource_type="file",
|
||||
resource_id=file_id,
|
||||
details={
|
||||
"filename": stored_file.original_filename,
|
||||
"permanent": permanent
|
||||
},
|
||||
status="success"
|
||||
)
|
||||
|
||||
if permanent and current_user.is_superuser:
|
||||
crud.file_storage.hard_delete(db, id=file_id)
|
||||
else:
|
||||
crud.file_storage.soft_delete(db, id=file_id)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/allowed-types/", response_model=List[str])
|
||||
def get_allowed_types(
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Get list of allowed file types for upload.
|
||||
"""
|
||||
return ALLOWED_CONTENT_TYPES
|
||||
|
||||
|
||||
@router.get("/max-size/", response_model=dict)
|
||||
def get_max_size(
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Get maximum allowed file size.
|
||||
"""
|
||||
return {
|
||||
"max_size_bytes": MAX_FILE_SIZE,
|
||||
"max_size_mb": MAX_FILE_SIZE / (1024 * 1024)
|
||||
}
|
||||
Reference in New Issue
Block a user