Files
dify/api/controllers/web/human_input_form_access_request.py
T
8cbb6c0130 feat: HITL IM integration (#44864)
Co-authored-by: zxhlyh <jasonapring2015@outlook.com>
Co-authored-by: JzoNg <jzongcode@gmail.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-10-30 02:43:31 +00:00

190 lines
7.7 KiB
Python

"""Public web Human Input v2 form transport.
The versioned public routes are separate from legacy v1 forms and Web App
passport-authenticated forms. Each runtime path must reject tokens owned by the other
version when the service implementation is added.
"""
from __future__ import annotations
from typing import NoReturn
from flask import request
from flask_restx import Resource
from controllers.common.human_input_v2_contracts import (
FormAccessRequestResponse,
FormDefinitionResponse,
FormSubmitResponse,
FormUploadTokenResponse,
HumanInputV2FormSubmitRequest,
)
from controllers.common.human_input_v2_errors import (
HumanInputAccessDeliveryError,
HumanInputAccessRateLimitError,
HumanInputExpiredChallengeError,
HumanInputInvalidOtpError,
HumanInputStaleChallengeError,
HumanInputV2InvalidInputError,
HumanInputV2UnavailableError,
HumanInputV2WorkflowNotActiveError,
PublicHumanInputV2ExpiredError,
PublicHumanInputV2NotFoundError,
PublicHumanInputV2SubmittedError,
email_otp_http_error,
public_form_http_error,
)
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.console.wraps import model_validate
from controllers.web import web_ns
from controllers.web.human_input_file_upload import InvalidUploadTokenForbiddenError
from core.db.session_factory import session_factory
from factories.human_input_v2_form_presentation import build_form_presentation
from libs.datetime_utils import to_utc_timestamp
from libs.external_api import error_responses
from libs.helper import dump_response, extract_remote_ip
from services.human_input_file_upload_service import InvalidUploadTokenError
from services.human_input_v2.email_otp_composition import build_email_otp_service
from services.human_input_v2.email_otp_service import EmailOtpError
from services.human_input_v2.file_upload_service import build_file_upload_service
from services.human_input_v2.submission_service import (
HumanInputFormError,
HumanInputSubmissionService,
)
register_schema_models(web_ns, HumanInputV2FormSubmitRequest)
register_response_schema_models(
web_ns,
FormAccessRequestResponse,
FormDefinitionResponse,
FormSubmitResponse,
FormUploadTokenResponse,
)
def _raise_public_form_error(error: HumanInputFormError) -> NoReturn:
raise public_form_http_error(error) from error
@web_ns.route("/form/human-input/v2/public/<string:form_token>")
class HumanInputV2FormApi(Resource):
"""Read or submit a Human Input v2 form without sharing v1 submission logic."""
@error_responses(PublicHumanInputV2NotFoundError, PublicHumanInputV2SubmittedError, PublicHumanInputV2ExpiredError)
@web_ns.doc(
description="Read a public v2 form definition by delivery token without an OTP or login session.",
security=[],
responses={404: "Form not found", 412: "Form already submitted or expired"},
)
@web_ns.response(200, "Form definition", web_ns.models[FormDefinitionResponse.__name__])
def get(self, form_token: str):
service = HumanInputSubmissionService(session_factory=session_factory.get_session_maker())
try:
form = service.get_public_form(form_token=form_token, ip_address=extract_remote_ip(request) or None)
except HumanInputFormError as error:
_raise_public_form_error(error)
presentation = build_form_presentation(form.resolved_form)
return dump_response(
FormDefinitionResponse,
{
"form_content": presentation.form_content,
"inputs": presentation.inputs,
"resolved_default_values": presentation.resolved_default_values,
"user_actions": presentation.user_actions,
"expiration_time": to_utc_timestamp(min(form.expiration_time, form.global_timeout_deadline)),
},
)
@error_responses(
PublicHumanInputV2NotFoundError,
PublicHumanInputV2SubmittedError,
PublicHumanInputV2ExpiredError,
HumanInputV2WorkflowNotActiveError,
HumanInputV2InvalidInputError,
HumanInputExpiredChallengeError,
HumanInputInvalidOtpError,
HumanInputStaleChallengeError,
HumanInputV2UnavailableError,
)
@web_ns.expect(web_ns.models[HumanInputV2FormSubmitRequest.__name__])
@web_ns.doc(
description="Submit a form with inputs, an action, and email verification proof.",
security=[],
responses={
400: "Invalid form or email proof",
404: "Form not found",
409: "Workflow not active",
412: "Already submitted or expired",
422: "Invalid request body",
503: "Email verification unavailable",
},
)
@web_ns.response(200, "Form submitted", web_ns.models[FormSubmitResponse.__name__])
@model_validate(HumanInputV2FormSubmitRequest)
def post(self, payload: HumanInputV2FormSubmitRequest, form_token: str):
service = HumanInputSubmissionService(session_factory=session_factory.get_session_maker())
try:
service.submit_public_form(
email_otp_service=build_email_otp_service(),
form_token=form_token,
action=payload.action,
inputs=payload.inputs,
challenge_token=payload.challenge_token,
otp_code=payload.otp_code,
ip_address=extract_remote_ip(request) or None,
)
except EmailOtpError as error:
raise email_otp_http_error(error) from error
except HumanInputFormError as error:
_raise_public_form_error(error)
return FormSubmitResponse().model_dump(mode="json")
@web_ns.route("/form/human-input/v2/public/<string:form_token>/upload-token")
class HumanInputV2FormUploadTokenApi(Resource):
"""Issue an upload token for an active Human Input v2 form."""
@error_responses(
PublicHumanInputV2NotFoundError,
PublicHumanInputV2SubmittedError,
PublicHumanInputV2ExpiredError,
InvalidUploadTokenForbiddenError,
)
@web_ns.response(200, "Success", web_ns.models[FormUploadTokenResponse.__name__])
@web_ns.doc(security=[], responses={404: "Form not found", 412: "Already submitted or expired"})
def post(self, form_token: str):
try:
grant = build_file_upload_service().issue_public_upload_token(form_token)
except HumanInputFormError as error:
_raise_public_form_error(error)
except InvalidUploadTokenError as error:
raise InvalidUploadTokenForbiddenError() from error
return FormUploadTokenResponse(
upload_token=grant.upload_token, expires_at=to_utc_timestamp(grant.expires_at)
).model_dump(mode="json")
@web_ns.route("/form/human-input/v2/public/<string:form_token>/access-request")
class FormAccessRequestApi(Resource):
@error_responses(
PublicHumanInputV2NotFoundError,
PublicHumanInputV2SubmittedError,
PublicHumanInputV2ExpiredError,
HumanInputAccessRateLimitError,
HumanInputAccessDeliveryError,
HumanInputV2UnavailableError,
)
@web_ns.response(200, "Success", web_ns.models[FormAccessRequestResponse.__name__])
def post(self, form_token: str):
try:
challenge = build_email_otp_service().request_access(
form_token, ip_address=extract_remote_ip(request) or None
)
except EmailOtpError as error:
raise email_otp_http_error(error) from error
return FormAccessRequestResponse(
challenge_token=challenge.challenge_token,
expires_in_seconds=challenge.expires_in_seconds,
resend_after_seconds=challenge.resend_after_seconds,
).model_dump(mode="json")