mirror of
https://github.com/langgenius/dify.git
synced 2026-11-12 22:31:24 +08:00
303 lines
14 KiB
Python
303 lines
14 KiB
Python
"""Whole-Guest Web resource migration after Account login."""
|
|
|
|
from typing import Annotated, Literal
|
|
|
|
from flask import request
|
|
from flask_restx import Resource
|
|
from pydantic import BaseModel, ConfigDict, Field
|
|
|
|
from configs import dify_config
|
|
from controllers.common.end_user_connections import ConnectionConflictHTTPError, ConnectionLoginRequiredError
|
|
from controllers.common.schema import register_response_schema_models, register_schema_models
|
|
from controllers.common.web_login import web_login_errors
|
|
from controllers.console.wraps import model_validate
|
|
from controllers.web import web_ns
|
|
from controllers.web.error import WebAppAuthAccessDeniedError, WebAppNotFoundError
|
|
from extensions.ext_application_services import application_services
|
|
from fields.base import ResponseModel
|
|
from libs.exception import BaseHTTPException
|
|
from libs.external_api import error_responses
|
|
from libs.helper import dump_response
|
|
from libs.token import extract_webapp_access_token
|
|
from repositories.entities.web_execution import EndUserBusyError
|
|
from services.end_user_connections.errors import ConnectionConflictError, ConnectionIdentityError
|
|
from services.web_migration_service import (
|
|
ConnectionClaimFailure,
|
|
ConnectionClaimPreflightError,
|
|
ConnectionClaimUnavailableError,
|
|
GuestMigrationNotReadyError,
|
|
)
|
|
from services.web_passport_service import (
|
|
WebPassportAccessDeniedError,
|
|
WebPassportAccountExchangeDisabledError,
|
|
WebPassportAuthenticationRequiredError,
|
|
WebPassportNotFoundError,
|
|
WebPassportUnauthorizedError,
|
|
)
|
|
|
|
|
|
class ConnectionClaimPayload(BaseModel):
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
passports: list[Annotated[str, Field(min_length=1, max_length=8192)]] = Field(
|
|
min_length=1, max_length=50, repr=False
|
|
)
|
|
|
|
|
|
class ConnectionClaimSuccessResponse(ResponseModel):
|
|
status: Literal["success"]
|
|
index: int = Field(ge=0)
|
|
app_code: str
|
|
access_token: str
|
|
claimed_conversations: int = Field(
|
|
ge=0, description="Chat conversations transferred for supported Workflow/Chatflow/Chatbot Apps"
|
|
)
|
|
stop_requested_run_count: int = Field(
|
|
ge=0,
|
|
description="Guest Runs with accepted stop requests; transfer waits until Guest Runs are terminal",
|
|
)
|
|
claimed_connections: int = Field(
|
|
ge=0, description="Number transferred by this item; zero when no Guest Connections remain"
|
|
)
|
|
|
|
|
|
class ConnectionClaimErrorResponse(ResponseModel):
|
|
code: Literal[
|
|
"app_not_found",
|
|
"connection_claim_unavailable",
|
|
"connection_conflict",
|
|
"end_user_busy",
|
|
"end_user_login_required",
|
|
"migration_not_ready",
|
|
"unauthorized",
|
|
"web_app_access_denied",
|
|
]
|
|
message: str
|
|
status: int
|
|
|
|
|
|
class ConnectionClaimFailureResponse(ResponseModel):
|
|
status: Literal["failed"]
|
|
index: int = Field(ge=0)
|
|
error: ConnectionClaimErrorResponse
|
|
|
|
|
|
class ConnectionClaimPreflightDetails(BaseModel):
|
|
failures: list[ConnectionClaimFailureResponse]
|
|
|
|
|
|
class ConnectionClaimPreflightHTTPError(BaseHTTPException):
|
|
code = 400
|
|
error_code = "connection_claim_preflight_failed"
|
|
description = "Guest migration preflight failed. No resources were transferred."
|
|
details_model = ConnectionClaimPreflightDetails
|
|
|
|
|
|
class ConnectionClaimUnavailableHTTPError(BaseHTTPException):
|
|
code = 503
|
|
error_code = "connection_claim_unavailable"
|
|
description = "Guest migration is temporarily unavailable. Please retry."
|
|
|
|
|
|
class ConnectionClaimResponse(ResponseModel):
|
|
data: list[
|
|
Annotated[ConnectionClaimSuccessResponse | ConnectionClaimFailureResponse, Field(discriminator="status")]
|
|
]
|
|
|
|
|
|
def claim_error_response(failure: ConnectionClaimFailure) -> ConnectionClaimErrorResponse:
|
|
error = failure.error
|
|
if isinstance(error, GuestMigrationNotReadyError):
|
|
return ConnectionClaimErrorResponse(code="migration_not_ready", status=409, message=str(error))
|
|
if isinstance(error, EndUserBusyError):
|
|
return ConnectionClaimErrorResponse(code="end_user_busy", status=409, message=str(error))
|
|
if isinstance(error, ConnectionIdentityError):
|
|
return ConnectionClaimErrorResponse(code="unauthorized", status=401, message=str(error))
|
|
if isinstance(error, WebPassportNotFoundError):
|
|
return ConnectionClaimErrorResponse(
|
|
code="app_not_found", status=404, message=str(WebAppNotFoundError.description)
|
|
)
|
|
if isinstance(error, (WebPassportAuthenticationRequiredError, WebPassportUnauthorizedError)):
|
|
return ConnectionClaimErrorResponse(
|
|
code="end_user_login_required", status=401, message=str(ConnectionLoginRequiredError.description)
|
|
)
|
|
if isinstance(error, WebPassportAccessDeniedError):
|
|
return ConnectionClaimErrorResponse(
|
|
code="web_app_access_denied", status=401, message=str(WebAppAuthAccessDeniedError.description)
|
|
)
|
|
if isinstance(error, ConnectionConflictError):
|
|
return ConnectionClaimErrorResponse(
|
|
code="connection_conflict", status=409, message=str(ConnectionConflictHTTPError.description)
|
|
)
|
|
if isinstance(error, ConnectionClaimUnavailableError):
|
|
return ConnectionClaimErrorResponse(code="connection_claim_unavailable", status=503, message=str(error))
|
|
raise error
|
|
|
|
|
|
register_schema_models(web_ns, ConnectionClaimPayload)
|
|
register_response_schema_models(
|
|
web_ns,
|
|
ConnectionClaimSuccessResponse,
|
|
ConnectionClaimErrorResponse,
|
|
ConnectionClaimFailureResponse,
|
|
ConnectionClaimResponse,
|
|
)
|
|
|
|
|
|
@web_ns.route("/migration/claim")
|
|
class ConnectionClaimApi(Resource):
|
|
@web_ns.doc(
|
|
description="Claim up to 50 anonymous browser Passports across Web Apps for one authenticated Account. "
|
|
"Submit Guest Passports in passports, with one Account Web login proof "
|
|
"in its HttpOnly cookie or Authorization. "
|
|
"Complete the login handoff through /api/passport before asking the user to confirm Claim. "
|
|
"Validate every source Passport and Account App admission before any transfer or identity creation. "
|
|
"Preflight failures return 400 connection_claim_preflight_failed with details.failures indexed by input. "
|
|
"An unavailable preflight dependency returns 503; neither failure starts a transfer. "
|
|
"Items run in input order with independent transactions; every item returns its index and success or failure. "
|
|
"Before transfer, request cancellation of persisted unfinished Guest Workflow/Chatflow Runs "
|
|
"through the existing stop flow, then poll until no unfinished Guest Runs remain. "
|
|
"The batch shares a five-second readiness window with 100ms polling outside migration transactions. "
|
|
"Timeout returns migration_not_ready (nested status 409) for that item without transferring its resources. "
|
|
"Stop failure also prevents that Passport from transferring resources. "
|
|
"Each Passport atomically transfers Connections, files and all conversations with their personal state. "
|
|
"Run terminal status is checked again inside each transfer transaction. "
|
|
"Ordinary Chatbot Apps transfer committed history without stopping or waiting for generation. "
|
|
"In-flight Chatbot requests may fail after transfer; uncommitted answers may be lost. "
|
|
"Their history reads and writes recheck the original caller's current conversation ownership. "
|
|
"Existing model calls or streams are not guaranteed to stop immediately. "
|
|
"Legacy agent configurations and non-Chatbot Guest history remain unsupported for Chatbot Claim. "
|
|
"Concurrent migration or identity updates may report end_user_busy without waiting. "
|
|
"Web execution registers each Run under the shared identity fence before launch. "
|
|
"Transfer takes the exclusive fence and rechecks unfinished Runs before changing ownership. "
|
|
"Success includes the App code, Account App Passport, transferred counts and accepted stop count. "
|
|
"Cancellation is not rolled back if the subsequent migration fails. "
|
|
"Keep the Guest Passport to retry remaining resources with the same Account. "
|
|
"The Guest Passport is not bound to the Account by migration. "
|
|
"Retries transfer only resources still owned by the Guest; empty resource categories report zero. "
|
|
"New Guest resources are included. "
|
|
"Account defaults take precedence; Conversation IDs, history and execution provenance are preserved. "
|
|
"Resubmit the same batch or retry failed items separately; no cross-App atomicity is provided."
|
|
)
|
|
@web_ns.expect(web_ns.models[ConnectionClaimPayload.__name__])
|
|
@web_ns.response(200, "Whole Guest migration results", web_ns.models[ConnectionClaimResponse.__name__])
|
|
@error_responses(
|
|
ConnectionLoginRequiredError, ConnectionClaimPreflightHTTPError, ConnectionClaimUnavailableHTTPError
|
|
)
|
|
@model_validate(ConnectionClaimPayload)
|
|
def post(self, payload: ConnectionClaimPayload):
|
|
with web_login_errors():
|
|
if not dify_config.WEBAPP_ACCOUNT_PASSPORT_ENABLED:
|
|
raise WebPassportAccountExchangeDisabledError()
|
|
login_token = extract_webapp_access_token(request) or ""
|
|
try:
|
|
results = application_services().web_migration.claim(payload.passports, access_token=login_token)
|
|
except ConnectionClaimPreflightError as exc:
|
|
error = ConnectionClaimPreflightHTTPError()
|
|
assert error.data is not None
|
|
error.data["details"] = ConnectionClaimPreflightDetails(
|
|
failures=[
|
|
ConnectionClaimFailureResponse(
|
|
status="failed", index=failure.index, error=claim_error_response(failure)
|
|
)
|
|
for failure in exc.failures
|
|
]
|
|
).model_dump(mode="json")
|
|
raise error from exc
|
|
except ConnectionClaimUnavailableError as exc:
|
|
raise ConnectionClaimUnavailableHTTPError() from exc
|
|
data: list[ConnectionClaimSuccessResponse | ConnectionClaimFailureResponse] = []
|
|
for result in results:
|
|
if isinstance(result, ConnectionClaimFailure):
|
|
data.append(
|
|
ConnectionClaimFailureResponse(
|
|
status="failed", index=result.index, error=claim_error_response(result)
|
|
)
|
|
)
|
|
else:
|
|
data.append(
|
|
ConnectionClaimSuccessResponse(
|
|
status="success",
|
|
index=result.index,
|
|
app_code=result.app_code,
|
|
access_token=result.access_token,
|
|
claimed_connections=result.claimed_connections,
|
|
claimed_conversations=result.claimed_conversations,
|
|
stop_requested_run_count=result.stop_requested_runs,
|
|
)
|
|
)
|
|
return (
|
|
dump_response(ConnectionClaimResponse, {"data": data}),
|
|
200,
|
|
{"Cache-Control": "no-store"},
|
|
)
|
|
|
|
|
|
class ClaimablePassportsPayload(BaseModel):
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
passports: list[Annotated[str, Field(min_length=1, max_length=8192)]] = Field(max_length=50, repr=False)
|
|
|
|
|
|
class ClaimablePassportResponse(ResponseModel):
|
|
index: int = Field(ge=0, description="Index of the candidate in the request's passports list")
|
|
app_code: str
|
|
app_name: str
|
|
conversation_count: int = Field(
|
|
ge=0, description="Remaining Chatflow/Chatbot conversations; Workflow normally has zero"
|
|
)
|
|
connection_count: int = Field(
|
|
ge=0, description="Current Guest Connection count; does not determine Passport eligibility"
|
|
)
|
|
|
|
|
|
class ClaimablePassportsResponse(ResponseModel):
|
|
data: list[ClaimablePassportResponse]
|
|
|
|
|
|
register_schema_models(web_ns, ClaimablePassportsPayload)
|
|
register_response_schema_models(web_ns, ClaimablePassportResponse, ClaimablePassportsResponse)
|
|
|
|
|
|
@web_ns.route("/migration/claimable-passports")
|
|
class ClaimablePassportsApi(Resource):
|
|
@web_ns.doc(
|
|
description="Check anonymous Passports held by this browser across Web Apps without changing identity or data. "
|
|
"Submit local candidates; return request indices, App names/codes and current Connection/conversation counts. "
|
|
"Invalid, expired, duplicate, Account and SSO candidates are omitted. No Account login is required. "
|
|
"Unavailable, disabled and unpublished Web Apps are filtered out. "
|
|
"Zero-Connection Guest Passports are included. "
|
|
"Supports Workflow, Chatflow and ordinary Chatbot Apps; other App modes are omitted. "
|
|
"Chatbot Apps with legacy agent configurations or non-Chatbot Guest history are omitted. "
|
|
"conversation_count counts Chatflow/Chatbot history; Workflow normally has none. "
|
|
"connection_count counts all currently Guest-owned Connections, including ones needing reconnection. "
|
|
"This is advisory: actual Claim rechecks ownership and Account App admission."
|
|
)
|
|
@web_ns.expect(web_ns.models[ClaimablePassportsPayload.__name__])
|
|
@web_ns.response(200, "Claimable browser Passports", web_ns.models[ClaimablePassportsResponse.__name__])
|
|
@model_validate(ClaimablePassportsPayload)
|
|
def post(self, payload: ClaimablePassportsPayload):
|
|
with web_login_errors():
|
|
if not dify_config.WEBAPP_ACCOUNT_PASSPORT_ENABLED:
|
|
raise WebPassportAccountExchangeDisabledError()
|
|
candidates = application_services().web_migration.claimable_passports(payload.passports)
|
|
return (
|
|
dump_response(
|
|
ClaimablePassportsResponse,
|
|
{
|
|
"data": [
|
|
ClaimablePassportResponse(
|
|
index=index,
|
|
app_code=candidate.app_code,
|
|
app_name=candidate.app_name,
|
|
connection_count=candidate.connection_count,
|
|
conversation_count=candidate.conversation_count,
|
|
)
|
|
for index, candidate in candidates
|
|
]
|
|
},
|
|
),
|
|
200,
|
|
{"Cache-Control": "no-store"},
|
|
)
|