mirror of
https://github.com/langgenius/dify.git
synced 2026-09-24 23:22:26 +08:00
feat(agent): separate CLI file URL audiences (#39952)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
autofix-ci[bot]
parent
9d81f0da10
commit
b462cb041d
@@ -17,6 +17,7 @@ inner_api_ns = Namespace("inner_api", description="Internal API operations", pat
|
||||
|
||||
from . import mail as _mail
|
||||
from . import runtime_credentials as _runtime_credentials
|
||||
from .agent import files as _agent_files
|
||||
from .agent import tools as _agent_tools
|
||||
from .app import dsl as _app_dsl
|
||||
from .knowledge import retrieval as _knowledge_retrieval
|
||||
@@ -30,6 +31,7 @@ api.add_namespace(inner_api_ns)
|
||||
__all__ = [
|
||||
"_agent_config",
|
||||
"_agent_drive",
|
||||
"_agent_files",
|
||||
"_agent_tools",
|
||||
"_app_dsl",
|
||||
"_knowledge_retrieval",
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
"""Agent-owned inner endpoints for CLI file URL allocation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, ConfigDict, ValidationError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from configs import dify_config
|
||||
from controllers.common.schema import register_response_schema_models, register_schema_models
|
||||
from controllers.common.session import with_session
|
||||
from controllers.console.wraps import setup_required
|
||||
from controllers.inner_api import inner_api_ns
|
||||
from controllers.inner_api.plugin.wraps import get_user
|
||||
from controllers.inner_api.wraps import plugin_inner_api_only
|
||||
from core.plugin.entities.request import RequestDownloadFileMapping, RequestRequestUploadFile
|
||||
from core.tools.signature import bind_file_uri, get_signed_file_uri_for_plugin
|
||||
from fields.base import ResponseModel
|
||||
from libs.exception import BaseHTTPException
|
||||
from services.account_service import TenantService
|
||||
from services.file_request_service import FileRequestService
|
||||
|
||||
|
||||
class AgentFileRequestHttpError(BaseHTTPException):
|
||||
error_code = "agent_file_request_failed"
|
||||
description = "Agent file request failed."
|
||||
code = 500
|
||||
|
||||
def __init__(self, *, error_code: str, description: str, status_code: int) -> None:
|
||||
self.error_code = error_code
|
||||
self.description = description
|
||||
self.code = status_code
|
||||
super().__init__(description)
|
||||
|
||||
|
||||
class AgentFileUploadRequestPayload(RequestRequestUploadFile):
|
||||
tenant_id: str
|
||||
user_id: str
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class AgentFileDownloadRequestPayload(BaseModel):
|
||||
tenant_id: str
|
||||
user_id: str
|
||||
user_from: Literal["account", "end-user"]
|
||||
invoke_from: Literal[
|
||||
"service-api",
|
||||
"openapi",
|
||||
"web-app",
|
||||
"trigger",
|
||||
"explore",
|
||||
"debugger",
|
||||
"published",
|
||||
"validation",
|
||||
]
|
||||
file: RequestDownloadFileMapping
|
||||
for_frontend: bool = True
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class AgentFileUploadRequestResponse(ResponseModel):
|
||||
upload_uri: str
|
||||
|
||||
|
||||
class AgentFileDownloadRequestResponse(ResponseModel):
|
||||
filename: str
|
||||
mime_type: str | None = None
|
||||
size: int
|
||||
download_uri: str
|
||||
|
||||
|
||||
register_schema_models(inner_api_ns, AgentFileUploadRequestPayload, AgentFileDownloadRequestPayload)
|
||||
register_response_schema_models(
|
||||
inner_api_ns,
|
||||
AgentFileUploadRequestResponse,
|
||||
AgentFileDownloadRequestResponse,
|
||||
)
|
||||
|
||||
|
||||
@inner_api_ns.route("/agent/files/upload-request")
|
||||
class AgentFileUploadRequestApi(Resource):
|
||||
"""Allocate an origin-free signed upload URI for the Agent CLI."""
|
||||
|
||||
@setup_required
|
||||
@plugin_inner_api_only
|
||||
@inner_api_ns.doc("inner_agent_file_upload_request")
|
||||
@inner_api_ns.expect(inner_api_ns.models[AgentFileUploadRequestPayload.__name__])
|
||||
@inner_api_ns.response(
|
||||
200,
|
||||
"Upload URI allocated",
|
||||
inner_api_ns.models[AgentFileUploadRequestResponse.__name__],
|
||||
)
|
||||
@with_session(write=False)
|
||||
def post(self, session: Session) -> dict[str, object]:
|
||||
try:
|
||||
payload = AgentFileUploadRequestPayload.model_validate(inner_api_ns.payload or {})
|
||||
except ValidationError as exc:
|
||||
raise AgentFileRequestHttpError(
|
||||
error_code="invalid_request",
|
||||
description=str(exc),
|
||||
status_code=400,
|
||||
) from exc
|
||||
|
||||
tenant = TenantService.get_tenant_by_id(payload.tenant_id, session=session)
|
||||
if tenant is None:
|
||||
raise AgentFileRequestHttpError(
|
||||
error_code="tenant_not_found",
|
||||
description="tenant not found",
|
||||
status_code=404,
|
||||
)
|
||||
try:
|
||||
user = get_user(tenant.id, payload.user_id)
|
||||
upload_uri = get_signed_file_uri_for_plugin(
|
||||
filename=payload.filename,
|
||||
mimetype=payload.mimetype,
|
||||
tenant_id=tenant.id,
|
||||
user_id=user.id,
|
||||
conversation_id=payload.conversation_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise AgentFileRequestHttpError(
|
||||
error_code="user_not_found",
|
||||
description=str(exc),
|
||||
status_code=404,
|
||||
) from exc
|
||||
|
||||
return AgentFileUploadRequestResponse(upload_uri=upload_uri).model_dump(mode="json")
|
||||
|
||||
|
||||
@inner_api_ns.route("/agent/files/download-request")
|
||||
class AgentFileDownloadRequestApi(Resource):
|
||||
"""Allocate a transfer URI or frontend URL for one Agent CLI file."""
|
||||
|
||||
@setup_required
|
||||
@plugin_inner_api_only
|
||||
@inner_api_ns.doc("inner_agent_file_download_request")
|
||||
@inner_api_ns.expect(inner_api_ns.models[AgentFileDownloadRequestPayload.__name__])
|
||||
@inner_api_ns.response(
|
||||
200,
|
||||
"Download URI allocated",
|
||||
inner_api_ns.models[AgentFileDownloadRequestResponse.__name__],
|
||||
)
|
||||
@with_session(write=False)
|
||||
def post(self, session: Session) -> dict[str, object]:
|
||||
try:
|
||||
payload = AgentFileDownloadRequestPayload.model_validate(inner_api_ns.payload or {})
|
||||
except ValidationError as exc:
|
||||
raise AgentFileRequestHttpError(
|
||||
error_code="invalid_request",
|
||||
description=str(exc),
|
||||
status_code=400,
|
||||
) from exc
|
||||
|
||||
if TenantService.get_tenant_by_id(payload.tenant_id, session=session) is None:
|
||||
raise AgentFileRequestHttpError(
|
||||
error_code="tenant_not_found",
|
||||
description="tenant not found",
|
||||
status_code=404,
|
||||
)
|
||||
try:
|
||||
result = FileRequestService().request_download(
|
||||
tenant_id=payload.tenant_id,
|
||||
user_id=payload.user_id,
|
||||
user_from=payload.user_from,
|
||||
invoke_from=payload.invoke_from,
|
||||
file_mapping=payload.file.model_dump(mode="python", exclude_none=True),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise AgentFileRequestHttpError(
|
||||
error_code="file_not_accessible",
|
||||
description=str(exc),
|
||||
status_code=404,
|
||||
) from exc
|
||||
|
||||
download_uri = result.download_uri
|
||||
if payload.for_frontend:
|
||||
download_uri = bind_file_uri(download_uri, dify_config.FILES_URL)
|
||||
|
||||
return AgentFileDownloadRequestResponse(
|
||||
filename=result.filename,
|
||||
mime_type=result.mime_type,
|
||||
size=result.size,
|
||||
download_uri=download_uri,
|
||||
).model_dump(mode="json")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AgentFileDownloadRequestApi",
|
||||
"AgentFileDownloadRequestPayload",
|
||||
"AgentFileDownloadRequestResponse",
|
||||
"AgentFileUploadRequestApi",
|
||||
"AgentFileUploadRequestPayload",
|
||||
"AgentFileUploadRequestResponse",
|
||||
]
|
||||
@@ -1,6 +1,7 @@
|
||||
from flask_restx import Resource
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from configs import dify_config
|
||||
from controllers.console.app.wraps import with_session
|
||||
from controllers.console.wraps import setup_required
|
||||
from controllers.inner_api import inner_api_ns
|
||||
@@ -31,7 +32,7 @@ from core.plugin.entities.request import (
|
||||
RequestRequestUploadFile,
|
||||
)
|
||||
from core.tools.entities.tool_entities import ToolProviderType
|
||||
from core.tools.signature import get_signed_file_url_for_plugin
|
||||
from core.tools.signature import bind_file_uri, get_signed_file_uri_for_plugin
|
||||
from extensions.ext_database import db
|
||||
from graphon.model_runtime.utils.encoders import jsonable_encoder
|
||||
from libs.helper import length_prefixed_response
|
||||
@@ -429,13 +430,14 @@ class PluginUploadFileRequestApi(Resource):
|
||||
)
|
||||
def post(self, user_model: Account | EndUser, tenant_model: Tenant, payload: RequestRequestUploadFile):
|
||||
# generate signed url
|
||||
url = get_signed_file_url_for_plugin(
|
||||
uri = get_signed_file_uri_for_plugin(
|
||||
filename=payload.filename,
|
||||
mimetype=payload.mimetype,
|
||||
tenant_id=tenant_model.id,
|
||||
user_id=user_model.id,
|
||||
conversation_id=payload.conversation_id,
|
||||
)
|
||||
url = bind_file_uri(uri, dify_config.INTERNAL_FILES_URL or dify_config.FILES_URL)
|
||||
return BaseBackwardsInvocationResponse(data={"url": url}).model_dump()
|
||||
|
||||
|
||||
@@ -454,36 +456,33 @@ class PluginDownloadFileRequestApi(Resource):
|
||||
}
|
||||
)
|
||||
def post(self, payload: RequestRequestDownloadFile):
|
||||
"""Resolve signed download metadata for trusted external runtimes.
|
||||
"""Adapt a shared file request result to the Plugin backward contract.
|
||||
|
||||
Unlike end-user-facing upload/download APIs, this inner endpoint serves
|
||||
trusted callers such as the ``dify-agent`` back proxy. The caller sends
|
||||
flattened ``tenant_id`` / ``user_id`` / ``user_from`` / ``invoke_from``
|
||||
context explicitly in the body, and ``FileRequestService`` rebuilds the
|
||||
corresponding ``FileAccessScope`` before resolving the signed URL.
|
||||
|
||||
The response is control-plane metadata only: filename, mime type, size,
|
||||
and the signed download URL. File bytes still flow through the existing
|
||||
signed file endpoints rather than through this inner API.
|
||||
``FileRequestService`` rebuilds the caller's ``FileAccessScope`` and
|
||||
resolves one origin-free signed URI. This controller binds that URI to
|
||||
the Plugin-selected external or internal files base URL, then returns
|
||||
the existing backward-invocation envelope.
|
||||
"""
|
||||
tenant_model = db.session.get(Tenant, payload.tenant_id)
|
||||
if tenant_model is None:
|
||||
raise ValueError("tenant not found")
|
||||
|
||||
result = FileRequestService().request_download_url(
|
||||
result = FileRequestService().request_download(
|
||||
tenant_id=tenant_model.id,
|
||||
user_id=payload.user_id,
|
||||
user_from=payload.user_from,
|
||||
invoke_from=payload.invoke_from,
|
||||
file_mapping=payload.file.model_dump(mode="python", exclude_none=True),
|
||||
for_external=payload.for_external,
|
||||
)
|
||||
base_url = (
|
||||
dify_config.FILES_URL if payload.for_external else (dify_config.INTERNAL_FILES_URL or dify_config.FILES_URL)
|
||||
)
|
||||
return BaseBackwardsInvocationResponse(
|
||||
data={
|
||||
"filename": result.filename,
|
||||
"mime_type": result.mime_type,
|
||||
"size": result.size,
|
||||
"download_url": result.download_url,
|
||||
"download_url": bind_file_uri(result.download_uri, base_url),
|
||||
}
|
||||
).model_dump()
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ from configs import dify_config
|
||||
from core.app.file_access import DatabaseFileAccessController, FileAccessControllerProtocol
|
||||
from core.db.session_factory import session_factory
|
||||
from core.file import remote_fetcher
|
||||
from core.tools.signature import sign_tool_file
|
||||
from core.tools.signature import bind_file_uri, sign_tool_file_uri
|
||||
from core.workflow.file_reference import parse_file_reference
|
||||
from extensions.ext_storage import storage
|
||||
from graphon.file import FileTransferMethod
|
||||
@@ -62,32 +62,42 @@ class DifyWorkflowFileRuntime(WorkflowFileRuntimeProtocol):
|
||||
|
||||
@override
|
||||
def resolve_file_url(self, *, file: File, for_external: bool = True) -> str | None:
|
||||
uri = self.resolve_file_uri(file=file)
|
||||
if uri is None or file.transfer_method == FileTransferMethod.REMOTE_URL:
|
||||
return uri
|
||||
return bind_file_uri(uri, self._base_url(for_external=for_external))
|
||||
|
||||
def resolve_file_uri(self, *, file: File) -> str | None:
|
||||
"""Resolve a signed file URI without binding Dify-owned files to an origin.
|
||||
|
||||
Remote URLs retain their external absolute URL. Dify-owned files return
|
||||
a signed ``/files/...`` URI that callers can bind to their own network
|
||||
audience without exposing ``FILES_URL`` or ``INTERNAL_FILES_URL``.
|
||||
"""
|
||||
|
||||
if file.transfer_method == FileTransferMethod.REMOTE_URL:
|
||||
return file.remote_url
|
||||
parsed_reference = parse_file_reference(file.reference)
|
||||
if parsed_reference is None:
|
||||
raise ValueError("Missing file reference")
|
||||
if file.transfer_method == FileTransferMethod.LOCAL_FILE:
|
||||
return self.resolve_upload_file_url(
|
||||
return self.resolve_upload_file_uri(
|
||||
upload_file_id=parsed_reference.record_id,
|
||||
for_external=for_external,
|
||||
)
|
||||
if file.transfer_method == FileTransferMethod.DATASOURCE_FILE:
|
||||
if file.extension is None:
|
||||
raise ValueError("Missing file extension")
|
||||
self._assert_upload_file_access(upload_file_id=parsed_reference.record_id)
|
||||
return sign_tool_file(
|
||||
return sign_tool_file_uri(
|
||||
tool_file_id=parsed_reference.record_id,
|
||||
extension=file.extension,
|
||||
for_external=for_external,
|
||||
)
|
||||
if file.transfer_method == FileTransferMethod.TOOL_FILE:
|
||||
if file.extension is None:
|
||||
raise ValueError("Missing file extension")
|
||||
return self.resolve_tool_file_url(
|
||||
return self.resolve_tool_file_uri(
|
||||
tool_file_id=parsed_reference.record_id,
|
||||
extension=file.extension,
|
||||
for_external=for_external,
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -99,18 +109,34 @@ class DifyWorkflowFileRuntime(WorkflowFileRuntimeProtocol):
|
||||
as_attachment: bool = False,
|
||||
for_external: bool = True,
|
||||
) -> str:
|
||||
uri = self.resolve_upload_file_uri(upload_file_id=upload_file_id, as_attachment=as_attachment)
|
||||
return bind_file_uri(uri, self._base_url(for_external=for_external))
|
||||
|
||||
def resolve_upload_file_uri(
|
||||
self,
|
||||
*,
|
||||
upload_file_id: str,
|
||||
as_attachment: bool = False,
|
||||
) -> str:
|
||||
"""Resolve a signed UploadFile URI without selecting an origin."""
|
||||
|
||||
self._assert_upload_file_access(upload_file_id=upload_file_id)
|
||||
base_url = self._base_url(for_external=for_external)
|
||||
url = f"{base_url}/files/{upload_file_id}/file-preview"
|
||||
uri = f"/files/{upload_file_id}/file-preview"
|
||||
query = self._sign_query(payload=f"file-preview|{upload_file_id}")
|
||||
if as_attachment:
|
||||
query["as_attachment"] = "true"
|
||||
return f"{url}?{urllib.parse.urlencode(query)}"
|
||||
return f"{uri}?{urllib.parse.urlencode(query)}"
|
||||
|
||||
@override
|
||||
def resolve_tool_file_url(self, *, tool_file_id: str, extension: str, for_external: bool = True) -> str:
|
||||
uri = self.resolve_tool_file_uri(tool_file_id=tool_file_id, extension=extension)
|
||||
return bind_file_uri(uri, self._base_url(for_external=for_external))
|
||||
|
||||
def resolve_tool_file_uri(self, *, tool_file_id: str, extension: str) -> str:
|
||||
"""Resolve a signed ToolFile URI without selecting an origin."""
|
||||
|
||||
self._assert_tool_file_access(tool_file_id=tool_file_id)
|
||||
return sign_tool_file(tool_file_id=tool_file_id, extension=extension, for_external=for_external)
|
||||
return sign_tool_file_uri(tool_file_id=tool_file_id, extension=extension)
|
||||
|
||||
@override
|
||||
def verify_preview_signature(
|
||||
|
||||
+35
-14
@@ -4,29 +4,52 @@ import hmac
|
||||
import os
|
||||
import time
|
||||
import urllib.parse
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from configs import dify_config
|
||||
|
||||
|
||||
def bind_file_uri(uri: str, base_url: str) -> str:
|
||||
"""Bind a Dify-owned file URI to one caller-selected origin.
|
||||
|
||||
Explicit remote HTTP(S) URLs are already complete and pass through. Other
|
||||
values must be origin-free ``/files/...`` URIs.
|
||||
"""
|
||||
|
||||
parsed = urlsplit(uri)
|
||||
if parsed.scheme in {"http", "https"} and parsed.netloc:
|
||||
return uri
|
||||
if (
|
||||
parsed.scheme
|
||||
or parsed.netloc
|
||||
or parsed.fragment
|
||||
or uri.startswith("//")
|
||||
or not parsed.path.startswith("/files/")
|
||||
):
|
||||
raise ValueError("file URI must be an absolute HTTP(S) URL or a /files/ URI")
|
||||
return f"{base_url}{uri}"
|
||||
|
||||
|
||||
def _secret_key() -> bytes:
|
||||
return dify_config.SECRET_KEY.encode()
|
||||
|
||||
|
||||
def sign_tool_file(tool_file_id: str, extension: str, for_external: bool = True) -> str:
|
||||
"""
|
||||
sign file to get a temporary url for plugin access
|
||||
"""
|
||||
# Use internal URL for plugin/tool file access in Docker environments, unless for_external is True
|
||||
base_url = dify_config.FILES_URL if for_external else (dify_config.INTERNAL_FILES_URL or dify_config.FILES_URL)
|
||||
file_preview_url = f"{base_url}/files/tools/{tool_file_id}{extension}"
|
||||
|
||||
def sign_tool_file_uri(tool_file_id: str, extension: str) -> str:
|
||||
"""Sign a ToolFile path without selecting a network origin."""
|
||||
timestamp = str(int(time.time()))
|
||||
nonce = os.urandom(16).hex()
|
||||
data_to_sign = f"file-preview|{tool_file_id}|{timestamp}|{nonce}"
|
||||
sign = hmac.new(_secret_key(), data_to_sign.encode(), hashlib.sha256).digest()
|
||||
encoded_sign = base64.urlsafe_b64encode(sign).decode()
|
||||
|
||||
return f"{file_preview_url}?timestamp={timestamp}&nonce={nonce}&sign={encoded_sign}"
|
||||
return f"/files/tools/{tool_file_id}{extension}?timestamp={timestamp}&nonce={nonce}&sign={encoded_sign}"
|
||||
|
||||
|
||||
def sign_tool_file(tool_file_id: str, extension: str, for_external: bool = True) -> str:
|
||||
"""Sign a ToolFile URL for the browser or an internal Dify service."""
|
||||
|
||||
base_url = dify_config.FILES_URL if for_external else (dify_config.INTERNAL_FILES_URL or dify_config.FILES_URL)
|
||||
return bind_file_uri(sign_tool_file_uri(tool_file_id, extension), base_url)
|
||||
|
||||
|
||||
def sign_upload_file_preview_url(upload_file_id: str, extension: str) -> str:
|
||||
@@ -64,13 +87,11 @@ def verify_tool_file_signature(file_id: str, timestamp: str, nonce: str, sign: s
|
||||
return current_time - int(timestamp) <= dify_config.FILES_ACCESS_TIMEOUT
|
||||
|
||||
|
||||
def get_signed_file_url_for_plugin(
|
||||
def get_signed_file_uri_for_plugin(
|
||||
filename: str, mimetype: str, tenant_id: str, user_id: str, conversation_id: str | None = None
|
||||
) -> str:
|
||||
"""Build the signed upload URL used by the plugin-facing file upload endpoint."""
|
||||
"""Build a signed plugin-upload URI without selecting a network origin."""
|
||||
|
||||
base_url = dify_config.INTERNAL_FILES_URL or dify_config.FILES_URL
|
||||
upload_url = f"{base_url}/files/upload/for-plugin"
|
||||
timestamp = str(int(time.time()))
|
||||
nonce = os.urandom(16).hex()
|
||||
data_to_sign = f"upload|{filename}|{mimetype}|{tenant_id}|{user_id}|{conversation_id or ''}|{timestamp}|{nonce}"
|
||||
@@ -86,7 +107,7 @@ def get_signed_file_url_for_plugin(
|
||||
if conversation_id:
|
||||
query_params["conversation_id"] = conversation_id
|
||||
query = urllib.parse.urlencode(query_params)
|
||||
return f"{upload_url}?{query}"
|
||||
return f"/files/upload/for-plugin?{query}"
|
||||
|
||||
|
||||
def verify_plugin_file_signature(
|
||||
|
||||
@@ -701,7 +701,7 @@ class WorkflowAgentRuntimeRequestBuilder:
|
||||
"only the accepted file-mapping shape and the returned `reference`; never invent the `reference` "
|
||||
"value.",
|
||||
"If you are replying to the user in natural language and want them to open or download the produced "
|
||||
"file, include the returned `download_url` in that reply instead of copying it into structured "
|
||||
"file, include the returned `public_download_url` in that reply instead of copying it into structured "
|
||||
"`final_output` unless the schema explicitly asks for it.",
|
||||
*file_output_lines,
|
||||
]
|
||||
|
||||
@@ -320,7 +320,7 @@ def _format_output_mention(output: DeclaredOutputConfig) -> str:
|
||||
f"{output.name} (file output; create the file locally, run "
|
||||
f"`dify-agent file upload <path>`, then set final_output.{output.name} to a `tool_file` mapping "
|
||||
f"using the returned `reference`; if replying to the user in natural language, use the returned "
|
||||
f"`download_url`; do not call final_output before upload succeeds, and do not use the local path, "
|
||||
f"`public_download_url`; do not call final_output before upload succeeds, and do not use the local path, "
|
||||
"filename, URL, or a synthesized dify-file-ref as the reference)"
|
||||
)
|
||||
if (
|
||||
@@ -332,7 +332,7 @@ def _format_output_mention(output: DeclaredOutputConfig) -> str:
|
||||
f"{output.name} (array[file] output; upload each produced file with "
|
||||
f"`dify-agent file upload <path>`, then set final_output.{output.name} to `tool_file` mappings "
|
||||
f"using the returned `reference` values; if replying to the user in natural language, use the returned "
|
||||
f"`download_url`; do not call final_output before all uploads succeed, and do not use local paths, "
|
||||
f"`public_download_url`; do not call final_output before all uploads succeed, and do not use local paths, "
|
||||
"filenames, URLs, or synthesized dify-file-ref values as references)"
|
||||
)
|
||||
return f"{output.name} ({output.type.value})"
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
"""Resolve a download request for a workflow file ref to a signed URL (Agent Files §3.1.1/§4.5).
|
||||
|
||||
The dify-agent server calls this on behalf of a sandbox that needs to pull a
|
||||
``File`` / ``Array[File]`` workflow input. It binds the flattened file-access
|
||||
context as a ``FileAccessScope``, rebuilds the graphon ``File`` from the mapping
|
||||
(reusing tenant/user access checks), and returns an internal signed download URL
|
||||
plus metadata — never the file bytes. The dify-agent server / sandbox then GETs
|
||||
the URL directly from Dify API.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom
|
||||
from core.app.file_access.controller import DatabaseFileAccessController
|
||||
from core.app.file_access.scope import FileAccessScope, bind_file_access_scope
|
||||
from core.app.workflow.file_runtime import DifyWorkflowFileRuntime
|
||||
from factories import file_factory
|
||||
|
||||
|
||||
class FileDownloadRequestError(Exception):
|
||||
"""A download-request failure mapped to an HTTP status by the controller."""
|
||||
|
||||
code: str
|
||||
message: str
|
||||
status_code: int
|
||||
|
||||
def __init__(self, code: str, message: str, *, status_code: int = 400) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.message = message
|
||||
self.status_code = status_code
|
||||
|
||||
|
||||
class AgentFileDownloadRequestService:
|
||||
"""Resolve a workflow file ref to a sandbox-accessible internal signed download URL."""
|
||||
|
||||
@classmethod
|
||||
def resolve(
|
||||
cls,
|
||||
*,
|
||||
tenant_id: str,
|
||||
user_id: str,
|
||||
user_from: str,
|
||||
invoke_from: str,
|
||||
file_mapping: Mapping[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
scope_user_from = UserFrom(user_from)
|
||||
scope_invoke_from = InvokeFrom(invoke_from)
|
||||
except ValueError as exc:
|
||||
raise FileDownloadRequestError("invalid_access_context", str(exc), status_code=400) from exc
|
||||
|
||||
if not isinstance(file_mapping, Mapping) or not file_mapping.get("transfer_method"):
|
||||
raise FileDownloadRequestError("invalid_file_mapping", "file.transfer_method is required", status_code=400)
|
||||
|
||||
scope = FileAccessScope(
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
user_from=scope_user_from,
|
||||
invoke_from=scope_invoke_from,
|
||||
)
|
||||
controller = DatabaseFileAccessController()
|
||||
runtime = DifyWorkflowFileRuntime(file_access_controller=controller)
|
||||
try:
|
||||
with bind_file_access_scope(scope):
|
||||
file = file_factory.build_from_mapping(
|
||||
mapping=file_mapping,
|
||||
tenant_id=tenant_id,
|
||||
access_controller=controller,
|
||||
)
|
||||
# Internal URL (for_external=False): the consumer is the agent backend /
|
||||
# sandbox, not a browser. Resolves against INTERNAL_FILES_URL, falling
|
||||
# back to FILES_URL when not configured.
|
||||
download_url = runtime.resolve_file_url(file=file, for_external=False)
|
||||
except ValueError as exc:
|
||||
raise FileDownloadRequestError("file_not_accessible", str(exc), status_code=404) from exc
|
||||
|
||||
if not download_url:
|
||||
raise FileDownloadRequestError(
|
||||
"download_url_unavailable", "could not resolve a download URL for the file", status_code=502
|
||||
)
|
||||
return {
|
||||
"filename": file.filename,
|
||||
"mime_type": file.mime_type,
|
||||
"size": file.size,
|
||||
"download_url": download_url,
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["AgentFileDownloadRequestService", "FileDownloadRequestError"]
|
||||
@@ -1,9 +1,9 @@
|
||||
"""Service helpers for trusted file request control-plane endpoints.
|
||||
|
||||
These helpers are used by inner APIs that return signed upload/download URLs to
|
||||
trusted external runtimes such as ``dify-agent``. They do not transfer file
|
||||
bytes themselves; they only rebuild access-scoped ``graphon.file.File`` values
|
||||
and resolve the signed URL that the caller should use directly.
|
||||
These helpers are used by inner APIs that allocate file access for trusted
|
||||
external runtimes. They rebuild access-scoped ``graphon.file.File`` values and
|
||||
return origin-free signed URIs so each transport adapter can select its own
|
||||
network origin without signing the file twice.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -14,30 +14,32 @@ from typing import Any
|
||||
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom
|
||||
from core.app.file_access import DatabaseFileAccessController, FileAccessScope, bind_file_access_scope
|
||||
from core.app.workflow.file_runtime import DifyWorkflowFileRuntime
|
||||
from factories.file_factory.builders import build_from_mapping
|
||||
from graphon.file import File
|
||||
from graphon.file import helpers as file_helpers
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DownloadFileRequestResult:
|
||||
"""Resolved metadata and signed URL returned to trusted download callers."""
|
||||
"""Resolved metadata and signed URI returned to trusted download callers."""
|
||||
|
||||
filename: str
|
||||
mime_type: str | None
|
||||
size: int
|
||||
download_url: str
|
||||
download_uri: str
|
||||
|
||||
|
||||
class FileRequestService:
|
||||
"""Resolve signed download URLs for trusted external file consumers."""
|
||||
"""Resolve signed download URIs for trusted external file consumers."""
|
||||
|
||||
_access_controller: DatabaseFileAccessController
|
||||
_runtime: DifyWorkflowFileRuntime
|
||||
|
||||
def __init__(self, access_controller: DatabaseFileAccessController | None = None) -> None:
|
||||
self._access_controller = access_controller or DatabaseFileAccessController()
|
||||
self._runtime = DifyWorkflowFileRuntime(file_access_controller=self._access_controller)
|
||||
|
||||
def request_download_url(
|
||||
def request_download(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
@@ -45,7 +47,6 @@ class FileRequestService:
|
||||
user_from: UserFrom | str,
|
||||
invoke_from: InvokeFrom | str,
|
||||
file_mapping: Mapping[str, Any],
|
||||
for_external: bool = True,
|
||||
) -> DownloadFileRequestResult:
|
||||
"""Resolve one file mapping into signed download metadata.
|
||||
|
||||
@@ -62,15 +63,15 @@ class FileRequestService:
|
||||
)
|
||||
with bind_file_access_scope(scope):
|
||||
file = self._build_file(mapping=file_mapping, tenant_id=tenant_id)
|
||||
download_url = file_helpers.resolve_file_url(file, for_external=for_external)
|
||||
download_uri = self._runtime.resolve_file_uri(file=file)
|
||||
|
||||
if not download_url:
|
||||
if not download_uri:
|
||||
raise ValueError("file does not support signed download")
|
||||
return DownloadFileRequestResult(
|
||||
filename=file.filename or "download.bin",
|
||||
mime_type=file.mime_type,
|
||||
size=file.size,
|
||||
download_url=download_url,
|
||||
download_uri=download_uri,
|
||||
)
|
||||
|
||||
def _build_file(self, *, mapping: Mapping[str, Any], tenant_id: str) -> File:
|
||||
|
||||
@@ -202,6 +202,16 @@ def test_internal_files_url_prefers_explicit_value(monkeypatch: pytest.MonkeyPat
|
||||
assert config.INTERNAL_FILES_URL == "http://files-internal:5001"
|
||||
|
||||
|
||||
def test_empty_files_url_overrides_console_api_url_for_relative_browser_uris(monkeypatch: pytest.MonkeyPatch):
|
||||
_clear_environment(monkeypatch)
|
||||
monkeypatch.setenv("FILES_URL", "")
|
||||
monkeypatch.setenv("CONSOLE_API_URL", "http://api:5001")
|
||||
|
||||
config = DifyConfig(_env_file=None)
|
||||
|
||||
assert config.FILES_URL == ""
|
||||
|
||||
|
||||
# NOTE: If there is a `.env` file in your Workspace, this test might not succeed as expected.
|
||||
# This is due to `pymilvus` loading all the variables from the `.env` file into `os.environ`.
|
||||
def test_flask_configs(monkeypatch: pytest.MonkeyPatch):
|
||||
|
||||
@@ -263,11 +263,12 @@ class TestPluginUploadFileRequestApi:
|
||||
assert hasattr(api_instance, "post")
|
||||
assert callable(api_instance.post)
|
||||
|
||||
@patch("controllers.inner_api.plugin.plugin.get_signed_file_url_for_plugin")
|
||||
def test_post_returns_signed_url(self, mock_get_url, api_instance, app: Flask):
|
||||
@patch("controllers.inner_api.plugin.plugin.get_signed_file_uri_for_plugin")
|
||||
def test_post_returns_signed_url(self, mock_get_uri, api_instance, app: Flask, monkeypatch: pytest.MonkeyPatch):
|
||||
"""Test that post() generates a signed URL and returns it"""
|
||||
# Arrange
|
||||
mock_get_url.return_value = "https://storage.example.com/signed-upload-url"
|
||||
mock_get_uri.return_value = "/files/upload/for-plugin?sign=1"
|
||||
monkeypatch.setattr(plugin_module.dify_config, "INTERNAL_FILES_URL", "http://api:5001")
|
||||
mock_tenant = MagicMock()
|
||||
mock_tenant.id = "tenant-id"
|
||||
mock_user = MagicMock()
|
||||
@@ -282,14 +283,14 @@ class TestPluginUploadFileRequestApi:
|
||||
result = raw_post(api_instance, user_model=mock_user, tenant_model=mock_tenant, payload=mock_payload)
|
||||
|
||||
# Assert
|
||||
mock_get_url.assert_called_once_with(
|
||||
mock_get_uri.assert_called_once_with(
|
||||
filename="test.pdf",
|
||||
mimetype="application/pdf",
|
||||
tenant_id="tenant-id",
|
||||
user_id="user-id",
|
||||
conversation_id="conversation-id",
|
||||
)
|
||||
assert result["data"]["url"] == "https://storage.example.com/signed-upload-url"
|
||||
assert result["data"]["url"] == "http://api:5001/files/upload/for-plugin?sign=1"
|
||||
|
||||
|
||||
class TestPluginDownloadFileRequestApi:
|
||||
@@ -304,6 +305,13 @@ class TestPluginDownloadFileRequestApi:
|
||||
assert callable(api_instance.post)
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(Tenant,)], indirect=True)
|
||||
@pytest.mark.parametrize(
|
||||
("for_external", "expected_url"),
|
||||
[
|
||||
(True, "https://files.example.com/files/tools/report.pdf?sign=1"),
|
||||
(False, "http://api:5001/files/tools/report.pdf?sign=1"),
|
||||
],
|
||||
)
|
||||
@patch("controllers.inner_api.plugin.plugin.FileRequestService")
|
||||
def test_post_returns_signed_download_url(
|
||||
self,
|
||||
@@ -312,6 +320,8 @@ class TestPluginDownloadFileRequestApi:
|
||||
app: Flask,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
sqlite_session: Session,
|
||||
for_external: bool,
|
||||
expected_url: str,
|
||||
):
|
||||
tenant = Tenant(
|
||||
name="Plugin Tenant",
|
||||
@@ -324,18 +334,20 @@ class TestPluginDownloadFileRequestApi:
|
||||
sqlite_session.commit()
|
||||
monkeypatch.setattr(plugin_module.db, "session", sqlite_session)
|
||||
mock_service = mock_service_cls.return_value
|
||||
mock_service.request_download_url.return_value = MagicMock(
|
||||
mock_service.request_download.return_value = MagicMock(
|
||||
filename="report.pdf",
|
||||
mime_type="application/pdf",
|
||||
size=123,
|
||||
download_url="https://files.example.com/download",
|
||||
download_uri="/files/tools/report.pdf?sign=1",
|
||||
)
|
||||
monkeypatch.setattr(plugin_module.dify_config, "FILES_URL", "https://files.example.com")
|
||||
monkeypatch.setattr(plugin_module.dify_config, "INTERNAL_FILES_URL", "http://api:5001")
|
||||
mock_payload = MagicMock()
|
||||
mock_payload.tenant_id = tenant.id
|
||||
mock_payload.user_id = "user-id"
|
||||
mock_payload.user_from = "account"
|
||||
mock_payload.invoke_from = "debugger"
|
||||
mock_payload.for_external = False
|
||||
mock_payload.for_external = for_external
|
||||
reference = build_file_reference(record_id="tool-file-1")
|
||||
mock_payload.file.model_dump.return_value = {
|
||||
"transfer_method": "tool_file",
|
||||
@@ -345,19 +357,18 @@ class TestPluginDownloadFileRequestApi:
|
||||
raw_post = _extract_raw_post(PluginDownloadFileRequestApi)
|
||||
result = raw_post(api_instance, payload=mock_payload)
|
||||
|
||||
mock_service.request_download_url.assert_called_once_with(
|
||||
mock_service.request_download.assert_called_once_with(
|
||||
tenant_id=tenant.id,
|
||||
user_id="user-id",
|
||||
user_from="account",
|
||||
invoke_from="debugger",
|
||||
file_mapping={"transfer_method": "tool_file", "reference": reference},
|
||||
for_external=False,
|
||||
)
|
||||
assert result["data"] == {
|
||||
"filename": "report.pdf",
|
||||
"mime_type": "application/pdf",
|
||||
"size": 123,
|
||||
"download_url": "https://files.example.com/download",
|
||||
"download_url": expected_url,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import inspect
|
||||
from collections.abc import Callable
|
||||
from types import SimpleNamespace
|
||||
from typing import cast
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
from controllers.inner_api.agent.files import AgentFileDownloadRequestApi, AgentFileUploadRequestApi
|
||||
from core.workflow.file_reference import build_file_reference
|
||||
from services.file_request_service import DownloadFileRequestResult
|
||||
|
||||
MODULE = "controllers.inner_api.agent.files"
|
||||
|
||||
|
||||
def _raw[R](method: Callable[..., R]) -> Callable[..., R]:
|
||||
return cast(Callable[..., R], inspect.unwrap(method))
|
||||
|
||||
|
||||
def test_upload_request_returns_origin_free_uri(app: Flask) -> None:
|
||||
payload = {
|
||||
"tenant_id": "tenant-1",
|
||||
"user_id": "execution-user-1",
|
||||
"filename": "report.pdf",
|
||||
"mimetype": "application/pdf",
|
||||
"conversation_id": "conversation-1",
|
||||
}
|
||||
tenant = SimpleNamespace(id="tenant-1")
|
||||
user = SimpleNamespace(id="canonical-end-user-1")
|
||||
session = MagicMock()
|
||||
with app.test_request_context("/", method="POST", json=payload):
|
||||
with (
|
||||
patch(f"{MODULE}.TenantService") as tenant_service,
|
||||
patch(f"{MODULE}.get_user", return_value=user),
|
||||
patch(f"{MODULE}.get_signed_file_uri_for_plugin", return_value="/files/upload/for-plugin?sign=1") as sign,
|
||||
):
|
||||
tenant_service.get_tenant_by_id.return_value = tenant
|
||||
response = _raw(AgentFileUploadRequestApi.post)(AgentFileUploadRequestApi(), session)
|
||||
|
||||
assert response == {"upload_uri": "/files/upload/for-plugin?sign=1"}
|
||||
tenant_service.get_tenant_by_id.assert_called_once_with("tenant-1", session=session)
|
||||
sign.assert_called_once_with(
|
||||
filename="report.pdf",
|
||||
mimetype="application/pdf",
|
||||
tenant_id="tenant-1",
|
||||
user_id="canonical-end-user-1",
|
||||
conversation_id="conversation-1",
|
||||
)
|
||||
|
||||
|
||||
def test_download_request_returns_origin_free_uri_for_sandbox(app: Flask) -> None:
|
||||
reference = build_file_reference(record_id="tool-file-1")
|
||||
payload = {
|
||||
"tenant_id": "tenant-1",
|
||||
"user_id": "user-1",
|
||||
"user_from": "account",
|
||||
"invoke_from": "debugger",
|
||||
"file": {"transfer_method": "tool_file", "reference": reference},
|
||||
"for_frontend": False,
|
||||
}
|
||||
session = MagicMock()
|
||||
with app.test_request_context("/", method="POST", json=payload):
|
||||
with (
|
||||
patch(f"{MODULE}.TenantService") as tenant_service,
|
||||
patch(f"{MODULE}.FileRequestService") as service,
|
||||
):
|
||||
tenant_service.get_tenant_by_id.return_value = MagicMock()
|
||||
service.return_value.request_download.return_value = DownloadFileRequestResult(
|
||||
filename="report.pdf",
|
||||
mime_type="application/pdf",
|
||||
size=123,
|
||||
download_uri="/files/tools/tool-file-1.pdf?sign=1",
|
||||
)
|
||||
response = _raw(AgentFileDownloadRequestApi.post)(AgentFileDownloadRequestApi(), session)
|
||||
|
||||
assert response == {
|
||||
"filename": "report.pdf",
|
||||
"mime_type": "application/pdf",
|
||||
"size": 123,
|
||||
"download_uri": "/files/tools/tool-file-1.pdf?sign=1",
|
||||
}
|
||||
service.return_value.request_download.assert_called_once_with(
|
||||
tenant_id="tenant-1",
|
||||
user_id="user-1",
|
||||
user_from="account",
|
||||
invoke_from="debugger",
|
||||
file_mapping={"transfer_method": "tool_file", "reference": reference},
|
||||
)
|
||||
|
||||
|
||||
def test_download_request_binds_frontend_url(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
reference = build_file_reference(record_id="tool-file-1")
|
||||
payload = {
|
||||
"tenant_id": "tenant-1",
|
||||
"user_id": "user-1",
|
||||
"user_from": "account",
|
||||
"invoke_from": "debugger",
|
||||
"file": {"transfer_method": "tool_file", "reference": reference},
|
||||
"for_frontend": True,
|
||||
}
|
||||
monkeypatch.setattr(f"{MODULE}.dify_config.FILES_URL", "https://files.example.com")
|
||||
session = MagicMock()
|
||||
with app.test_request_context("/", method="POST", json=payload):
|
||||
with (
|
||||
patch(f"{MODULE}.TenantService") as tenant_service,
|
||||
patch(f"{MODULE}.FileRequestService") as service,
|
||||
):
|
||||
tenant_service.get_tenant_by_id.return_value = MagicMock()
|
||||
service.return_value.request_download.return_value = DownloadFileRequestResult(
|
||||
filename="report.pdf",
|
||||
mime_type="application/pdf",
|
||||
size=123,
|
||||
download_uri="/files/tools/tool-file-1.pdf?sign=1",
|
||||
)
|
||||
response = _raw(AgentFileDownloadRequestApi.post)(AgentFileDownloadRequestApi(), session)
|
||||
|
||||
assert response["download_uri"] == "https://files.example.com/files/tools/tool-file-1.pdf?sign=1"
|
||||
@@ -141,8 +141,9 @@ def test_resolve_file_url_requires_extension_for_tool_files() -> None:
|
||||
def test_resolve_file_url_uses_tool_signatures_for_tool_and_datasource_files(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
sign_tool_file = MagicMock(return_value="https://signed.example.com/file")
|
||||
monkeypatch.setattr(file_runtime, "sign_tool_file", sign_tool_file)
|
||||
sign_tool_file_uri = MagicMock(return_value="/files/signed")
|
||||
monkeypatch.setattr(file_runtime, "sign_tool_file_uri", sign_tool_file_uri)
|
||||
monkeypatch.setattr(file_runtime.dify_config, "FILES_URL", "https://files.example.com")
|
||||
runtime = _build_runtime()
|
||||
|
||||
tool_file = _build_file(
|
||||
@@ -156,9 +157,35 @@ def test_resolve_file_url_uses_tool_signatures_for_tool_and_datasource_files(
|
||||
extension=".png",
|
||||
)
|
||||
|
||||
assert runtime.resolve_file_url(file=tool_file) == "https://signed.example.com/file"
|
||||
assert runtime.resolve_file_url(file=datasource_file) == "https://signed.example.com/file"
|
||||
assert sign_tool_file.call_count == 2
|
||||
assert runtime.resolve_file_url(file=tool_file) == "https://files.example.com/files/signed"
|
||||
assert runtime.resolve_file_url(file=datasource_file) == "https://files.example.com/files/signed"
|
||||
assert sign_tool_file_uri.call_count == 2
|
||||
|
||||
|
||||
def test_resolve_file_uri_keeps_dify_owned_file_origin_free(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
sign_tool_file_uri = MagicMock(return_value="/files/tools/tool-file-id.png?sign=1")
|
||||
monkeypatch.setattr(file_runtime, "sign_tool_file_uri", sign_tool_file_uri)
|
||||
runtime = _build_runtime()
|
||||
file = _build_file(
|
||||
transfer_method=FileTransferMethod.TOOL_FILE,
|
||||
reference=build_file_reference(record_id="tool-file-id"),
|
||||
extension=".png",
|
||||
)
|
||||
|
||||
assert runtime.resolve_file_uri(file=file) == "/files/tools/tool-file-id.png?sign=1"
|
||||
|
||||
|
||||
def test_resolve_file_url_returns_relative_uri_when_files_url_is_empty(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(file_runtime, "sign_tool_file_uri", lambda **_: "/files/tools/tool-file-id.png?sign=1")
|
||||
monkeypatch.setattr(file_runtime.dify_config, "FILES_URL", "")
|
||||
runtime = _build_runtime()
|
||||
file = _build_file(
|
||||
transfer_method=FileTransferMethod.TOOL_FILE,
|
||||
reference=build_file_reference(record_id="tool-file-id"),
|
||||
extension=".png",
|
||||
)
|
||||
|
||||
assert runtime.resolve_file_url(file=file, for_external=True) == "/files/tools/tool-file-id.png?sign=1"
|
||||
|
||||
|
||||
def test_resolve_upload_file_url_signs_internal_urls_and_supports_attachments(
|
||||
|
||||
@@ -7,14 +7,40 @@ from urllib.parse import parse_qs, urlparse
|
||||
import pytest
|
||||
|
||||
from core.tools.signature import (
|
||||
get_signed_file_url_for_plugin,
|
||||
bind_file_uri,
|
||||
get_signed_file_uri_for_plugin,
|
||||
sign_tool_file,
|
||||
sign_tool_file_uri,
|
||||
sign_upload_file_preview_url,
|
||||
verify_plugin_file_signature,
|
||||
verify_tool_file_signature,
|
||||
)
|
||||
|
||||
|
||||
def test_bind_file_uri_uses_selected_base_and_preserves_remote_url() -> None:
|
||||
uri = "/files/tools/tool-file-id.png?sign=1"
|
||||
|
||||
assert bind_file_uri(uri, "https://files.example.com") == f"https://files.example.com{uri}"
|
||||
assert bind_file_uri(uri, "") == uri
|
||||
assert bind_file_uri("https://remote.example.com/report.pdf", "https://files.example.com") == (
|
||||
"https://remote.example.com/report.pdf"
|
||||
)
|
||||
|
||||
|
||||
def test_sign_tool_file_uri_has_no_origin(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr("core.tools.signature.time.time", lambda: 1700000000)
|
||||
monkeypatch.setattr("core.tools.signature.os.urandom", lambda _: b"\x08" * 16)
|
||||
monkeypatch.setattr("core.tools.signature.dify_config.SECRET_KEY", "unit-secret")
|
||||
|
||||
uri = sign_tool_file_uri("tool-file-id", ".png")
|
||||
parsed = urlparse(uri)
|
||||
|
||||
assert parsed.scheme == ""
|
||||
assert parsed.netloc == ""
|
||||
assert parsed.path == "/files/tools/tool-file-id.png"
|
||||
assert parse_qs(parsed.query)["timestamp"] == ["1700000000"]
|
||||
|
||||
|
||||
def test_sign_tool_file_and_verify_roundtrip(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr("core.tools.signature.time.time", lambda: 1700000000)
|
||||
monkeypatch.setattr("core.tools.signature.os.urandom", lambda _: b"\x01" * 16)
|
||||
@@ -125,25 +151,23 @@ def test_sign_upload_file_preview_url_ignores_internal_files_url(monkeypatch: py
|
||||
assert query["sign"][0]
|
||||
|
||||
|
||||
def test_get_signed_file_url_for_plugin_and_verify_roundtrip(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_get_signed_file_uri_for_plugin_and_verify_roundtrip(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr("core.tools.signature.time.time", lambda: 1700000000)
|
||||
monkeypatch.setattr("core.tools.signature.os.urandom", lambda _: b"\x06" * 16)
|
||||
monkeypatch.setattr("core.tools.signature.dify_config.SECRET_KEY", "unit-secret")
|
||||
monkeypatch.setattr("core.tools.signature.dify_config.FILES_URL", "https://files.example.com")
|
||||
monkeypatch.setattr("core.tools.signature.dify_config.INTERNAL_FILES_URL", "https://internal.example.com")
|
||||
monkeypatch.setattr("core.tools.signature.dify_config.FILES_ACCESS_TIMEOUT", 60)
|
||||
|
||||
url = get_signed_file_url_for_plugin(
|
||||
uri = get_signed_file_uri_for_plugin(
|
||||
filename="report.pdf",
|
||||
mimetype="application/pdf",
|
||||
tenant_id="tenant-id",
|
||||
user_id="user-id",
|
||||
conversation_id="conversation-id",
|
||||
)
|
||||
parsed = urlparse(url)
|
||||
parsed = urlparse(uri)
|
||||
query = parse_qs(parsed.query)
|
||||
|
||||
assert parsed.netloc == "internal.example.com"
|
||||
assert parsed.netloc == ""
|
||||
assert parsed.path == "/files/upload/for-plugin"
|
||||
assert query["tenant_id"] == ["tenant-id"]
|
||||
assert query["user_id"] == ["user-id"]
|
||||
@@ -167,17 +191,15 @@ def test_verify_plugin_file_signature_rejects_invalid_signatures(monkeypatch: py
|
||||
monkeypatch.setattr("core.tools.signature.time.time", lambda: 1700000000)
|
||||
monkeypatch.setattr("core.tools.signature.os.urandom", lambda _: b"\x07" * 16)
|
||||
monkeypatch.setattr("core.tools.signature.dify_config.SECRET_KEY", "unit-secret")
|
||||
monkeypatch.setattr("core.tools.signature.dify_config.FILES_URL", "https://files.example.com")
|
||||
monkeypatch.setattr("core.tools.signature.dify_config.INTERNAL_FILES_URL", "")
|
||||
monkeypatch.setattr("core.tools.signature.dify_config.FILES_ACCESS_TIMEOUT", 30)
|
||||
|
||||
url = get_signed_file_url_for_plugin(
|
||||
uri = get_signed_file_uri_for_plugin(
|
||||
filename="report.pdf",
|
||||
mimetype="application/pdf",
|
||||
tenant_id="tenant-id",
|
||||
user_id="user-id",
|
||||
)
|
||||
query = parse_qs(urlparse(url).query)
|
||||
query = parse_qs(urlparse(uri).query)
|
||||
|
||||
assert (
|
||||
verify_plugin_file_signature(
|
||||
|
||||
@@ -432,7 +432,7 @@ def test_builds_workflow_run_request_with_file_output_schema_and_reserved_metada
|
||||
assert "never invent the `reference` value" in output_description
|
||||
assert "Do not call `final_output` before the upload command succeeds" in output_description
|
||||
assert "accepted file-mapping shape and the returned `reference`" in output_description
|
||||
assert "include the returned `download_url` in that reply" in output_description
|
||||
assert "include the returned `public_download_url` in that reply" in output_description
|
||||
assert output_schema["properties"]["confidence"]["type"] == "number"
|
||||
assert output_schema["required"] == ["report"]
|
||||
assert layers[DIFY_AGENT_MODEL_LAYER_ID]["config"]["model_settings"] == {"temperature": 0.2}
|
||||
|
||||
@@ -820,7 +820,6 @@ project-excludes = [
|
||||
"services/test_agent_app_sandbox_service.py",
|
||||
"services/test_agent_config_service.py",
|
||||
"services/test_agent_drive_service.py",
|
||||
"services/test_agent_file_request_service.py",
|
||||
"services/test_annotation_service.py",
|
||||
"services/test_api_token_service.py",
|
||||
"services/test_app_generate_service.py",
|
||||
|
||||
@@ -246,7 +246,7 @@ def test_node_job_resolver_resolves_each_kind(node_job: WorkflowNodeJobConfig):
|
||||
"Read START/tenders and produce qna_report (file output; create the file locally, run "
|
||||
"`dify-agent file upload <path>`, then set final_output.qna_report to a `tool_file` mapping "
|
||||
"using the returned `reference`; if replying to the user in natural language, use the returned "
|
||||
"`download_url`; do not call final_output before upload succeeds, and do not use the local path, "
|
||||
"`public_download_url`; do not call final_output before upload succeeds, and do not use the local path, "
|
||||
"filename, URL, or a synthesized dify-file-ref as the reference); "
|
||||
"if unsure contact EMAIL · David Hayes."
|
||||
)
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
"""Unit tests for the Agent Files download-request service (ENG-592)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from services.agent_file_request_service import AgentFileDownloadRequestService, FileDownloadRequestError
|
||||
|
||||
_MOD = "services.agent_file_request_service"
|
||||
|
||||
|
||||
def _fake_file() -> SimpleNamespace:
|
||||
return SimpleNamespace(filename="report.pdf", mime_type="application/pdf", size=12)
|
||||
|
||||
|
||||
def test_resolve_returns_metadata_and_internal_url():
|
||||
with (
|
||||
patch(f"{_MOD}.file_factory.build_from_mapping", return_value=_fake_file()) as build,
|
||||
patch(f"{_MOD}.DifyWorkflowFileRuntime") as runtime_cls,
|
||||
):
|
||||
runtime_cls.return_value.resolve_file_url.return_value = "http://internal/files/x?sign=1"
|
||||
data = AgentFileDownloadRequestService.resolve(
|
||||
tenant_id="tenant-1",
|
||||
user_id="user-1",
|
||||
user_from="account",
|
||||
invoke_from="service-api",
|
||||
file_mapping={"transfer_method": "tool_file", "reference": "tool-file-1"},
|
||||
)
|
||||
|
||||
assert data == {
|
||||
"filename": "report.pdf",
|
||||
"mime_type": "application/pdf",
|
||||
"size": 12,
|
||||
"download_url": "http://internal/files/x?sign=1",
|
||||
}
|
||||
assert build.call_args.kwargs["tenant_id"] == "tenant-1"
|
||||
# Sandbox/agent backend consumes the URL -> must be internal, not external.
|
||||
assert runtime_cls.return_value.resolve_file_url.call_args.kwargs["for_external"] is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("user_from", "invoke_from", "code"),
|
||||
[
|
||||
("bogus", "service-api", "invalid_access_context"),
|
||||
("account", "not-a-source", "invalid_access_context"),
|
||||
],
|
||||
)
|
||||
def test_invalid_access_context_rejected(user_from: str, invoke_from: str, code: str):
|
||||
with pytest.raises(FileDownloadRequestError) as exc_info:
|
||||
AgentFileDownloadRequestService.resolve(
|
||||
tenant_id="t",
|
||||
user_id="u",
|
||||
user_from=user_from,
|
||||
invoke_from=invoke_from,
|
||||
file_mapping={"transfer_method": "tool_file", "reference": "x"},
|
||||
)
|
||||
assert exc_info.value.status_code == 400
|
||||
assert exc_info.value.code == code
|
||||
|
||||
|
||||
def test_missing_transfer_method_rejected():
|
||||
with pytest.raises(FileDownloadRequestError) as exc_info:
|
||||
AgentFileDownloadRequestService.resolve(
|
||||
tenant_id="t",
|
||||
user_id="u",
|
||||
user_from="account",
|
||||
invoke_from="service-api",
|
||||
file_mapping={},
|
||||
)
|
||||
assert exc_info.value.status_code == 400
|
||||
assert exc_info.value.code == "invalid_file_mapping"
|
||||
|
||||
|
||||
def test_inaccessible_file_maps_to_404():
|
||||
with patch(f"{_MOD}.file_factory.build_from_mapping", side_effect=ValueError("ToolFile x not found")):
|
||||
with pytest.raises(FileDownloadRequestError) as exc_info:
|
||||
AgentFileDownloadRequestService.resolve(
|
||||
tenant_id="t",
|
||||
user_id="u",
|
||||
user_from="end-user",
|
||||
invoke_from="web-app",
|
||||
file_mapping={"transfer_method": "tool_file", "reference": "x"},
|
||||
)
|
||||
assert exc_info.value.status_code == 404
|
||||
assert exc_info.value.code == "file_not_accessible"
|
||||
|
||||
|
||||
def test_unresolved_url_maps_to_502():
|
||||
with (
|
||||
patch(f"{_MOD}.file_factory.build_from_mapping", return_value=_fake_file()),
|
||||
patch(f"{_MOD}.DifyWorkflowFileRuntime") as runtime_cls,
|
||||
):
|
||||
runtime_cls.return_value.resolve_file_url.return_value = None
|
||||
with pytest.raises(FileDownloadRequestError) as exc_info:
|
||||
AgentFileDownloadRequestService.resolve(
|
||||
tenant_id="t",
|
||||
user_id="u",
|
||||
user_from="account",
|
||||
invoke_from="service-api",
|
||||
file_mapping={"transfer_method": "tool_file", "reference": "x"},
|
||||
)
|
||||
assert exc_info.value.status_code == 502
|
||||
@@ -15,7 +15,7 @@ from services.file_request_service import FileRequestService
|
||||
("end-user", "service-api", UserFrom.END_USER, InvokeFrom.SERVICE_API),
|
||||
],
|
||||
)
|
||||
def test_request_download_url_builds_file_under_bound_scope(
|
||||
def test_request_download_builds_file_under_bound_scope(
|
||||
user_from: UserFrom | str,
|
||||
invoke_from: InvokeFrom | str,
|
||||
expected_user_from: UserFrom,
|
||||
@@ -29,12 +29,9 @@ def test_request_download_url_builds_file_under_bound_scope(
|
||||
with (
|
||||
patch("services.file_request_service.bind_file_access_scope", return_value=nullcontext()) as bind_scope,
|
||||
patch.object(service, "_build_file", return_value=fake_file) as build_file,
|
||||
patch(
|
||||
"services.file_request_service.file_helpers.resolve_file_url",
|
||||
return_value="https://files.example.com/x",
|
||||
) as resolve_file_url,
|
||||
patch.object(service._runtime, "resolve_file_uri", return_value="/files/tools/x?sign=1") as resolve_file_uri,
|
||||
):
|
||||
result = service.request_download_url(
|
||||
result = service.request_download(
|
||||
tenant_id="tenant-1",
|
||||
user_id="user-1",
|
||||
user_from=user_from,
|
||||
@@ -52,48 +49,23 @@ def test_request_download_url_builds_file_under_bound_scope(
|
||||
build_file.assert_called_once_with(
|
||||
mapping={"transfer_method": "tool_file", "reference": reference}, tenant_id="tenant-1"
|
||||
)
|
||||
resolve_file_url.assert_called_once_with(fake_file, for_external=True)
|
||||
resolve_file_uri.assert_called_once_with(file=fake_file)
|
||||
assert result.filename == "report.pdf"
|
||||
assert result.mime_type == "application/pdf"
|
||||
assert result.size == 123
|
||||
assert result.download_url == "https://files.example.com/x"
|
||||
assert result.download_uri == "/files/tools/x?sign=1"
|
||||
|
||||
|
||||
def test_request_download_url_supports_internal_download_urls() -> None:
|
||||
fake_file = MagicMock(filename="report.pdf", mime_type="application/pdf", size=123)
|
||||
service = FileRequestService(access_controller=MagicMock())
|
||||
|
||||
with (
|
||||
patch("services.file_request_service.bind_file_access_scope", return_value=nullcontext()),
|
||||
patch.object(service, "_build_file", return_value=fake_file),
|
||||
patch(
|
||||
"services.file_request_service.file_helpers.resolve_file_url",
|
||||
return_value="http://internal-files/report.pdf",
|
||||
) as resolve_file_url,
|
||||
):
|
||||
result = service.request_download_url(
|
||||
tenant_id="tenant-1",
|
||||
user_id="user-1",
|
||||
user_from="account",
|
||||
invoke_from="debugger",
|
||||
file_mapping={"transfer_method": "tool_file", "reference": "dify-file-ref:tool-file-1"},
|
||||
for_external=False,
|
||||
)
|
||||
|
||||
resolve_file_url.assert_called_once_with(fake_file, for_external=False)
|
||||
assert result.download_url == "http://internal-files/report.pdf"
|
||||
|
||||
|
||||
def test_request_download_url_rejects_unsupported_files() -> None:
|
||||
def test_request_download_rejects_unsupported_files() -> None:
|
||||
service = FileRequestService(access_controller=MagicMock())
|
||||
|
||||
with (
|
||||
patch("services.file_request_service.bind_file_access_scope", return_value=nullcontext()),
|
||||
patch.object(service, "_build_file", return_value=MagicMock(filename="report.pdf", mime_type=None, size=1)),
|
||||
patch("services.file_request_service.file_helpers.resolve_file_url", return_value=None),
|
||||
patch.object(service._runtime, "resolve_file_uri", return_value=None),
|
||||
):
|
||||
with pytest.raises(ValueError, match="file does not support signed download"):
|
||||
service.request_download_url(
|
||||
service.request_download(
|
||||
tenant_id="tenant-1",
|
||||
user_id="user-1",
|
||||
user_from="account",
|
||||
|
||||
Reference in New Issue
Block a user