Files
OSIT-AE-API-FastAPI/app/routers/hosted_file.py
Scott Idem 9d89d4c8e4 fix: exclude account_id and virtual fields from archive_content DB writes
- Adds fields_to_exclude_from_db to Archive_Content_Base to prevent SQL errors on non-existent columns.
- Updates documentation for V3 Create/Update patterns and the x-ae-ignore-extra-fields header.
- Propagates account_id_random to hosted file and media processing methods.
2026-02-24 11:30:17 -05:00

364 lines
16 KiB
Python

import aiofiles, datetime, hashlib, mimetypes, os, pathlib, random, shutil, subprocess, shlex, tempfile, time
from fastapi import APIRouter, Body, Depends, File, Form, Header, HTTPException, Path, Query, Response, status, UploadFile
from fastapi.responses import FileResponse, StreamingResponse
from pydantic import BaseModel, EmailStr, Field
from typing import Dict, List, Optional, Set, Union
from app.lib_general import log, logging, common_route_params, Common_Route_Params, common_route_params_min, Common_Route_Params_Min
from app.config import settings
from app.db_sql import sql_insert, sql_update, sql_insert_or_update, sql_select, sql_delete, redis_lookup_id_random
from app.methods.hosted_file_methods import (
create_hosted_file_obj, handle_delete_hosted_file, load_hosted_file_obj,
save_file, save_file_to_hosted_file, create_hosted_file_link,
delete_hosted_file_link, get_hosted_file_link_rec_list, lookup_file_hash,
check_for_hosted_file_hash_file, directory_check_method
)
from app.methods.lib_media import clip_video_method, convert_file_method
from app.models.hosted_file_models import Hosted_File_Base
from app.models.response_models import Resp_Body_Base, mk_resp
router = APIRouter()
# ### BEGIN ### API Hosted File ### directory_check() ###
# Updated 2026-02-03 (Modularized)
@router.get('/directory_check', response_model=Resp_Body_Base)
async def directory_check(
rm_orphan: bool = False,
commons: Common_Route_Params = Depends(common_route_params),
):
"""
Scans hosted_files root and migrates legacy files to 2-char subdirectories.
"""
log.setLevel(logging.INFO)
result_list = directory_check_method(rm_orphan=rm_orphan)
if result_list is False:
return mk_resp(data=False, status_code=500, response=commons.response, status_message='Hosted files directory not found.')
return mk_resp(data=result_list, response=commons.response, status_message=f'Processed {len(result_list)} files.')
# ### END ### API Hosted File ### directory_check() ###
# ### BEGIN ### API Hosted File ### download_hosted_file() ###
# Updated 2022-08-08
@router.get('/{hosted_file_id}/download', response_model=Resp_Body_Base)
async def download_hosted_file(
hosted_file_id: str = Path(min_length=11, max_length=22),
filename: str = Query(None, min_length=4, max_length=255),
commons: Common_Route_Params = Depends(common_route_params),
):
log.setLevel(logging.INFO)
# ID Resolve
if hfid_int := redis_lookup_id_random(record_id_random=hosted_file_id, table_name='hosted_file'): pass
else: return mk_resp(data=None, status_code=404, response=commons.response, status_message='The hosted_file ID was invalid or not found.')
hosted_file_obj = load_hosted_file_obj(hosted_file_id=hfid_int)
if not hosted_file_obj:
return mk_resp(data=False, status_code=400, response=commons.response)
target_filename = filename or hosted_file_obj.filename
hash_sha256 = hosted_file_obj.hash_sha256
hosted_files_path = settings.FILES_PATH['hosted_files_root']
subdir = hosted_file_obj.subdirectory_path or ''
file_path = os.path.join(hosted_files_path, subdir, f'{hash_sha256}.file')
if os.path.exists(file_path):
return FileResponse(file_path, filename=target_filename)
else:
log.error(f'Physical file missing: {file_path}')
return mk_resp(data=None, status_code=404, response=commons.response, status_message='Physical file not found on server.')
# ### END ### API Hosted File ### download_hosted_file() ###
# ### BEGIN ### API Hosted File ### file_streamer() ###
async def file_streamer(path: str, start: int, end: int):
chunk_size = 8192
async with aiofiles.open(path, mode='rb') as f:
await f.seek(start)
while True:
chunk_start = await f.tell()
if chunk_start >= end: break
bytes_to_read = min(chunk_size, end - chunk_start)
data = await f.read(bytes_to_read)
if not data: break
yield data
# ### END ### API Hosted File ### file_streamer() ###
# ### BEGIN ### API Hosted File ### stream_hosted_file() ###
@router.get('/{hosted_file_id}/stream')
async def stream_hosted_file(
hosted_file_id: str = Path(min_length=11, max_length=22),
filename: str = Query(None, min_length=4, max_length=255),
range: str = Header(),
commons: Common_Route_Params = Depends(common_route_params),
):
if hfid_int := redis_lookup_id_random(record_id_random=hosted_file_id, table_name='hosted_file'): pass
else: return mk_resp(data=None, status_code=404, response=commons.response)
hosted_file_obj = load_hosted_file_obj(hosted_file_id=hfid_int)
if not hosted_file_obj: return mk_resp(data=False, status_code=400, response=commons.response)
file_path = os.path.join(settings.FILES_PATH['hosted_files_root'], hosted_file_obj.subdirectory_path or '', f'{hosted_file_obj.hash_sha256}.file')
if os.path.exists(file_path):
file_size = os.stat(file_path).st_size
range_parts = range.replace('bytes=', '').split('-')
start = int(range_parts[0])
end = int(range_parts[1]) if len(range_parts) > 1 and range_parts[1] else file_size - 1
if start >= file_size:
raise HTTPException(status_code=status.HTTP_416_REQUESTED_RANGE_NOT_SATISFIABLE)
end = min(end, file_size - 1)
content_length = end - start + 1
return StreamingResponse(
file_streamer(file_path, start, end + 1),
media_type = mimetypes.guess_type(filename or hosted_file_obj.filename)[0],
status_code = status.HTTP_206_PARTIAL_CONTENT,
headers = {
'Accept-Ranges': 'bytes',
'Content-Range': f'bytes {start}-{end}/{file_size}',
'Content-Length': str(content_length),
}
)
return mk_resp(data=None, status_code=404, response=commons.response)
# ### END ### API Hosted File ### stream_hosted_file() ###
# ### BEGIN ### API Hosted File Route ### upload_files() ###
@router.post('/upload_files')
async def upload_files(
file_list: List[UploadFile] = File(...),
account_id: str = Form(..., min_length=1, max_length=22),
link_to_type: str = Form(...),
link_to_id: str = Form(..., min_length=1, max_length=22),
check_allowed_extension: bool = False,
x_account_id: str = Header(..., ),
return_obj: bool = True,
by_alias: bool = True,
exclude_unset: bool = True,
response: Response = Response,
):
"""
Legacy Upload Route (V2). Preserved for frontend compatibility.
"""
log.setLevel(logging.INFO)
acc_id_rand = account_id
if acc_id_int := redis_lookup_id_random(record_id_random=acc_id_rand, table_name='account'): pass
else: return mk_resp(data=None, status_code=400, response=response)
lid_rand = link_to_id
if lid_int := redis_lookup_id_random(record_id_random=lid_rand, table_name=link_to_type): pass
else: return mk_resp(data=None, status_code=400, response=response)
hosted_file_list = []
for file_obj in file_list:
file_info = await save_file(
file = file_obj,
account_id = acc_id_int,
account_id_random = acc_id_rand,
link_to_type = link_to_type,
link_to_id = lid_int,
link_to_id_random = lid_rand,
check_allowed_extension = check_allowed_extension,
)
if not file_info['saved']: continue
# Deduplication & DB Sync
if hosted_file_sel := sql_select(table_name='hosted_file', field_name='hash_sha256', field_value=file_info['hash_sha256']):
hfid_int = hosted_file_sel.get('id')
# Migration check
if not hosted_file_sel.get('subdirectory_path') and file_info.get('subdirectory_path'):
sql_update(table_name='hosted_file', data={'id': hfid_int, 'subdirectory_path': file_info['subdirectory_path']})
hosted_file_dict = load_hosted_file_obj(hosted_file_id=hfid_int, model_as_dict=True)
else:
file_info['account_id'] = acc_id_int
file_info['account_id_random'] = acc_id_rand
new_obj = Hosted_File_Base(**file_info)
if res_id := create_hosted_file_obj(hosted_file_obj_new=new_obj):
hosted_file_dict = load_hosted_file_obj(hosted_file_id=res_id, model_as_dict=True)
hfid_int = res_id
else: continue
# Final metadata and linking
hosted_file_dict.update({'saved': True, 'already_exists': file_info['already_exists'], 'copy_timer': file_info['copy_timer']})
if link_to_type not in ['event', 'event_location', 'event_session', 'event_presentation', 'event_presenter', 'event_badge', 'event_exhibit', 'event_person']:
create_hosted_file_link(account_id=acc_id_int, hosted_file_id=hfid_int, link_to_type=link_to_type, link_to_id=lid_int)
hosted_file_list.append(hosted_file_dict)
return mk_resp(data=hosted_file_list, response=response)
# ### END ### API Hosted File Route ### upload_files() ###
@router.delete('/{hosted_file_id}', response_model=Resp_Body_Base)
async def delete_hosted_file(
hosted_file_id: str = Path(min_length=11, max_length=22),
link_to_type: str = None,
link_to_id: Union[int, str] = None,
rm_orphan: bool = False,
commons: Common_Route_Params = Depends(common_route_params),
):
if hfid_int := redis_lookup_id_random(record_id_random=hosted_file_id, table_name='hosted_file'):
if handle_delete_hosted_file(account_id=commons.x_account_id, hosted_file_id=hfid_int, link_to_type=link_to_type, link_to_id=link_to_id, rm_orphan=rm_orphan):
return mk_resp(data=True, response=commons.response)
return mk_resp(data=None, status_code=404, response=commons.response)
@router.get('/{hosted_file_id}', response_model=Resp_Body_Base)
async def get_hosted_file_obj_route(
hosted_file_id: str = Path(min_length=11, max_length=22),
enabled: str = 'enabled',
x_account_id: str = Header(...),
response: Response = Response,
):
if hfid_int := redis_lookup_id_random(record_id_random=hosted_file_id, table_name='hosted_file'):
if obj := load_hosted_file_obj(hosted_file_id=hfid_int, enabled=enabled, model_as_dict=True):
return mk_resp(data=obj, response=response)
return mk_resp(data=None, status_code=404, response=response)
@router.get('/hash/{hosted_file_hash}', response_model=Resp_Body_Base)
async def check_hosted_file_obj_w_hash(
hosted_file_hash: str = Path(min_length=64, max_length=64),
check_for_local: Optional[bool] = True,
commons: Common_Route_Params = Depends(common_route_params),
):
if hfid := lookup_file_hash(file_hash=hosted_file_hash):
obj = load_hosted_file_obj(hosted_file_id=hfid, model_as_dict=True)
if check_for_local and obj:
if check := check_for_hosted_file_hash_file(file_hash=hosted_file_hash, sub_dir=obj.get('subdirectory_path', '')):
obj['hosted_file_found_check'] = True
obj['hosted_file_size_check'] = check['file_size']
return mk_resp(data=obj, response=commons.response)
return mk_resp(data=False, status_code=404, response=commons.response)
@router.get('/tmp/{subdirectory}/{filename}/download', response_model=Resp_Body_Base)
async def download_tmp(
subdirectory: str = Path(min_length=1, max_length=100),
filename: str = Path(min_length=4, max_length=255),
commons: Common_Route_Params = Depends(common_route_params),
):
path = os.path.join(settings.FILES_PATH['hosted_tmp_root'], subdirectory, filename)
if os.path.exists(path):
return FileResponse(path, filename=filename)
return mk_resp(data=False, status_code=404, response=commons.response)
# ### BEGIN ### API Hosted File Route ### convert_file() ###
@router.get('/{hosted_file_id}/convert_file')
async def convert_file(
hosted_file_id: str = Path(min_length=11, max_length=22),
link_to_type: str = Query(...),
link_to_id: str = Query(...),
filename_no_ext: str = Query('automated_hosted_file_conversion'),
to_type: str = 'webp',
commons: Common_Route_Params = Depends(common_route_params),
):
"""
Modularized file conversion route.
"""
if lid_int := redis_lookup_id_random(record_id_random=link_to_id, table_name=link_to_type):
result = await convert_file_method(
hosted_file_id = hosted_file_id,
link_to_type = link_to_type,
link_to_id = lid_int,
account_id = commons.x_account_id,
account_id_random = commons.x_account_id_random,
filename_no_ext = filename_no_ext,
to_type = to_type
)
if result: return mk_resp(data=result, response=commons.response)
return mk_resp(data=None, status_code=400, response=commons.response)
# ### END ### API Hosted File Route ### convert_file() ###
# ### BEGIN ### API Hosted File ### clip_video() ###
@router.get('/{hosted_file_id}/clip_video')
async def clip_video(
hosted_file_id: str = Path(min_length=11, max_length=22),
link_to_type: str = Query(...),
link_to_id: str = Query(...),
start_time: str = Query(..., min_length=8, max_length=8),
end_time: str = Query(..., min_length=8, max_length=8),
filename_no_ext: str = Query('automated_hosted_file_clip_video'),
reencode: bool = Query(False),
scale_down: bool = Query(False),
commons: Common_Route_Params = Depends(common_route_params),
):
"""
Modularized video clipping route.
"""
if lid_int := redis_lookup_id_random(record_id_random=link_to_id, table_name=link_to_type):
result = await clip_video_method(
hosted_file_id = hosted_file_id,
start_time = start_time,
end_time = end_time,
account_id = commons.x_account_id,
account_id_random = commons.x_account_id_random,
link_to_type = link_to_type,
link_to_id = lid_int,
filename_no_ext = filename_no_ext,
reencode = reencode,
scale_down = scale_down
)
if result: return mk_resp(data=result, response=commons.response)
return mk_resp(data=None, status_code=400, response=commons.response)
# ### END ### API Hosted File ### clip_video() ###
@router.post('/create_video')
async def create_video(
file: UploadFile = File(...),
title_image: UploadFile = File(None),
title_part_1: str = Form(...),
title_part_2: str = Form(...),
subtitle_part_1: str = Form(...),
subtitle_part_2: str = Form(...),
font_color: str = Form('darkblue'),
):
# Specialized utility route kept inline for now.
from wand.image import Image
from wand.drawing import Drawing
with tempfile.NamedTemporaryFile(delete=False, suffix='.mp3') as audio_file:
audio_file.write(await file.read())
audio_file_path = audio_file.name
if title_image:
with tempfile.NamedTemporaryFile(delete=False, suffix='.png') as ti_opened:
ti_opened.write(await title_image.read())
title_image_path = ti_opened.name
else:
with tempfile.NamedTemporaryFile(delete=False, suffix='.png') as ti_opened:
title_image_path = ti_opened.name
with Image(width=1280, height=720, background='lightblue') as img:
img.save(filename=title_image_path)
with Image(filename=title_image_path) as img:
with Drawing() as ctx:
ctx.font_family = 'DejaVu Sans'
ctx.font_size = 72
ctx.fill_color = font_color
ctx.text(0, 150, title_part_1)
ctx(img)
img.save(filename=title_image_path)
video_name = f"{tempfile.gettempdir()}/{title_part_1}.mp4"
cmd = f"ffmpeg -y -loop 1 -i {title_image_path} -i {audio_file_path} -c:a copy -c:v libx264 -shortest {video_name}"
subprocess.run(shlex.split(cmd), check=True)
return FileResponse(video_name, media_type='video/mp4', filename=f'{title_part_1}.mp4')