234 lines
8.7 KiB
Python
234 lines
8.7 KiB
Python
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"}
|