dify/api/controllers/web/human_input_file_upload.py

208 lines
7.5 KiB
Python

"""HITL human input form file uploads.
This controller exposes a single public upload endpoint for both local files and
remote URLs. The caller always submits a multipart form: when a non-empty
``url`` field is present, the request follows the remote fetch flow; otherwise it
falls back to the local file upload flow.
"""
from typing import Any
from flask import request
from flask_restx import Resource
from pydantic import BaseModel, ConfigDict, Field, HttpUrl
from werkzeug.datastructures import FileStorage
import services
from controllers.common.errors import (
BlockedFileExtensionError,
FilenameNotExistsError,
FileTooLargeError,
NoFileUploadedError,
RemoteFileAccessDeniedError,
RemoteFileInvalidResponseError,
RemoteFileInvalidUrlError,
RemoteFileNotFoundError,
RemoteFileUnavailableError,
RemoteFileUrlBlockedError,
TooManyFilesError,
UnsupportedFileTypeError,
)
from controllers.common.schema import JsonResponseWithStatus, register_schema_models
from controllers.web import web_ns
from extensions.ext_application_services import application_services
from fields.file_fields import FileResponse, FileWithSignedUrl
from libs.exception import BaseHTTPException
from libs.helper import dump_response
from services.human_input_file_upload_service import (
HITL_UPLOAD_TOKEN_PREFIX,
HumanInputFileUploadService,
HumanInputUploadContext,
InvalidUploadTokenError,
)
from services.remote_file_service import (
RemoteFileAccessDeniedError as RemoteFileAccessDeniedServiceError,
)
from services.remote_file_service import (
RemoteFileInvalidResponseError as RemoteFileInvalidResponseServiceError,
)
from services.remote_file_service import RemoteFileInvalidUrlError as RemoteFileInvalidUrlServiceError
from services.remote_file_service import RemoteFileNotFoundError as RemoteFileNotFoundServiceError
from services.remote_file_service import RemoteFileUnavailableError as RemoteFileUnavailableServiceError
from services.remote_file_service import RemoteFileUrlBlockedError as RemoteFileUrlBlockedServiceError
class InvalidUploadTokenBadRequestError(BaseHTTPException):
error_code = "invalid_upload_token"
description = "Invalid upload token."
code = 400
class InvalidUploadTokenUnauthorizedError(BaseHTTPException):
error_code = "invalid_upload_token"
description = "Upload token is required."
code = 401
class InvalidUploadTokenForbiddenError(BaseHTTPException):
error_code = "invalid_upload_token"
description = "Upload token is invalid or expired."
code = 403
class HumanInputFileUploadFormPayload(BaseModel):
"""Parsed multipart form fields for HITL uploads."""
model_config = ConfigDict(extra="ignore")
url: HttpUrl | None = Field(default=None, description="Remote file URL")
register_schema_models(web_ns, HumanInputFileUploadFormPayload, FileResponse, FileWithSignedUrl)
def _extract_hitl_upload_token() -> str:
"""Read HITL upload token from Authorization without invoking other bearer auth chains."""
authorization = request.headers.get("Authorization")
if authorization is None:
raise InvalidUploadTokenUnauthorizedError()
parts = authorization.split()
if len(parts) != 2:
raise InvalidUploadTokenUnauthorizedError()
scheme, token = parts
if scheme.lower() != "bearer":
raise InvalidUploadTokenBadRequestError()
if not token:
raise InvalidUploadTokenUnauthorizedError()
if not token.startswith(HITL_UPLOAD_TOKEN_PREFIX):
raise InvalidUploadTokenBadRequestError()
return token
def _validate_context(service: HumanInputFileUploadService, token: str) -> HumanInputUploadContext:
try:
return service.validate_upload_token(token)
except InvalidUploadTokenError as exc:
raise InvalidUploadTokenForbiddenError() from exc
def _parse_local_upload_file() -> FileStorage:
if "file" not in request.files:
raise NoFileUploadedError()
if len(request.files) > 1:
raise TooManyFilesError()
file = request.files["file"]
if not file.filename:
raise FilenameNotExistsError()
return file
def _parse_upload_form() -> HumanInputFileUploadFormPayload:
return HumanInputFileUploadFormPayload.model_validate(request.form.to_dict(flat=True))
def _upload_local_file(
*,
service: HumanInputFileUploadService,
context: HumanInputUploadContext,
) -> dict[str, Any]:
file = _parse_local_upload_file()
try:
upload_file = service.upload_local_file(
context=context,
filename=file.filename or "",
content=file.read(),
mimetype=file.mimetype,
)
except services.errors.file.FileTooLargeError as file_too_large_error:
raise FileTooLargeError(file_too_large_error.description or "File size exceeded.") from file_too_large_error
except services.errors.file.UnsupportedFileTypeError as error:
raise UnsupportedFileTypeError() from error
except services.errors.file.BlockedFileExtensionError as exc:
raise BlockedFileExtensionError() from exc
return dump_response(FileResponse, upload_file)
def _upload_remote_file(
*,
service: HumanInputFileUploadService,
context: HumanInputUploadContext,
url: str,
) -> dict[str, Any]:
try:
upload_file = service.upload_remote_file(context=context, url=url)
except RemoteFileInvalidUrlServiceError as error:
raise RemoteFileInvalidUrlError() from error
except RemoteFileUrlBlockedServiceError as error:
raise RemoteFileUrlBlockedError() from error
except RemoteFileNotFoundServiceError as error:
raise RemoteFileNotFoundError() from error
except RemoteFileAccessDeniedServiceError as error:
raise RemoteFileAccessDeniedError() from error
except RemoteFileUnavailableServiceError as error:
raise RemoteFileUnavailableError() from error
except RemoteFileInvalidResponseServiceError as error:
raise RemoteFileInvalidResponseError() from error
except services.errors.file.FileTooLargeError as file_too_large_error:
raise FileTooLargeError(file_too_large_error.description or "File size exceeded.") from file_too_large_error
except services.errors.file.UnsupportedFileTypeError as error:
raise UnsupportedFileTypeError() from error
except services.errors.file.BlockedFileExtensionError as exc:
raise BlockedFileExtensionError() from exc
return dump_response(FileWithSignedUrl, upload_file)
@web_ns.route("/human-input-forms/files")
@web_ns.response(201, "File uploaded successfully", web_ns.models[FileResponse.__name__])
class HumanInputFileUploadApi(Resource):
def post(self) -> JsonResponseWithStatus:
"""Upload one local file or remote URL file for a HITL human input form."""
token = _extract_hitl_upload_token()
upload_service = application_services().human_input_file_uploads
context = _validate_context(upload_service, token)
form = _parse_upload_form()
# The browser always submits multipart/form-data. A non-empty `url`
# switches the endpoint into the remote-fetch flow; otherwise the
# request must carry a local `file`.
if form.url is not None:
response = _upload_remote_file(
service=upload_service,
context=context,
url=str(form.url),
)
else:
response = _upload_local_file(service=upload_service, context=context)
# response-contract:ignore pre-dumped response. See above
return response, 201