Files
OSIT-AE-API-FastAPI/app/routers/api_v3_actions_event_file.py
Scott Idem 3806613427 fix(download): strict ID typing — remove cross-resolution from hosted_file download
event_file download now resolves event_file_id → hosted_file_id explicitly before
delegating, rather than relying on a cross-resolution fallback inside the hosted_file
endpoint. The hosted_file download endpoint now only accepts hosted_file IDs.

Cross-resolution was added reactively (ea117bf) to patch incorrect frontend ID usage
and was never a deliberate design decision. With no per-record account ownership check
on the download path, the implicit ID aliasing was an unauditable gap.

- download_event_file_action: resolves event_file → hosted_file via Redis + SQL before
  delegating; 404s explicitly if chain is broken
- download_file_action: strict hosted_file ID only; cross-resolution fallback removed
- Also fixes ?key= not being forwarded (was missing from event_file endpoint signature)
- TODO: per-record account ownership check (P1), archive_content download endpoint (P2)
- Docs: breaking change note added to frontend guide (remove ~2026-06-24)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 14:46:47 -04:00

317 lines
13 KiB
Python

from fastapi import APIRouter, Depends, File, Form, Header, HTTPException, Path, Query, Response, status, UploadFile
from fastapi.responses import FileResponse, StreamingResponse
import aiofiles
import mimetypes
import os
import pathlib
from typing import Dict, List, Optional, Set, Union
import asyncio
import logging
log = logging.getLogger(__name__)
from app.config import settings
from app.db_sql import redis_lookup_id_random, sql_select, sql_update, sql_delete, get_id_random
from app.methods.hosted_file_methods import (
create_hosted_file_obj, load_hosted_file_obj, save_file,
create_hosted_file_link, delete_hosted_file_link
)
from app.methods.event_file_methods import create_event_file_obj, load_event_file_obj
from app.lib_general_v3 import (
AccountContext, get_account_context, get_account_context_optional,
SerializationParams, DelayParams
)
from app.models.hosted_file_models import Hosted_File_Base
from app.models.event_file_models import Event_File_Base
from app.models.response_models import Resp_Body_Base, mk_resp
"""
Aether API V3 - Event File Action Router
------------------------------------------
Handles high-level atomic operations for the Event module, specifically
marrying physical hosted_files with the relational event_file table.
"""
router = APIRouter()
# --- Helpers ---
def validate_file_extension(filename: str, allowed_extensions: List[str]):
if not allowed_extensions:
return True
ext = filename.rsplit('.', 1)[-1].lower()
if ext not in [e.lower().strip('.') for e in allowed_extensions]:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"File extension '.{ext}' is not allowed. Allowed: {', '.join(allowed_extensions)}"
)
return True
# --- Routes ---
@router.post('/upload', response_model=Resp_Body_Base)
async def upload_event_file_action(
file_list: List[UploadFile] = File(...),
account_id: str = Form(..., min_length=11, max_length=22),
for_type: str = Form(...),
for_id: str = Form(..., min_length=11, max_length=22),
# Event Specific Metadata
event_id: Optional[str] = Form(None),
event_session_id: Optional[str] = Form(None),
event_presentation_id: Optional[str] = Form(None),
event_presenter_id: Optional[str] = Form(None),
event_location_id: Optional[str] = Form(None),
event_track_id: Optional[str] = Form(None),
# Display/Logic Metadata
title: Optional[str] = Form(None),
description: Optional[str] = Form(None),
internal_use: Optional[bool] = Form(False),
open_in_os: Optional[str] = Form(None),
allowed_extensions: Optional[List[str]] = Query(None),
account: AccountContext = Depends(get_account_context),
delay: DelayParams = Depends(),
):
"""
High-level Event File Upload.
- Saves physical file (hosted_file).
- Links to generic storage (hosted_file_link).
- Creates context-aware association (event_file).
- Returns full event_file objects.
"""
if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s)
# 1. Resolve Core Parent IDs
account_id_int = redis_lookup_id_random(record_id_random=account_id, table_name='account')
if not account_id_int:
raise HTTPException(status_code=400, detail="Invalid account_id.")
# Generic link target (usually same as for_type/for_id but explicitly passed)
link_to_id_int = redis_lookup_id_random(record_id_random=for_id, table_name=for_type)
if not link_to_id_int:
raise HTTPException(status_code=400, detail=f"Invalid link target ID for type {for_type}.")
event_file_results = []
for file_obj in file_list:
# 2. Extension Validation
validate_file_extension(file_obj.filename, allowed_extensions)
# 3. Physical Save & Hosted File Sync (Deduplication)
file_info = await save_file(
file = file_obj,
account_id = account_id_int,
account_id_random = account_id,
link_to_type = for_type,
link_to_id = link_to_id_int,
link_to_id_random = for_id,
check_allowed_extension = False,
)
if not file_info.get('saved'):
log.error(f"Failed to save physical file: {file_obj.filename}")
continue
hosted_file_id_int = None
# Deduplication lookup
if existing_rec := sql_select(table_name='hosted_file', field_name='hash_sha256', field_value=file_info['hash_sha256']):
hosted_file_id_int = existing_rec.get('id')
if not existing_rec.get('subdirectory_path') and file_info.get('subdirectory_path'):
sql_update(table_name='hosted_file', data={'id': hosted_file_id_int, 'subdirectory_path': file_info['subdirectory_path']})
else:
file_info['account_id'] = account_id_int
file_info['account_id_random'] = account_id
new_hf = Hosted_File_Base(**file_info)
hosted_file_id_int = create_hosted_file_obj(hosted_file_obj_new=new_hf)
if not hosted_file_id_int:
log.error("Database failure creating hosted_file record.")
continue
# 4. Standard Generic Linking
create_hosted_file_link(
account_id = account_id_int,
hosted_file_id = hosted_file_id_int,
link_to_type = for_type,
link_to_id = link_to_id_int
)
# 5. Specialized Event File Linking
# Prepare the event_file record
ef_data = {
"hosted_file_id": hosted_file_id_int,
"for_type": for_type,
"for_id": link_to_id_int, # Explicitly pass the resolved int ID
"for_id_random": for_id,
"event_id_random": event_id,
"event_session_id_random": event_session_id,
"event_presentation_id_random": event_presentation_id,
"event_presenter_id_random": event_presenter_id,
"event_location_id_random": event_location_id,
"event_track_id_random": event_track_id,
"filename": file_obj.filename,
"extension": file_info['extension'],
"title": title,
"description": description,
"internal_use": internal_use,
"open_in_os": open_in_os,
"enable": True
}
# Instantiate model to trigger ID resolution validators
new_ef_obj = Event_File_Base(**ef_data)
res_ef_id = create_event_file_obj(event_file_obj_new=new_ef_obj)
if res_ef_id is True:
# An update happened instead of an insert. Resolve the ID via unique keys.
# unique index: hosted_file_id_for (hosted_file_id, for_type, for_id)
lookup_res = sql_select(
table_name='event_file',
data={
'hosted_file_id': hosted_file_id_int,
'for_type': for_type,
'for_id': link_to_id_int
}
)
if lookup_res:
res_ef_id = lookup_res.get('id')
if isinstance(res_ef_id, int):
# Load the newly created/updated object (enriched view)
if enriched_ef := load_event_file_obj(event_file_id=res_ef_id, inc_hosted_file=True, model_as_dict=True):
# Vision Transformer: Ensure ID is the random string for the frontend
if not isinstance(enriched_ef.get('id'), str):
rid = get_id_random(res_ef_id, table_name='event_file')
enriched_ef['id'] = rid
enriched_ef['event_file_id'] = rid
event_file_results.append(enriched_ef)
else:
log.error(f"Created/Updated event_file {res_ef_id} but failed to reload.")
else:
log.error(f"Failed to create/update event_file record. Result: {res_ef_id}")
return mk_resp(data=event_file_results, status_message=f"Successfully processed {len(event_file_results)} event files.")
@router.post('/from_hosted_file/{hosted_file_id}', response_model=Resp_Body_Base)
async def create_event_file_from_hosted_file_action(
event_file_obj: Event_File_Base,
hosted_file_id: str = Path(..., min_length=11, max_length=22),
inc_hosted_file: bool = Query(False),
return_obj: bool = Query(True),
account: AccountContext = Depends(get_account_context),
delay: DelayParams = Depends(),
):
"""
Specialized Action: Create Event File from Existing Hosted File.
This endpoint allows the frontend to associate an ALREADY UPLOADED hosted_file
with an event-specific context (e.g., event_session, exhibit).
Matches V3 Vision ID Standard:
- Accepts string ID in path.
- Resolves relational IDs in body via Event_File_Base root_validator.
"""
if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s)
# 1. Verify physical file exists
hf_rec = load_hosted_file_obj(hosted_file_id=hosted_file_id)
if not hf_rec:
raise HTTPException(status_code=404, detail=f"Hosted file '{hosted_file_id}' not found.")
# 2. Prepare event_file data
# The Event_File_Base model now standardizes all IDs to Union[int, str]
# and its root_validator handles the string->int resolution during instantiation.
ef_data = event_file_obj.dict(exclude_unset=True)
ef_data['hosted_file_id'] = hosted_file_id # Inject the path ID
# Re-instantiate to trigger hardened Vision resolution
validated_ef = Event_File_Base(**ef_data)
# 3. Standard Generic Linking (Ensures hosted_file_link exists)
# We need the integers for the method call
hf_id_int = redis_lookup_id_random(record_id_random=hosted_file_id, table_name='hosted_file')
link_to_id_int = redis_lookup_id_random(record_id_random=validated_ef.for_id, table_name=validated_ef.for_type)
create_hosted_file_link(
account_id = account.account_id,
hosted_file_id = hf_id_int,
link_to_type = validated_ef.for_type,
link_to_id = link_to_id_int
)
# 4. Create Event File record
res_ef_id = create_event_file_obj(event_file_obj_new=validated_ef)
if res_ef_id is True:
# Update instead of insert - find the ID
lookup_res = sql_select(
table_name='event_file',
data={
'hosted_file_id': hf_id_int,
'for_type': validated_ef.for_type,
'for_id': link_to_id_int
}
)
if lookup_res: res_ef_id = lookup_res.get('id')
if not isinstance(res_ef_id, int):
raise HTTPException(status_code=400, detail="Failed to create event_file record.")
# 5. Return result
if return_obj:
enriched_ef = load_event_file_obj(event_file_id=res_ef_id, inc_hosted_file=inc_hosted_file, model_as_dict=True)
# Vision Transformer: Ensure clean ID for frontend
if enriched_ef and not isinstance(enriched_ef.get('id'), str):
rid = get_id_random(res_ef_id, table_name='event_file')
enriched_ef['id'] = rid
enriched_ef['event_file_id'] = rid
return mk_resp(data=enriched_ef)
return mk_resp(data={"event_file_id": get_id_random(res_ef_id, 'event_file')})
@router.get('/{event_file_id}/download')
async def download_event_file_action(
response: Response,
event_file_id: str = Path(min_length=11, max_length=22),
filename: Optional[str] = Query(None, min_length=4, max_length=255),
key: Optional[str] = Query(None),
site_key: Optional[str] = Query(None),
range: Optional[str] = Header(None),
account: AccountContext = Depends(get_account_context_optional),
delay: DelayParams = Depends(),
):
"""
Download the underlying file for an event_file record.
Resolves event_file_id → hosted_file_id explicitly before delegating.
"""
ef_int_id = redis_lookup_id_random(record_id_random=event_file_id, table_name='event_file')
if not ef_int_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Event file record not found.")
ef_rec = sql_select(sql="SELECT hosted_file_id FROM event_file WHERE id = :id", data={'id': ef_int_id})
if not ef_rec or not ef_rec.get('hosted_file_id'):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Event file has no associated hosted file.")
hosted_file_id_random = get_id_random(record_id=ef_rec['hosted_file_id'], table_name='hosted_file')
if not hosted_file_id_random:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Hosted file record not found.")
from app.routers.api_v3_actions_hosted_file import download_file_action
return await download_file_action(
response=response,
hosted_file_id=hosted_file_id_random,
filename=filename,
key=key,
site_key=site_key,
range=range,
account=account,
delay=delay
)