99from fastapi import APIRouter , Body , File , Form , Header , HTTPException , Path as PathParam , Query , UploadFile
1010from fastapi .responses import JSONResponse , RedirectResponse , StreamingResponse
1111
12+ from consts .exceptions import FileTooLargeException , NotFoundException , OfficeConversionException , UnsupportedFileTypeException
1213from consts .model import ProcessParams
1314from services .file_management_service import upload_to_minio , upload_files_impl , \
14- get_file_url_impl , get_file_stream_impl , delete_file_impl , list_files_impl
15+ get_file_url_impl , get_file_stream_impl , delete_file_impl , list_files_impl , \
16+ preview_file_impl
1517from utils .file_management_utils import trigger_data_process
1618
1719logger = logging .getLogger ("file_management_app" )
1820
1921
20- def build_content_disposition_header (filename : Optional [str ]) -> str :
22+ def build_content_disposition_header (filename : Optional [str ], inline : bool = False ) -> str :
2123 """
2224 Build a Content-Disposition header that keeps the original filename.
2325
26+ Args:
27+ filename: Original filename to include in header
28+ inline: If True, use 'inline' disposition (for preview); otherwise 'attachment' (for download)
29+
2430 - ASCII filenames are returned directly.
2531 - Non-ASCII filenames include both an ASCII fallback and RFC 5987 encoded value
2632 so modern browsers keep the original name.
2733 """
34+ disposition = "inline" if inline else "attachment"
2835 safe_name = (filename or "download" ).strip () or "download"
2936
3037 def _sanitize_ascii (value : str ) -> str :
@@ -40,26 +47,26 @@ def _sanitize_ascii(value: str) -> str:
4047
4148 try :
4249 safe_name .encode ("ascii" )
43- return f'attachment ; filename="{ _sanitize_ascii (safe_name )} "'
50+ return f'{ disposition } ; filename="{ _sanitize_ascii (safe_name )} "'
4451 except UnicodeEncodeError :
4552 try :
4653 encoded = quote (safe_name , safe = "" )
4754 except Exception :
4855 # quote failure, fallback to sanitized ASCII only
4956 logger .warning ("Failed to encode filename '%s', using fallback" , safe_name )
50- return f'attachment ; filename="{ _sanitize_ascii (safe_name )} "'
57+ return f'{ disposition } ; filename="{ _sanitize_ascii (safe_name )} "'
5158
5259 fallback = _sanitize_ascii (
5360 safe_name .encode ("ascii" , "ignore" ).decode ("ascii" ) or "download"
5461 )
55- return f'attachment ; filename="{ fallback } "; filename*=UTF-8\' \' { encoded } '
62+ return f'{ disposition } ; filename="{ fallback } "; filename*=UTF-8\' \' { encoded } '
5663 except Exception as exc : # pragma: no cover
5764 logger .warning (
5865 "Failed to encode filename '%s': %s. Using fallback." ,
5966 safe_name ,
6067 exc ,
6168 )
62- return 'attachment ; filename="download"'
69+ return f' { disposition } ; filename="download"'
6370
6471# Create API router
6572file_management_runtime_router = APIRouter (prefix = "/file" )
@@ -567,3 +574,69 @@ async def get_storage_file_batch_urls(
567574 "failed_count" : sum (1 for r in results if not r .get ("success" , False )),
568575 "results" : results
569576 }
577+
578+ @file_management_config_router .get ("/preview/{object_name:path}" )
579+ async def preview_file (
580+ object_name : str = PathParam (..., description = "File object name to preview" ),
581+ filename : Optional [str ] = Query (None , description = "Original filename for display (optional)" )
582+ ):
583+ """
584+ Preview file inline in browser
585+
586+ - **object_name**: File object name in storage
587+ - **filename**: Original filename for Content-Disposition header (optional)
588+
589+ Returns file stream with Content-Disposition: inline for browser preview
590+ """
591+ try :
592+ # Get file stream from preview service
593+ file_stream , content_type = await preview_file_impl (object_name = object_name )
594+
595+ # Use provided filename or extract from object_name
596+ display_filename = filename
597+ if not display_filename :
598+ display_filename = object_name .split ("/" )[- 1 ] if "/" in object_name else object_name
599+
600+ # Build Content-Disposition header for inline display
601+ content_disposition = build_content_disposition_header (display_filename , inline = True )
602+
603+ return StreamingResponse (
604+ file_stream ,
605+ media_type = content_type ,
606+ headers = {
607+ "Content-Disposition" : content_disposition ,
608+ "Cache-Control" : "public, max-age=3600" ,
609+ "ETag" : f'"{ object_name } "' ,
610+ }
611+ )
612+
613+ except FileTooLargeException as e :
614+ logger .warning (f"[preview_file] File too large: object_name={ object_name } , error={ str (e )} " )
615+ raise HTTPException (
616+ status_code = HTTPStatus .REQUEST_ENTITY_TOO_LARGE ,
617+ detail = str (e )
618+ )
619+ except NotFoundException as e :
620+ logger .error (f"[preview_file] File not found: object_name={ object_name } , error={ str (e )} " )
621+ raise HTTPException (
622+ status_code = HTTPStatus .NOT_FOUND ,
623+ detail = f"File not found: { object_name } "
624+ )
625+ except UnsupportedFileTypeException as e :
626+ logger .error (f"[preview_file] Unsupported file type: object_name={ object_name } , error={ str (e )} " )
627+ raise HTTPException (
628+ status_code = HTTPStatus .BAD_REQUEST ,
629+ detail = f"File format not supported for preview: { str (e )} "
630+ )
631+ except OfficeConversionException as e :
632+ logger .error (f"[preview_file] Conversion failed: object_name={ object_name } , error={ str (e )} " )
633+ raise HTTPException (
634+ status_code = HTTPStatus .INTERNAL_SERVER_ERROR ,
635+ detail = f"Failed to preview file: { str (e )} "
636+ )
637+ except Exception as e :
638+ logger .error (f"[preview_file] Unexpected error: object_name={ object_name } , error={ str (e )} " )
639+ raise HTTPException (
640+ status_code = HTTPStatus .INTERNAL_SERVER_ERROR ,
641+ detail = f"Failed to preview file: { str (e )} "
642+ )
0 commit comments