Files
app-service/backend/app/api/v1/files.py
matteoscrugli 8c4a555b88 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>
2025-12-17 22:27:32 +01:00

378 lines
10 KiB
Python

"""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)
}