2025-11-10 15:46:40 +01:00
from fastapi import APIRouter , Depends , UploadFile , File as FastAPIFile , HTTPException , status , Response , Form
2025-11-09 23:29:07 +01:00
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
2025-11-10 01:58:41 +01:00
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
2025-11-10 15:46:40 +01:00
class FileContentUpdate ( BaseModel ) :
content : str
2025-11-09 23:29:07 +01:00
@router.post ( " /upload " , response_model = FileOut , status_code = status . HTTP_201_CREATED )
async def upload_file (
file : UploadFile = FastAPIFile ( . . . ) ,
2025-11-10 15:46:40 +01:00
folder_id : Optional [ int ] = Form ( None ) ,
2025-11-09 23:29:07 +01:00
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 " )
2025-11-10 01:58:41 +01:00
db_file . last_accessed_at = datetime . now ( )
await db_file . save ( )
2025-11-09 23:29:07 +01:00
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 " )
2025-11-10 15:46:40 +01:00
2025-11-09 23:29:07 +01:00
db_file . is_deleted = True
db_file . deleted_at = datetime . now ( )
await db_file . save ( )
2025-11-10 15:46:40 +01:00
await delete_thumbnail ( db_file . id )
2025-11-09 23:29:07 +01:00
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 " )
2025-11-10 01:58:41 +01:00
db_file . last_accessed_at = datetime . now ( )
await db_file . save ( )
2025-11-09 23:29:07 +01:00
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 ]
2025-11-10 01:58:41 +01:00
@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 )
2025-11-10 15:46:40 +01:00
class BatchOperationResult ( BaseModel ) :
succeeded : List [ FileOut ]
failed : List [ dict ]
@router.post ( " /batch " )
2025-11-10 01:58:41 +01:00
async def batch_file_operations (
batch_operation : BatchFileOperation ,
payload : Optional [ BatchMoveCopyPayload ] = None ,
current_user : User = Depends ( get_current_user )
) :
2025-11-10 15:46:40 +01:00
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 } " )
2025-11-10 01:58:41 +01:00
updated_files = [ ]
2025-11-10 15:46:40 +01:00
failed_operations = [ ]
2025-11-10 01:58:41 +01:00
for file_id in batch_operation . file_ids :
2025-11-10 15:46:40 +01:00
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 " )
2025-11-10 01:58:41 +01:00
2025-11-10 15:46:40 +01:00
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 "
2025-11-10 01:58:41 +01:00
)
2025-11-10 15:46:40 +01:00
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 )