Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 43 additions & 41 deletions backend/main.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import logging
import os
import stat
import tempfile
import uuid
from contextlib import asynccontextmanager
Expand All @@ -24,27 +23,23 @@ async def lifespan(app: FastAPI):
logger.info("ScriptCut backend shutting down")


app = FastAPI(
title="ScriptCut Backend",
version="0.1.0",
lifespan=lifespan,
)

app = FastAPI(title="ScriptCut Backend", version="0.1.0", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"],
allow_credentials=False,
allow_methods=["GET", "POST", "OPTIONS"],
allow_headers=["Content-Type", "Range", "X-ScriptCut-Token"],
expose_headers=["Content-Range", "Accept-Ranges", "Content-Length"],
)

LOCAL_API_TOKEN = os.getenv("SCRIPTCUT_API_TOKEN", "")
MAX_UPLOAD_BYTES = int(os.getenv("SCRIPTCUT_MAX_UPLOAD_BYTES", str(10 * 1024 * 1024 * 1024)))


@app.middleware("http")
async def require_local_api_token(request: Request, call_next):
"""Protect packaged local APIs from other processes on the same machine."""
"""Protect local APIs from other processes and browser origins."""
if (
LOCAL_API_TOKEN
and request.method != "OPTIONS"
Expand All @@ -54,6 +49,7 @@ async def require_local_api_token(request: Request, call_next):
return JSONResponse(status_code=401, content={"detail": "Unauthorized local API request"})
return await call_next(request)


app.include_router(transcribe.router)
app.include_router(export.router)
app.include_router(ai.router)
Expand All @@ -63,7 +59,6 @@ async def require_local_api_token(request: Request, call_next):
app.include_router(background.router)
app.include_router(system.router)


MIME_MAP = {
".mp4": "video/mp4",
".mkv": "video/x-matroska",
Expand All @@ -75,66 +70,76 @@ async def require_local_api_token(request: Request, call_next):
".mp3": "audio/mpeg",
".flac": "audio/flac",
}

UPLOAD_DIR = Path(tempfile.gettempdir()) / "scriptcut_uploads"
SUPPORTED_UPLOAD_EXTENSIONS = set(MIME_MAP)


@app.post("/media/upload")
async def upload_media(file: UploadFile = File(...)):
"""Accept browser-selected media and return a local backend path."""
async def upload_media(request: Request, file: UploadFile = File(...)):
"""Accept browser-selected media with a bounded disk footprint."""
content_length = request.headers.get("content-length")
if content_length:
try:
if int(content_length) > MAX_UPLOAD_BYTES:
raise HTTPException(status_code=413, detail="Upload exceeds the configured size limit")
except ValueError:
raise HTTPException(status_code=400, detail="Invalid Content-Length header")

source_name = Path(file.filename or "upload").name
suffix = Path(source_name).suffix.lower()
if suffix not in SUPPORTED_UPLOAD_EXTENSIONS:
raise HTTPException(status_code=400, detail=f"Unsupported media type: {suffix or 'unknown'}")

UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
UPLOAD_DIR.mkdir(parents=True, exist_ok=True, mode=0o700)
upload_path = UPLOAD_DIR / f"{uuid.uuid4().hex}{suffix}"
size = 0

try:
with open(upload_path, "wb") as output:
with open(upload_path, "xb") as output:
os.chmod(upload_path, 0o600)
while chunk := await file.read(1024 * 1024):
size += len(chunk)
if size > MAX_UPLOAD_BYTES:
raise HTTPException(status_code=413, detail="Upload exceeds the configured size limit")
output.write(chunk)
except Exception:
upload_path.unlink(missing_ok=True)
raise
finally:
await file.close()

return {
"path": str(upload_path),
"filename": source_name,
"size": size,
}
return {"path": str(upload_path), "filename": source_name, "size": size}


@app.get("/file")
async def serve_local_file(request: Request, path: str = Query(...)):
"""Stream a local file with HTTP Range support (required for video seeking)."""
file_path = Path(path)
"""Stream a local file with validated single-range seeking."""
file_path = Path(path).expanduser().resolve()
if not file_path.is_file():
raise HTTPException(status_code=404, detail=f"File not found: {path}")
raise HTTPException(status_code=404, detail="File not found")

file_size = file_path.stat().st_size
content_type = MIME_MAP.get(file_path.suffix.lower(), "application/octet-stream")

range_header = request.headers.get("range")
if range_header:
range_spec = range_header.replace("bytes=", "")
range_start_str, range_end_str = range_spec.split("-")
range_start = int(range_start_str) if range_start_str else 0
range_end = int(range_end_str) if range_end_str else file_size - 1
if not range_header.startswith("bytes=") or "," in range_header:
raise HTTPException(status_code=416, detail="Unsupported byte range")
try:
range_start_str, range_end_str = range_header[6:].split("-", 1)
range_start = int(range_start_str) if range_start_str else 0
range_end = int(range_end_str) if range_end_str else file_size - 1
except (ValueError, TypeError):
raise HTTPException(status_code=416, detail="Invalid byte range")
if file_size <= 0 or range_start < 0 or range_start >= file_size or range_end < range_start:
raise HTTPException(status_code=416, detail="Byte range is outside the file")
range_end = min(range_end, file_size - 1)
content_length = range_end - range_start + 1

def iter_range():
with open(file_path, "rb") as f:
f.seek(range_start)
with open(file_path, "rb") as media:
media.seek(range_start)
remaining = content_length
while remaining > 0:
chunk = f.read(min(65536, remaining))
chunk = media.read(min(65536, remaining))
if not chunk:
break
remaining -= len(chunk)
Expand All @@ -152,17 +157,14 @@ def iter_range():
)

