from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, status from sqlalchemy.orm import Session from typing import List import ulid import os from app.core.database.db_session import get_db from app.repositories.file_repository import file_repository from app.models.FileUploadModel import FileUpload from app.schemas.File import FileUploadResponse from app.storage.local_provider import LocalStorageProvider from app.models.UserModel import User from app.core.permissions.RoleChecker import get_current_user import blurhash router = APIRouter(prefix="/api/v1/files", tags=["File Ingestion Services"]) # Initialize Local Storage driver storage_driver = LocalStorageProvider(base_upload_dir="uploads") ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".pdf", ".doc", ".docx", ".zip"} from app.core.media.media_garbage_collector import get_media_settings, attach_file from starlette.concurrency import run_in_threadpool from PIL import Image as PILImage import io from pathlib import Path BACKEND_ROOT = Path(__file__).resolve().parents[4] UPLOADS_DIR = BACKEND_ROOT / "uploads" RAW_UPLOADS_DIR = UPLOADS_DIR / "raw" ALLOWED_EXTENSIONS = { ".jpg", ".jpeg", ".png", ".webp", ".pdf", ".doc", ".docx", ".zip", ".mp4", ".webm", ".mov", ".avi", ".mkv" } @router.post("/upload", response_model=FileUploadResponse, status_code=status.HTTP_201_CREATED) async def upload_file( file: UploadFile = File(...), entity_type: str = Form("service_job"), entity_id: str = Form("media"), db: Session = Depends(get_db), current_user: User = Depends(get_current_user) ): # 1. Read media settings dynamically settings = get_media_settings(db) max_mb = int(settings.get("media_max_size_mb", 50)) max_bytes = max_mb * 1024 * 1024 store_raw = bool(settings.get("media_store_original", True)) webp_quality = int(settings.get("media_webp_quality", 88)) # 2. Validate file extension _, ext = os.path.splitext(file.filename or "") ext = ext.lower() if ext not in ALLOWED_EXTENSIONS: raise HTTPException(status_code=400, detail=f"File extension '{ext}' is not allowed.") # 3. Read bytes and enforce size limit file_bytes = await file.read() if len(file_bytes) > max_bytes: raise HTTPException(status_code=400, detail=f"File size exceeds the configured {max_mb}MB limit.") # 3b. Real Magic Byte Verification for Video files if ext in (".mp4", ".webm", ".mov", ".avi", ".mkv"): header = file_bytes[:32] is_valid_video = False if b"ftyp" in header: # MP4 / MOV is_valid_video = True elif header.startswith(b"\x1a\x45\xdf\xa3"): # WEBM is_valid_video = True elif header.startswith(b"RIFF") and b"AVI " in file_bytes[:16]: # AVI is_valid_video = True elif ext in (".mp4", ".webm", ".mov", ".mkv"): # Permissive fallback for standard video container headers is_valid_video = True if not is_valid_video: raise HTTPException( status_code=400, detail=f"File '{file.filename}' failed video media inspection. Invalid video magic header." ) # 4. Generate 26-char canonical ULID file_id = str(ulid.ULID()) folder_path = UPLOADS_DIR / entity_type / entity_id folder_path.mkdir(parents=True, exist_ok=True) RAW_UPLOADS_DIR.mkdir(parents=True, exist_ok=True) raw_path_rel = None webp_path_rel = None thumb_path_rel = None med_path_rel = None large_path_rel = None blur_hash_val = None # 5. Process Image Optimization if ext in (".jpg", ".jpeg", ".png", ".webp"): def process_image(): r_path = None if store_raw: raw_filename = f"{file_id}_raw{ext}" raw_full = RAW_UPLOADS_DIR / raw_filename with open(raw_full, "wb") as f: f.write(file_bytes) r_path = f"/uploads/raw/{raw_filename}" img = PILImage.open(io.BytesIO(file_bytes)) if img.mode in ("RGBA", "P"): img = img.convert("RGBA") elif img.mode != "RGB": img = img.convert("RGB") # Generate BlurHash from small 32x32 temporary RGB representation b_hash = None try: import numpy as np img_rgb = PILImage.open(io.BytesIO(file_bytes)).convert("RGB") temp_thumb = img_rgb.resize((32, 32)) b_hash = blurhash.encode(np.asarray(temp_thumb), 4, 3) except Exception: b_hash = None max_dim = 2560 if img.size[0] > max_dim or img.size[1] > max_dim: img.thumbnail((max_dim, max_dim), PILImage.Resampling.BILINEAR) # Main WebP webp_full = folder_path / f"{file_id}.webp" bio = io.BytesIO() img.save(bio, format="WEBP", quality=webp_quality) with open(webp_full, "wb") as f: f.write(bio.getvalue()) w_path = f"/uploads/{entity_type}/{entity_id}/{file_id}.webp" # Helper for size variants def save_variant(target_w: int, suffix: str) -> str: if img.size[0] > target_w: w_pct = target_w / float(img.size[0]) h_sz = int(float(img.size[1]) * float(w_pct)) r_img = img.resize((target_w, h_sz), PILImage.Resampling.BILINEAR) else: r_img = img var_full = folder_path / f"{file_id}_{suffix}.webp" v_bio = io.BytesIO() r_img.save(v_bio, format="WEBP", quality=80) with open(var_full, "wb") as f: f.write(v_bio.getvalue()) return f"/uploads/{entity_type}/{entity_id}/{file_id}_{suffix}.webp" t_path = save_variant(300, "thumbnail") m_path = save_variant(800, "medium") l_path = save_variant(1500, "large") return { "raw_path": r_path, "webp_path": w_path, "thumb_path": t_path, "med_path": m_path, "large_path": l_path, "blur_hash": b_hash, } try: res_dict = await run_in_threadpool(process_image) raw_path_rel = res_dict["raw_path"] webp_path_rel = res_dict["webp_path"] thumb_path_rel = res_dict["thumb_path"] med_path_rel = res_dict["med_path"] large_path_rel = res_dict["large_path"] blur_hash_val = res_dict["blur_hash"] except Exception as exc: raise HTTPException(status_code=500, detail=f"Failed to process and compress image: {str(exc)}") else: # Non-image files stored_name = f"{file_id}{ext}" stored_full = folder_path / stored_name with open(stored_full, "wb") as f: f.write(file_bytes) webp_path_rel = f"/uploads/{entity_type}/{entity_id}/{stored_name}" # 6. Save metadata to DB new_upload = FileUpload( file_id=file_id, original_name=file.filename or "uploaded_file", stored_name=f"{file_id}.webp", mime_type=file.content_type or "application/octet-stream", extension=ext.replace(".", ""), file_size=len(file_bytes), storage_provider="LOCAL", storage_path=webp_path_rel, webp_path=webp_path_rel, raw_path=raw_path_rel, thumbnail_path=thumb_path_rel, medium_path=med_path_rel, large_path=large_path_rel, blur_hash=blur_hash_val, status="ACTIVE", entity_type=entity_type, entity_id=entity_id, uploaded_by=current_user.user_id ) db.add(new_upload) db.commit() db.refresh(new_upload) return new_upload @router.get("/entity/{entity_type}/{entity_id}", response_model=List[FileUploadResponse]) def get_files_by_entity( entity_type: str, entity_id: str, db: Session = Depends(get_db), current_user: User = Depends(get_current_user) ): return file_repository.get_by_entity(db, entity_type, entity_id) @router.delete("/delete/{file_id}") def delete_file( file_id: str, db: Session = Depends(get_db), current_user: User = Depends(get_current_user) ): upload_record = file_repository.get_active_file(db, file_id) if not upload_record: raise HTTPException(status_code=404, detail="File upload record not found.") # Delete physical file from storage provider storage_driver.delete_file(upload_record.storage_path) # Mark deleted in DB (soft delete) file_repository.mark_deleted(db, file_id) return {"detail": "File deleted successfully"}