| 1 | # Copyright (C) 2025 AIDC-AI |
| 2 | # |
| 3 | # Licensed under the Apache License, Version 2.0 (the "License"); |
| 4 | # you may not use this file except in compliance with the License. |
| 5 | # You may obtain a copy of the License at |
| 6 | # http://www.apache.org/licenses/LICENSE-2.0 |
| 7 | # Unless required by applicable law or agreed to in writing, software |
| 8 | # distributed under the License is distributed on an "AS IS" BASIS, |
| 9 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 10 | # See the License for the specific language governing permissions and |
| 11 | # limitations under the License. |
| 12 | |
| 13 | """ |
| 14 | File service endpoints |
| 15 | |
| 16 | Provides access to generated files (videos, images, audio) and resource files. |
| 17 | """ |
| 18 | |
| 19 | from pathlib import Path |
| 20 | from fastapi import APIRouter, HTTPException |
| 21 | from fastapi.responses import FileResponse |
| 22 | from loguru import logger |
| 23 | |
| 24 | router = APIRouter(prefix="/files", tags=["Files"]) |
| 25 | |
| 26 | |
| 27 | @router.get("/{file_path:path}") |
| 28 | async def get_file(file_path: str): |
| 29 | """ |
| 30 | Get file by path |
| 31 | |
| 32 | Serves files from allowed directories: |
| 33 | - output/ - Generated files (videos, images, audio) |
| 34 | - workflows/ - ComfyUI workflow files |
| 35 | - templates/ - HTML templates |
| 36 | - bgm/ - Background music |
| 37 | - data/bgm/ - Custom background music |
| 38 | - data/templates/ - Custom templates |
| 39 | - resources/ - Other resources (images, fonts, etc.) |
| 40 | |
| 41 | - **file_path**: File path relative to allowed directories |
| 42 | |
| 43 | Examples: |
| 44 | - "abc123.mp4" → output/abc123.mp4 |
| 45 | - "workflows/runninghub/image_flux.json" → workflows/runninghub/image_flux.json |
| 46 | - "templates/1080x1920/default.html" → templates/1080x1920/default.html |
| 47 | - "bgm/default.mp3" → bgm/default.mp3 |
| 48 | - "resources/example.png" → resources/example.png |
| 49 | |
| 50 | Returns file for download or preview. |
| 51 | """ |
| 52 | try: |
| 53 | # Define allowed directories (in priority order) |
| 54 | allowed_prefixes = [ |
| 55 | "output/", |
| 56 | "workflows/", |
| 57 | "templates/", |
| 58 | "bgm/", |
| 59 | "data/bgm/", |
| 60 | "data/templates/", |
| 61 | "resources/", |
| 62 | ] |
| 63 | |
| 64 | # Check if path starts with allowed prefix, otherwise try output/ |
| 65 | full_path = None |
| 66 | for prefix in allowed_prefixes: |
| 67 | if file_path.startswith(prefix): |
| 68 | full_path = file_path |
| 69 | break |
| 70 | |
| 71 | # If no prefix matched, assume it's in output/ (backward compatibility) |
| 72 | if full_path is None: |
| 73 | full_path = f"output/{file_path}" |
| 74 | |
| 75 | abs_path = Path.cwd() / full_path |
| 76 | |
| 77 | if not abs_path.exists(): |
| 78 | raise HTTPException(status_code=404, detail=f"File not found: {file_path}") |
| 79 | |
| 80 | if not abs_path.is_file(): |
| 81 | raise HTTPException(status_code=400, detail=f"Path is not a file: {file_path}") |
| 82 | |
| 83 | # Security: only allow access to specified directories |
| 84 | try: |
| 85 | rel_path = abs_path.relative_to(Path.cwd()) |
| 86 | rel_path_str = str(rel_path) |
| 87 | |
| 88 | # Check if path starts with any allowed prefix |
| 89 | is_allowed = any(rel_path_str.startswith(prefix.rstrip('/')) for prefix in allowed_prefixes) |
| 90 | |
| 91 | if not is_allowed: |
| 92 | raise HTTPException( |
| 93 | status_code=403, |
| 94 | detail=f"Access denied: only {', '.join(p.rstrip('/') for p in allowed_prefixes)} directories are accessible" |
| 95 | ) |
| 96 | except ValueError: |
| 97 | raise HTTPException(status_code=403, detail="Access denied") |
| 98 | |
| 99 | # Determine media type |
| 100 | suffix = abs_path.suffix.lower() |
| 101 | media_types = { |
| 102 | '.mp4': 'video/mp4', |
| 103 | '.mp3': 'audio/mpeg', |
| 104 | '.wav': 'audio/wav', |
| 105 | '.png': 'image/png', |
| 106 | '.jpg': 'image/jpeg', |
| 107 | '.jpeg': 'image/jpeg', |
| 108 | '.gif': 'image/gif', |
| 109 | '.html': 'text/html', |
| 110 | '.json': 'application/json', |
| 111 | } |
| 112 | media_type = media_types.get(suffix, 'application/octet-stream') |
| 113 | |
| 114 | # Use inline disposition for browser preview |
| 115 | return FileResponse( |
| 116 | path=str(abs_path), |
| 117 | media_type=media_type, |
| 118 | headers={ |
| 119 | "Content-Disposition": f'inline; filename="{abs_path.name}"' |
| 120 | } |
| 121 | ) |
| 122 | |
| 123 | except HTTPException: |
| 124 | raise |
| 125 | except Exception as e: |
| 126 | logger.error(f"File access error: {e}") |
| 127 | raise HTTPException(status_code=500, detail=str(e)) |
| 128 | |
| 129 |