def iter_file():
with open(file_path, "rb") as f:
while chunk := f.read(65536):
with open(file_path, "rb") as media:
while chunk := media.read(65536):
yield chunk

return StreamingResponse(
iter_file(),
media_type=content_type,
headers={
"Accept-Ranges": "bytes",
"Content-Length": str(file_size),
},
headers={"Accept-Ranges": "bytes", "Content-Length": str(file_size)},
)


Expand Down
33 changes: 33 additions & 0 deletions backend/network_security.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"""Validation for user-configurable AI provider endpoints."""

from __future__ import annotations

import ipaddress
import socket
from urllib.parse import urlparse


def validate_provider_url(value: str | None, *, allow_loopback: bool = True) -> str | None:
if value is None:
return None
url = value.strip().rstrip("/")
parsed = urlparse(url)
if parsed.scheme not in {"http", "https"} or not parsed.hostname or parsed.username or parsed.password:
raise ValueError("Provider URL must be a plain HTTP(S) origin without credentials")
if parsed.scheme == "http" and parsed.hostname not in {"localhost", "127.0.0.1", "::1"}:
raise ValueError("Non-local provider URLs must use HTTPS")

try:
addresses = {
ipaddress.ip_address(item[4][0])
for item in socket.getaddrinfo(parsed.hostname, parsed.port or (443 if parsed.scheme == "https" else 80), type=socket.SOCK_STREAM)
}
except socket.gaierror as exc:
raise ValueError("Provider hostname could not be resolved") from exc

for address in addresses:
if address.is_loopback and allow_loopback:
continue
if address.is_private or address.is_link_local or address.is_multicast or address.is_reserved or address.is_unspecified:
raise ValueError("Provider URL resolves to a blocked network address")
return url
45 changes: 36 additions & 9 deletions backend/routers/ai.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel

from network_security import validate_provider_url
from services.ai_provider import AIProvider, detect_filler_words, create_clip_suggestion, create_clip_metadata, create_edit_plan

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -69,10 +70,20 @@ class ModelListRequest(BaseModel):
api_key: Optional[str] = None


def _safe_base_url(provider: str, value: Optional[str]) -> Optional[str]:
if not value:
return None
if provider not in {"ollama", "9router"}:
raise ValueError(f"Custom base URLs are not supported for provider: {provider}")
return validate_provider_url(value, allow_loopback=True)


@router.post("/ai/filler-removal")
async def filler_removal(req: FillerRequest):
try:
return run_filler_removal(req)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Filler detection failed: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
Expand All @@ -82,6 +93,8 @@ async def filler_removal(req: FillerRequest):
async def create_clip(req: ClipRequest):
try:
return run_create_clip(req)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Clip creation failed: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
Expand All @@ -91,6 +104,8 @@ async def create_clip(req: ClipRequest):
async def clip_metadata(req: ClipMetadataRequest):
try:
return run_clip_metadata(req)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Clip metadata failed: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
Expand All @@ -100,6 +115,8 @@ async def clip_metadata(req: ClipMetadataRequest):
async def edit_plan(req: EditPlanRequest):
try:
return run_edit_plan(req)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Edit plan failed: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
Expand All @@ -115,7 +132,7 @@ def run_filler_removal(req: FillerRequest, progress_callback=None):
provider=req.provider,
model=req.model,
api_key=req.api_key,
base_url=req.base_url,
base_url=_safe_base_url(req.provider, req.base_url),
custom_filler_words=req.custom_filler_words,
)
_progress(progress_callback, 100, "Filler detection complete")
Expand All @@ -137,7 +154,7 @@ def run_create_clip(req: ClipRequest, progress_callback=None):
provider=req.provider,
model=req.model,
api_key=req.api_key,
base_url=req.base_url,
base_url=_safe_base_url(req.provider, req.base_url),
)
_progress(progress_callback, 100, "Clip discovery complete")
return result
Expand All @@ -151,7 +168,7 @@ def run_clip_metadata(req: ClipMetadataRequest, progress_callback=None):
provider=req.provider,
model=req.model,
api_key=req.api_key,
base_url=req.base_url,
base_url=_safe_base_url(req.provider, req.base_url),
)
_progress(progress_callback, 100, "Clip package complete")
return result
Expand All @@ -168,7 +185,7 @@ def run_edit_plan(req: EditPlanRequest, progress_callback=None):
provider=req.provider,
model=req.model,
api_key=req.api_key,
base_url=req.base_url,
base_url=_safe_base_url(req.provider, req.base_url),
mode=req.mode,
platform=req.platform,
target_duration=req.target_duration,
Expand All @@ -184,16 +201,26 @@ def _progress(progress_callback, percent: int, message: str):

@router.get("/ai/ollama-models")
async def ollama_models(base_url: str = "http://localhost:11434"):
models = AIProvider.list_ollama_models(base_url)
return {"models": models}
try:
models = AIProvider.list_ollama_models(validate_provider_url(base_url, allow_loopback=True) or base_url)
return {"models": models}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))


@router.get("/ai/ollama-status")
async def ollama_status(base_url: str = "http://localhost:11434"):
return AIProvider.check_ollama(base_url)
try:
return AIProvider.check_ollama(validate_provider_url(base_url, allow_loopback=True) or base_url)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))


@router.post("/ai/9router-models")
async def nine_router_models(req: ModelListRequest):
models = AIProvider.list_9router_models(req.base_url or "http://localhost:20128/v1", req.api_key)
return {"models": models}
try:
base_url = validate_provider_url(req.base_url or "http://localhost:20128/v1", allow_loopback=True)
models = AIProvider.list_9router_models(base_url or "http://localhost:20128/v1", req.api_key)
return {"models": models}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
Loading