Spaces:
Running
Running
File size: 6,464 Bytes
1d54c8c e86dfae 1d54c8c e86dfae 1d54c8c e86dfae 1d54c8c e86dfae 1d54c8c e86dfae 1d54c8c e86dfae 1d54c8c e86dfae 66e41bf e86dfae 1d54c8c e86dfae 1d54c8c e86dfae 1d54c8c e86dfae 1d54c8c | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 | """
Direct File Upload Endpoint
Bypasses UploadThing CDN for regions where it's blocked.
Uploads file directly to backend and saves locally.
"""
import logging
import os
import uuid
from datetime import datetime
from fastapi import (
APIRouter,
BackgroundTasks,
Depends,
File,
HTTPException,
UploadFile,
status,
)
from sqlalchemy.orm import Session
from app.api.deps import get_current_user_id
from app.config import settings
from app.core.url_guard import build_storage_url
from app.db.session import get_db
from app.models.audit import AuditAction, create_audit_log
from app.models.document import Document, DocumentStatus
from app.schemas.document import DocumentUploadResponse
logger = logging.getLogger(__name__)
router = APIRouter()
UPLOAD_DIR = settings.UPLOAD_DIR
MAX_FILE_SIZE = settings.MAX_FILE_SIZE_MB * 1024 * 1024
# Body is streamed in chunks of this size so a large upload never has to be
# held in memory in full.
_CHUNK_SIZE = 1024 * 1024 # 1MB
# On-disk extension is chosen from the validated content type, so a filename
# like "invoice.pdf.exe" cannot influence what lands on the volume.
_EXTENSION_FOR_TYPE = {
"application/pdf": "pdf",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx",
"text/plain": "txt",
}
@router.post(
"", response_model=DocumentUploadResponse, status_code=status.HTTP_202_ACCEPTED
)
async def upload_document(
file: UploadFile = File(...),
user_id: str = Depends(get_current_user_id),
db: Session = Depends(get_db),
):
"""
Upload a document directly and queue it for AI processing.
The body is streamed to disk with a hard size ceiling rather than buffered
in memory, and the declared content type must be one this service can
actually parse.
"""
if not file.filename:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="No filename provided",
)
content_type = (file.content_type or "").split(";")[0].strip().lower()
if content_type not in settings.ALLOWED_FILE_TYPES:
raise HTTPException(
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
detail=(
f"Unsupported file type '{content_type or 'unknown'}'. "
f"Allowed: {', '.join(settings.ALLOWED_FILE_TYPES)}"
),
)
os.makedirs(UPLOAD_DIR, exist_ok=True)
# The extension is derived from the validated content type, never from the
# user-supplied filename — the filename is attacker-controlled and only
# kept for display.
ext = _EXTENSION_FOR_TYPE.get(content_type, "bin")
local_filename = f"{uuid.uuid4().hex}.{ext}"
local_path = os.path.join(UPLOAD_DIR, local_filename)
# Stream to disk in fixed-size chunks. Reading the whole body first (the
# previous behaviour) let a client pin arbitrary bytes in RAM before the
# size check ever ran.
file_size = 0
try:
with open(local_path, "wb") as f:
while chunk := await file.read(_CHUNK_SIZE):
file_size += len(chunk)
if file_size > MAX_FILE_SIZE:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail=(
f"File too large. Max size: "
f"{settings.MAX_FILE_SIZE_MB}MB"
),
)
f.write(chunk)
if file_size == 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Uploaded file is empty",
)
except Exception:
# Never leave a partial or oversized file behind on the volume.
try:
os.unlink(local_path)
except OSError:
pass
raise
# Mirror the bytes into durable storage. UPLOAD_DIR is on the container
# filesystem, which is wiped on every restart in the deployed environment,
# so the local copy alone is a cache rather than storage. A failure here is
# logged and tolerated: the upload still works for this container's
# lifetime, and rejecting it outright would be a worse outcome than storing
# it with reduced durability.
from app.services import object_storage
if object_storage.is_configured():
with open(local_path, "rb") as f:
stored = await object_storage.put_object(
local_filename, f.read(), content_type
)
if not stored:
logger.warning(
"Upload %s is on local disk only and will not survive a "
"restart: durable storage write failed.",
local_filename,
)
else:
logger.warning(
"Durable storage is not configured; upload %s will be lost when "
"the container restarts. Set SUPABASE_URL and SUPABASE_SERVICE_KEY.",
local_filename,
)
# Internal storage reference — resolved only against UPLOAD_DIR. Storing a
# bare key rather than an absolute path keeps the row portable across
# containers and gives the SSRF guard a value it can safely confine.
file_url = build_storage_url(local_filename)
document = Document(
user_id=user_id,
title=(
file.filename.rsplit(".", 1)[0] if "." in file.filename else file.filename
),
file_name=file.filename,
file_size=file_size,
file_type=content_type,
file_url=file_url,
status=DocumentStatus.PENDING,
tags=["upload"],
)
db.add(document)
db.flush()
audit = create_audit_log(
action=AuditAction.DOCUMENT_UPLOAD.value,
user_id=user_id,
resource_type="document",
resource_id=str(document.id),
details={"file_name": file.filename, "file_size": file_size},
)
db.add(audit)
db.commit()
db.refresh(document)
from app.services.queue import enqueue_document_task
enqueue_document_task(str(document.id))
logger.info(f"Direct upload: {document.id} - {file.filename}")
return DocumentUploadResponse(
id=str(document.id),
title=document.title,
file_name=document.file_name,
status=DocumentStatus.PENDING,
job_id=str(document.id),
message="Document uploaded directly. AI analysis queued.",
)
|