|
from fastapi import APIRouter, Depends, UploadFile, File as FastAPIFile, HTTPException, status, Response, Form
|
|
from fastapi.responses import StreamingResponse
|
|
from typing import List, Optional
|
|
import mimetypes
|
|
import hashlib
|
|
import os
|
|
from datetime import datetime
|
|
from pydantic import BaseModel
|
|
|
|
from ..auth import get_current_user
|
|
from ..models import User, File, Folder
|
|
from ..schemas import FileOut
|
|
from ..storage import storage_manager
|
|
from ..settings import settings
|
|
from ..activity import log_activity
|
|
from ..thumbnails import generate_thumbnail, delete_thumbnail
|
|
|
|
router = APIRouter(
|
|
prefix="/files",
|
|
tags=["files"],
|
|
)
|
|
|
|
class FileMove(BaseModel):
|
|
target_folder_id: Optional[int] = None
|
|
|
|
class FileRename(BaseModel):
|
|
new_name: str
|
|
|
|
class FileCopy(BaseModel):
|
|
target_folder_id: Optional[int] = None
|
|
|
|
class BatchFileOperation(BaseModel):
|
|
file_ids: List[int]
|
|
operation: str # e.g., "delete", "star", "unstar", "move", "copy"
|
|
|
|
class BatchMoveCopyPayload(BaseModel):
|
|
target_folder_id: Optional[int] = None
|
|
|
|
class FileContentUpdate(BaseModel):
|
|
content: str
|
|
|
|
@router.post("/upload", response_model=FileOut, status_code=status.HTTP_201_CREATED)
|
|
async def upload_file(
|
|
file: UploadFile = FastAPIFile(...),
|
|
folder_id: Optional[int] = Form(None),
|
|
current_user: User = Depends(get_current_user)
|
|
):
|
|
if folder_id:
|
|
parent_folder = await Folder.get_or_none(id=folder_id, owner=current_user, is_deleted=False)
|
|
if not parent_folder:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Folder not found")
|
|
else:
|
|
parent_folder = None
|
|
|
|
existing_file = await File.get_or_none(
|
|
name=file.filename, parent=parent_folder, owner=current_user, is_deleted=False
|
|
)
|
|
if existing_file:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
detail="File with this name already exists in the current folder",
|
|
)
|
|
|
|
file_content = await file.read()
|
|
file_size = len(file_content)
|
|
file_hash = hashlib.sha256(file_content).hexdigest()
|
|
|
|
if current_user.used_storage_bytes + file_size > current_user.storage_quota_bytes:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_507_INSUFFICIENT_STORAGE,
|
|
detail="Storage quota exceeded",
|
|
)
|
|
|
|
# Generate a unique path for storage
|
|
file_extension = os.path.splitext(file.filename)[1]
|
|
unique_filename = f"{file_hash}{file_extension}" # Use hash for unique filename
|
|
storage_path = unique_filename
|
|
|
|
# Save file to storage
|
|
await storage_manager.save_file(current_user.id, storage_path, file_content)
|
|
|
|
# Get mime type
|
|
mime_type, _ = mimetypes.guess_type(file.filename)
|
|
if not mime_type:
|
|
mime_type = "application/octet-stream"
|
|
|
|
# Create file entry in database
|
|
db_file = await File.create(
|
|
name=file.filename,
|
|
path=storage_path,
|
|
size=file_size,
|
|
mime_type=mime_type,
|
|
file_hash=file_hash,
|
|
owner=current_user,
|
|
parent=parent_folder,
|
|
)
|
|
|
|
current_user.used_storage_bytes += file_size
|
|
await current_user.save()
|
|
|
|
thumbnail_path = await generate_thumbnail(storage_path, mime_type, current_user.id)
|
|
if thumbnail_path:
|
|
db_file.thumbnail_path = thumbnail_path
|
|
await db_file.save()
|
|
|
|
return await FileOut.from_tortoise_orm(db_file)
|
|
|
|
@router.get("/download/{file_id}")
|
|
async def download_file(file_id: int, current_user: User = Depends(get_current_user)):
|
|
db_file = await File.get_or_none(id=file_id, owner=current_user, is_deleted=False)
|
|
if not db_file:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
|
|
|
|
db_file.last_accessed_at = datetime.now()
|
|
await db_file.save()
|
|
|
|
try:
|
|
async def file_iterator():
|
|
async for chunk in storage_manager.get_file(current_user.id, db_file.path):
|
|
yield chunk
|
|
|
|
return StreamingResponse(
|
|
file_iterator(),
|
|
media_type=db_file.mime_type,
|
|
headers={"Content-Disposition": f"attachment; filename=\"{db_file.name}\""}
|
|
)
|
|
except FileNotFoundError:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found in storage")
|
|
|
|
@router.delete("/{file_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
async def delete_file(file_id: int, current_user: User = Depends(get_current_user)):
|
|
db_file = await File.get_or_none(id=file_id, owner=current_user, is_deleted=False)
|
|
if not db_file:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
|
|
|
|
db_file.is_deleted = True
|
|
db_file.deleted_at = datetime.now()
|
|
await db_file.save()
|
|
|
|
await delete_thumbnail(db_file.id)
|
|
|
|
return
|
|
|
|
@router.post("/{file_id}/move", response_model=FileOut)
|
|
async def move_file(file_id: int, move_data: FileMove, current_user: User = Depends(get_current_user)):
|
|
db_file = await File.get_or_none(id=file_id, owner=current_user, is_deleted=False)
|
|
if not db_file:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
|
|
|
|
target_folder = None
|
|
if move_data.target_folder_id:
|
|
target_folder = await Folder.get_or_none(id=move_data.target_folder_id, owner=current_user, is_deleted=False)
|
|
if not target_folder:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Target folder not found")
|
|
|
|
existing_file = await File.get_or_none(
|
|
name=db_file.name, parent=target_folder, owner=current_user, is_deleted=False
|
|
)
|
|
if existing_file and existing_file.id != file_id:
|
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="File with this name already exists in target folder")
|
|
|
|
db_file.parent = target_folder
|
|
await db_file.save()
|
|
|
|
await log_activity(user=current_user, action="file_moved", target_type="file", target_id=file_id)
|
|
|
|
return await FileOut.from_tortoise_orm(db_file)
|
|
|
|
@router.post("/{file_id}/rename", response_model=FileOut)
|
|
async def rename_file(file_id: int, rename_data: FileRename, current_user: User = Depends(get_current_user)):
|
|
db_file = await File.get_or_none(id=file_id, owner=current_user, is_deleted=False)
|
|
if not db_file:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
|
|
|
|
existing_file = await File.get_or_none(
|
|
name=rename_data.new_name, parent_id=db_file.parent_id, owner=current_user, is_deleted=False
|
|
)
|
|
if existing_file and existing_file.id != file_id:
|
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="File with this name already exists in the same folder")
|
|
|
|
db_file.name = rename_data.new_name
|
|
await db_file.save()
|
|
|
|
await log_activity(user=current_user, action="file_renamed", target_type="file", target_id=file_id)
|
|
|
|
return await FileOut.from_tortoise_orm(db_file)
|
|
|
|
@router.post("/{file_id}/copy", response_model=FileOut, status_code=status.HTTP_201_CREATED)
|
|
async def copy_file(file_id: int, copy_data: FileCopy, current_user: User = Depends(get_current_user)):
|
|
db_file = await File.get_or_none(id=file_id, owner=current_user, is_deleted=False)
|
|
if not db_file:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
|
|
|
|
target_folder = None
|
|
if copy_data.target_folder_id:
|
|
target_folder = await Folder.get_or_none(id=copy_data.target_folder_id, owner=current_user, is_deleted=False)
|
|
if not target_folder:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Target folder not found")
|
|
|
|
base_name = db_file.name
|
|
name_parts = os.path.splitext(base_name)
|
|
counter = 1
|
|
new_name = base_name
|
|
|
|
while await File.get_or_none(name=new_name, parent=target_folder, owner=current_user, is_deleted=False):
|
|
new_name = f"{name_parts[0]} (copy {counter}){name_parts[1]}"
|
|
counter += 1
|
|
|
|
new_file = await File.create(
|
|
name=new_name,
|
|
path=db_file.path,
|
|
size=db_file.size,
|
|
mime_type=db_file.mime_type,
|
|
file_hash=db_file.file_hash,
|
|
owner=current_user,
|
|
parent=target_folder
|
|
)
|
|
|
|
await log_activity(user=current_user, action="file_copied", target_type="file", target_id=new_file.id)
|
|
|
|
return await FileOut.from_tortoise_orm(new_file)
|
|
|
|
@router.get("/", response_model=List[FileOut])
|
|
async def list_files(folder_id: Optional[int] = None, current_user: User = Depends(get_current_user)):
|
|
if folder_id:
|
|
parent_folder = await Folder.get_or_none(id=folder_id, owner=current_user, is_deleted=False)
|
|
if not parent_folder:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Folder not found")
|
|
files = await File.filter(parent=parent_folder, owner=current_user, is_deleted=False).order_by("name")
|
|
else:
|
|
files = await File.filter(parent=None, owner=current_user, is_deleted=False).order_by("name")
|
|
return [await FileOut.from_tortoise_orm(f) for f in files]
|
|
|
|
@router.get("/thumbnail/{file_id}")
|
|
async def get_thumbnail(file_id: int, current_user: User = Depends(get_current_user)):
|
|
db_file = await File.get_or_none(id=file_id, owner=current_user, is_deleted=False)
|
|
if not db_file:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
|
|
|
|
db_file.last_accessed_at = datetime.now()
|
|
await db_file.save()
|
|
|
|
thumbnail_path = getattr(db_file, 'thumbnail_path', None)
|
|
|
|
if not thumbnail_path:
|
|
thumbnail_path = await generate_thumbnail(db_file.path, db_file.mime_type, current_user.id)
|
|
|
|
if thumbnail_path:
|
|
db_file.thumbnail_path = thumbnail_path
|
|
await db_file.save()
|
|
else:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Thumbnail not available")
|
|
|
|
try:
|
|
async def thumbnail_iterator():
|
|
async for chunk in storage_manager.get_file(current_user.id, thumbnail_path):
|
|
yield chunk
|
|
|
|
return StreamingResponse(
|
|
thumbnail_iterator(),
|
|
media_type="image/jpeg"
|
|
)
|
|
except FileNotFoundError:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Thumbnail not found in storage")
|
|
|
|
@router.get("/photos", response_model=List[FileOut])
|
|
async def list_photos(current_user: User = Depends(get_current_user)):
|
|
files = await File.filter(
|
|
owner=current_user,
|
|
is_deleted=False,
|
|
mime_type__istartswith="image/"
|
|
).order_by("-created_at")
|
|
return [await FileOut.from_tortoise_orm(f) for f in files]
|
|
|
|
@router.get("/recent", response_model=List[FileOut])
|
|
async def list_recent_files(current_user: User = Depends(get_current_user), limit: int = 10):
|
|
files = await File.filter(
|
|
owner=current_user,
|
|
is_deleted=False,
|
|
last_accessed_at__isnull=False
|
|
).order_by("-last_accessed_at").limit(limit)
|
|
return [await FileOut.from_tortoise_orm(f) for f in files]
|
|
|
|
@router.post("/{file_id}/star", response_model=FileOut)
|
|
async def star_file(file_id: int, current_user: User = Depends(get_current_user)):
|
|
db_file = await File.get_or_none(id=file_id, owner=current_user, is_deleted=False)
|
|
if not db_file:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
|
|
db_file.is_starred = True
|
|
await db_file.save()
|
|
await log_activity(user=current_user, action="file_starred", target_type="file", target_id=file_id)
|
|
return await FileOut.from_tortoise_orm(db_file)
|
|
|
|
@router.post("/{file_id}/unstar", response_model=FileOut)
|
|
async def unstar_file(file_id: int, current_user: User = Depends(get_current_user)):
|
|
db_file = await File.get_or_none(id=file_id, owner=current_user, is_deleted=False)
|
|
if not db_file:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
|
|
db_file.is_starred = False
|
|
await db_file.save()
|
|
await log_activity(user=current_user, action="file_unstarred", target_type="file", target_id=file_id)
|
|
return await FileOut.from_tortoise_orm(db_file)
|
|
|
|
@router.get("/deleted", response_model=List[FileOut])
|
|
async def list_deleted_files(current_user: User = Depends(get_current_user)):
|
|
files = await File.filter(owner=current_user, is_deleted=True).order_by("-deleted_at")
|
|
return [await FileOut.from_tortoise_orm(f) for f in files]
|
|
|
|
@router.post("/{file_id}/restore", response_model=FileOut)
|
|
async def restore_file(file_id: int, current_user: User = Depends(get_current_user)):
|
|
db_file = await File.get_or_none(id=file_id, owner=current_user, is_deleted=True)
|
|
if not db_file:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Deleted file not found")
|
|
|
|
# Check if a file with the same name exists in the parent folder
|
|
existing_file = await File.get_or_none(
|
|
name=db_file.name, parent=db_file.parent, owner=current_user, is_deleted=False
|
|
)
|
|
if existing_file:
|
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="A file with the same name already exists in this location. Please rename the existing file or restore to a different location.")
|
|
|
|
db_file.is_deleted = False
|
|
db_file.deleted_at = None
|
|
await db_file.save()
|
|
await log_activity(user=current_user, action="file_restored", target_type="file", target_id=file_id)
|
|
return await FileOut.from_tortoise_orm(db_file)
|
|
|
|
class BatchOperationResult(BaseModel):
|
|
succeeded: List[FileOut]
|
|
failed: List[dict]
|
|
|
|
@router.post("/batch")
|
|
async def batch_file_operations(
|
|
batch_operation: BatchFileOperation,
|
|
payload: Optional[BatchMoveCopyPayload] = None,
|
|
current_user: User = Depends(get_current_user)
|
|
):
|
|
if batch_operation.operation not in ["delete", "star", "unstar", "move", "copy"]:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Invalid operation: {batch_operation.operation}")
|
|
|
|
updated_files = []
|
|
failed_operations = []
|
|
|
|
for file_id in batch_operation.file_ids:
|
|
try:
|
|
db_file = await File.get_or_none(id=file_id, owner=current_user, is_deleted=False)
|
|
if not db_file:
|
|
failed_operations.append({"file_id": file_id, "reason": "File not found or not owned by user"})
|
|
continue
|
|
|
|
if batch_operation.operation == "delete":
|
|
db_file.is_deleted = True
|
|
db_file.deleted_at = datetime.now()
|
|
await db_file.save()
|
|
await delete_thumbnail(db_file.id)
|
|
await log_activity(user=current_user, action="file_deleted_batch", target_type="file", target_id=file_id)
|
|
updated_files.append(db_file)
|
|
elif batch_operation.operation == "star":
|
|
db_file.is_starred = True
|
|
await db_file.save()
|
|
await log_activity(user=current_user, action="file_starred_batch", target_type="file", target_id=file_id)
|
|
updated_files.append(db_file)
|
|
elif batch_operation.operation == "unstar":
|
|
db_file.is_starred = False
|
|
await db_file.save()
|
|
await log_activity(user=current_user, action="file_unstarred_batch", target_type="file", target_id=file_id)
|
|
updated_files.append(db_file)
|
|
elif batch_operation.operation == "move":
|
|
if not payload or payload.target_folder_id is None:
|
|
failed_operations.append({"file_id": file_id, "reason": "Target folder not specified"})
|
|
continue
|
|
|
|
target_folder = await Folder.get_or_none(id=payload.target_folder_id, owner=current_user, is_deleted=False)
|
|
if not target_folder:
|
|
failed_operations.append({"file_id": file_id, "reason": "Target folder not found"})
|
|
continue
|
|
|
|
existing_file = await File.get_or_none(
|
|
name=db_file.name, parent=target_folder, owner=current_user, is_deleted=False
|
|
)
|
|
if existing_file and existing_file.id != file_id:
|
|
failed_operations.append({"file_id": file_id, "reason": "File with same name exists in target folder"})
|
|
continue
|
|
|
|
db_file.parent = target_folder
|
|
await db_file.save()
|
|
await log_activity(user=current_user, action="file_moved_batch", target_type="file", target_id=file_id)
|
|
updated_files.append(db_file)
|
|
elif batch_operation.operation == "copy":
|
|
if not payload or payload.target_folder_id is None:
|
|
failed_operations.append({"file_id": file_id, "reason": "Target folder not specified"})
|
|
continue
|
|
|
|
target_folder = await Folder.get_or_none(id=payload.target_folder_id, owner=current_user, is_deleted=False)
|
|
if not target_folder:
|
|
failed_operations.append({"file_id": file_id, "reason": "Target folder not found"})
|
|
continue
|
|
|
|
base_name = db_file.name
|
|
name_parts = os.path.splitext(base_name)
|
|
counter = 1
|
|
new_name = base_name
|
|
|
|
while await File.get_or_none(name=new_name, parent=target_folder, owner=current_user, is_deleted=False):
|
|
new_name = f"{name_parts[0]} (copy {counter}){name_parts[1]}"
|
|
counter += 1
|
|
|
|
new_file = await File.create(
|
|
name=new_name,
|
|
path=db_file.path,
|
|
size=db_file.size,
|
|
mime_type=db_file.mime_type,
|
|
file_hash=db_file.file_hash,
|
|
owner=current_user,
|
|
parent=target_folder
|
|
)
|
|
await log_activity(user=current_user, action="file_copied_batch", target_type="file", target_id=new_file.id)
|
|
updated_files.append(new_file)
|
|
except Exception as e:
|
|
failed_operations.append({"file_id": file_id, "reason": str(e)})
|
|
|
|
return {
|
|
"succeeded": [await FileOut.from_tortoise_orm(f) for f in updated_files],
|
|
"failed": failed_operations
|
|
}
|
|
|
|
@router.put("/{file_id}/content", response_model=FileOut)
|
|
async def update_file_content(
|
|
file_id: int,
|
|
payload: FileContentUpdate,
|
|
current_user: User = Depends(get_current_user)
|
|
):
|
|
db_file = await File.get_or_none(id=file_id, owner=current_user, is_deleted=False)
|
|
if not db_file:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
|
|
|
|
if not db_file.mime_type or not db_file.mime_type.startswith('text/'):
|
|
editableExtensions = [
|
|
'txt', 'md', 'log', 'json', 'js', 'py', 'html', 'css',
|
|
'xml', 'yaml', 'yml', 'sh', 'bat', 'ini', 'conf', 'cfg'
|
|
]
|
|
file_extension = os.path.splitext(db_file.name)[1][1:].lower()
|
|
if file_extension not in editableExtensions:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="File type is not editable"
|
|
)
|
|
|
|
content_bytes = payload.content.encode('utf-8')
|
|
new_size = len(content_bytes)
|
|
size_diff = new_size - db_file.size
|
|
|
|
if current_user.used_storage_bytes + size_diff > current_user.storage_quota_bytes:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_507_INSUFFICIENT_STORAGE,
|
|
detail="Storage quota exceeded"
|
|
)
|
|
|
|
new_hash = hashlib.sha256(content_bytes).hexdigest()
|
|
file_extension = os.path.splitext(db_file.name)[1]
|
|
new_storage_path = f"{new_hash}{file_extension}"
|
|
|
|
await storage_manager.save_file(current_user.id, new_storage_path, content_bytes)
|
|
|
|
if new_storage_path != db_file.path:
|
|
try:
|
|
await storage_manager.delete_file(current_user.id, db_file.path)
|
|
except:
|
|
pass
|
|
|
|
db_file.path = new_storage_path
|
|
db_file.size = new_size
|
|
db_file.file_hash = new_hash
|
|
db_file.updated_at = datetime.utcnow()
|
|
await db_file.save()
|
|
|
|
current_user.used_storage_bytes += size_diff
|
|
await current_user.save()
|
|
|
|
await log_activity(user=current_user, action="file_updated", target_type="file", target_id=file_id)
|
|
|
|
return await FileOut.from_tortoise_orm(db_file)
|