mirror of
https://github.com/langgenius/dify.git
synced 2026-07-21 10:38:32 +08:00
fix(api, web): scope trial app file uploads to the app tenant (#39149)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
parent
6022939bf0
commit
f93a5b95c6
@ -47,7 +47,9 @@ from controllers.console.explore.error import (
|
||||
NotWorkflowAppError,
|
||||
)
|
||||
from controllers.console.explore.wraps import TrialAppResource, trial_feature_enable
|
||||
from controllers.console.wraps import with_current_user
|
||||
from controllers.console.files import FILE_UPLOAD_PARAMS, upload_file_from_request
|
||||
from controllers.console.remote_files import RemoteFileUploadPayload, upload_remote_file_from_request
|
||||
from controllers.console.wraps import cloud_edition_billing_resource_check, with_current_user
|
||||
from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError
|
||||
from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict
|
||||
from core.app.apps.base_app_queue_manager import AppQueueManager
|
||||
@ -61,12 +63,13 @@ from extensions.ext_database import db
|
||||
from extensions.ext_redis import redis_client
|
||||
from fields.base import ResponseModel
|
||||
from fields.conversation_variable_fields import WorkflowConversationVariableResponse
|
||||
from fields.file_fields import FileResponse, FileWithSignedUrl
|
||||
from fields.message_fields import SuggestedQuestionsResponse
|
||||
from graphon.graph_engine.manager import GraphEngineManager
|
||||
from graphon.model_runtime.errors.invoke import InvokeError
|
||||
from libs import helper
|
||||
from libs.helper import dump_response, to_timestamp, uuid_value
|
||||
from models import Account
|
||||
from models import Account, App
|
||||
from models.account import TenantStatus
|
||||
from models.model import AppMode, Site, load_annotation_reply_config
|
||||
from models.workflow import Workflow
|
||||
@ -428,6 +431,36 @@ register_response_schema_models(
|
||||
simple_account_model = console_ns.models[TrialSimpleAccount.__name__]
|
||||
|
||||
|
||||
class TrialAppFileUploadApi(TrialAppResource):
|
||||
@trial_feature_enable
|
||||
@cloud_edition_billing_resource_check("documents")
|
||||
@console_ns.doc(consumes=["multipart/form-data"], params=FILE_UPLOAD_PARAMS)
|
||||
@console_ns.response(201, "File uploaded successfully", console_ns.models[FileResponse.__name__])
|
||||
@with_current_user
|
||||
def post(self, current_user: Account, app_model: App):
|
||||
"""Upload a file into the tenant that owns the trial app."""
|
||||
upload_file = upload_file_from_request(
|
||||
current_user=current_user,
|
||||
resource_tenant_id=app_model.tenant_id,
|
||||
)
|
||||
return dump_response(FileResponse, upload_file), 201
|
||||
|
||||
|
||||
class TrialAppRemoteFileUploadApi(TrialAppResource):
|
||||
@trial_feature_enable
|
||||
@cloud_edition_billing_resource_check("documents")
|
||||
@console_ns.expect(console_ns.models[RemoteFileUploadPayload.__name__])
|
||||
@console_ns.response(201, "File uploaded successfully", console_ns.models[FileWithSignedUrl.__name__])
|
||||
@with_current_user
|
||||
def post(self, current_user: Account, app_model: App):
|
||||
"""Upload a remote file into the tenant that owns the trial app."""
|
||||
remote_file = upload_remote_file_from_request(
|
||||
current_user=current_user,
|
||||
resource_tenant_id=app_model.tenant_id,
|
||||
)
|
||||
return remote_file.model_dump(mode="json"), 201
|
||||
|
||||
|
||||
class TrialAppWorkflowRunApi(TrialAppResource):
|
||||
@trial_feature_enable
|
||||
@console_ns.expect(console_ns.models[WorkflowRunRequest.__name__])
|
||||
@ -887,6 +920,18 @@ class DatasetListApi(Resource):
|
||||
|
||||
console_ns.add_resource(TrialChatApi, "/trial-apps/<uuid:app_id>/chat-messages", endpoint="trial_app_chat_completion")
|
||||
|
||||
console_ns.add_resource(
|
||||
TrialAppFileUploadApi,
|
||||
"/trial-apps/<uuid:app_id>/files/upload",
|
||||
endpoint="trial_app_file_upload",
|
||||
)
|
||||
|
||||
console_ns.add_resource(
|
||||
TrialAppRemoteFileUploadApi,
|
||||
"/trial-apps/<uuid:app_id>/remote-files/upload",
|
||||
endpoint="trial_app_remote_file_upload",
|
||||
)
|
||||
|
||||
console_ns.add_resource(
|
||||
TrialMessageSuggestedQuestionApi,
|
||||
"/trial-apps/<uuid:app_id>/messages/<uuid:message_id>/suggested-questions",
|
||||
|
||||
@ -29,7 +29,7 @@ from extensions.ext_database import db
|
||||
from fields.file_fields import FileResponse, UploadConfig
|
||||
from libs.helper import dump_response
|
||||
from libs.login import login_required
|
||||
from models import Account
|
||||
from models import Account, UploadFile
|
||||
from services.file_service import FileService
|
||||
|
||||
from . import console_ns
|
||||
@ -39,7 +39,7 @@ register_response_schema_models(console_ns, AllowedExtensionsResponse, TextConte
|
||||
|
||||
PREVIEW_WORDS_LIMIT = 3000
|
||||
|
||||
_FILE_UPLOAD_PARAMS = {
|
||||
FILE_UPLOAD_PARAMS = {
|
||||
"file": {
|
||||
"description": "File to upload",
|
||||
"in": "formData",
|
||||
@ -56,6 +56,43 @@ _FILE_UPLOAD_PARAMS = {
|
||||
}
|
||||
|
||||
|
||||
def upload_file_from_request(*, current_user: Account, resource_tenant_id: str | None = None) -> UploadFile:
|
||||
"""Validate the multipart request and persist the file under the requested resource tenant."""
|
||||
source_str = request.form.get("source")
|
||||
source: Literal["datasets"] | None = "datasets" if source_str == "datasets" else None
|
||||
|
||||
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
|
||||
if source == "datasets" and not current_user.is_dataset_editor:
|
||||
raise Forbidden()
|
||||
|
||||
if source not in ("datasets", None):
|
||||
source = None
|
||||
|
||||
try:
|
||||
return FileService(db.engine).upload_file(
|
||||
filename=file.filename,
|
||||
content=file.stream.read(),
|
||||
mimetype=file.mimetype,
|
||||
user=current_user,
|
||||
tenant_id=resource_tenant_id,
|
||||
source=source,
|
||||
)
|
||||
except services.errors.file.FileTooLargeError as file_too_large_error:
|
||||
raise FileTooLargeError(file_too_large_error.description)
|
||||
except services.errors.file.UnsupportedFileTypeError:
|
||||
raise UnsupportedFileTypeError()
|
||||
except services.errors.file.BlockedFileExtensionError as blocked_extension_error:
|
||||
raise BlockedFileExtensionError(blocked_extension_error.description)
|
||||
|
||||
|
||||
@console_ns.route("/files/upload")
|
||||
class FileApi(Resource):
|
||||
@setup_required
|
||||
@ -81,42 +118,11 @@ class FileApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@cloud_edition_billing_resource_check("documents")
|
||||
@console_ns.doc(consumes=["multipart/form-data"], params=_FILE_UPLOAD_PARAMS)
|
||||
@console_ns.doc(consumes=["multipart/form-data"], params=FILE_UPLOAD_PARAMS)
|
||||
@console_ns.response(201, "File uploaded successfully", console_ns.models[FileResponse.__name__])
|
||||
@with_current_user
|
||||
def post(self, current_user: Account):
|
||||
source_str = request.form.get("source")
|
||||
source: Literal["datasets"] | None = "datasets" if source_str == "datasets" else None
|
||||
|
||||
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
|
||||
if source == "datasets" and not current_user.is_dataset_editor:
|
||||
raise Forbidden()
|
||||
|
||||
if source not in ("datasets", None):
|
||||
source = None
|
||||
|
||||
try:
|
||||
upload_file = FileService(db.engine).upload_file(
|
||||
filename=file.filename,
|
||||
content=file.stream.read(),
|
||||
mimetype=file.mimetype,
|
||||
user=current_user,
|
||||
source=source,
|
||||
)
|
||||
except services.errors.file.FileTooLargeError as file_too_large_error:
|
||||
raise FileTooLargeError(file_too_large_error.description)
|
||||
except services.errors.file.UnsupportedFileTypeError:
|
||||
raise UnsupportedFileTypeError()
|
||||
except services.errors.file.BlockedFileExtensionError as blocked_extension_error:
|
||||
raise BlockedFileExtensionError(blocked_extension_error.description)
|
||||
upload_file = upload_file_from_request(current_user=current_user)
|
||||
|
||||
return dump_response(FileResponse, upload_file), 201
|
||||
|
||||
|
||||
@ -46,6 +46,61 @@ class GetRemoteFileInfo(Resource):
|
||||
).model_dump(mode="json")
|
||||
|
||||
|
||||
def upload_remote_file_from_request(
|
||||
*,
|
||||
current_user: Account,
|
||||
resource_tenant_id: str | None = None,
|
||||
) -> FileWithSignedUrl:
|
||||
"""Validate the JSON request, fetch its remote file, and persist it under the requested tenant."""
|
||||
payload = RemoteFileUploadPayload.model_validate(console_ns.payload)
|
||||
url = payload.url
|
||||
|
||||
# Try to fetch remote file metadata/content first
|
||||
try:
|
||||
resp = remote_fetcher.make_request("HEAD", url=url)
|
||||
if resp.status_code != httpx.codes.OK:
|
||||
resp = remote_fetcher.make_request("GET", url=url, timeout=3, follow_redirects=True)
|
||||
if resp.status_code != httpx.codes.OK:
|
||||
# Normalize into a user-friendly error message expected by tests
|
||||
raise RemoteFileUploadError(f"Failed to fetch file from {url}: {resp.text}")
|
||||
except httpx.RequestError as e:
|
||||
raise RemoteFileUploadError(f"Failed to fetch file from {url}: {str(e)}")
|
||||
|
||||
file_info = helpers.guess_file_info_from_response(resp)
|
||||
|
||||
# Enforce file size limit with 400 (Bad Request) per tests' expectation
|
||||
if not FileService.is_file_size_within_limit(extension=file_info.extension, file_size=file_info.size):
|
||||
raise FileTooLargeError()
|
||||
|
||||
# Load content if needed
|
||||
content = resp.content if resp.request.method == "GET" else remote_fetcher.make_request("GET", url).content
|
||||
|
||||
try:
|
||||
upload_file = FileService(db.engine).upload_file(
|
||||
filename=file_info.filename,
|
||||
content=content,
|
||||
mimetype=file_info.mimetype,
|
||||
user=current_user,
|
||||
tenant_id=resource_tenant_id,
|
||||
source_url=url,
|
||||
)
|
||||
except services.errors.file.FileTooLargeError as file_too_large_error:
|
||||
raise FileTooLargeError(file_too_large_error.description)
|
||||
except services.errors.file.UnsupportedFileTypeError:
|
||||
raise UnsupportedFileTypeError()
|
||||
|
||||
return FileWithSignedUrl(
|
||||
id=upload_file.id,
|
||||
name=upload_file.name,
|
||||
size=upload_file.size,
|
||||
extension=upload_file.extension,
|
||||
url=file_helpers.get_signed_file_url(upload_file_id=upload_file.id),
|
||||
mime_type=upload_file.mime_type,
|
||||
created_by=upload_file.created_by,
|
||||
created_at=int(upload_file.created_at.timestamp()),
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/remote-files/upload")
|
||||
class RemoteFileUpload(Resource):
|
||||
@console_ns.expect(console_ns.models[RemoteFileUploadPayload.__name__])
|
||||
@ -53,53 +108,8 @@ class RemoteFileUpload(Resource):
|
||||
@login_required
|
||||
@with_current_user
|
||||
def post(self, current_user: Account):
|
||||
payload = RemoteFileUploadPayload.model_validate(console_ns.payload)
|
||||
url = payload.url
|
||||
|
||||
# Try to fetch remote file metadata/content first
|
||||
try:
|
||||
resp = remote_fetcher.make_request("HEAD", url=url)
|
||||
if resp.status_code != httpx.codes.OK:
|
||||
resp = remote_fetcher.make_request("GET", url=url, timeout=3, follow_redirects=True)
|
||||
if resp.status_code != httpx.codes.OK:
|
||||
# Normalize into a user-friendly error message expected by tests
|
||||
raise RemoteFileUploadError(f"Failed to fetch file from {url}: {resp.text}")
|
||||
except httpx.RequestError as e:
|
||||
raise RemoteFileUploadError(f"Failed to fetch file from {url}: {str(e)}")
|
||||
|
||||
file_info = helpers.guess_file_info_from_response(resp)
|
||||
|
||||
# Enforce file size limit with 400 (Bad Request) per tests' expectation
|
||||
if not FileService.is_file_size_within_limit(extension=file_info.extension, file_size=file_info.size):
|
||||
raise FileTooLargeError()
|
||||
|
||||
# Load content if needed
|
||||
content = resp.content if resp.request.method == "GET" else remote_fetcher.make_request("GET", url).content
|
||||
|
||||
try:
|
||||
upload_file = FileService(db.engine).upload_file(
|
||||
filename=file_info.filename,
|
||||
content=content,
|
||||
mimetype=file_info.mimetype,
|
||||
user=current_user,
|
||||
source_url=url,
|
||||
)
|
||||
except services.errors.file.FileTooLargeError as file_too_large_error:
|
||||
raise FileTooLargeError(file_too_large_error.description)
|
||||
except services.errors.file.UnsupportedFileTypeError:
|
||||
raise UnsupportedFileTypeError()
|
||||
|
||||
# Success: return created resource with 201 status
|
||||
remote_file = upload_remote_file_from_request(current_user=current_user)
|
||||
return (
|
||||
FileWithSignedUrl(
|
||||
id=upload_file.id,
|
||||
name=upload_file.name,
|
||||
size=upload_file.size,
|
||||
extension=upload_file.extension,
|
||||
url=file_helpers.get_signed_file_url(upload_file_id=upload_file.id),
|
||||
mime_type=upload_file.mime_type,
|
||||
created_by=upload_file.created_by,
|
||||
created_at=int(upload_file.created_at.timestamp()),
|
||||
).model_dump(mode="json"),
|
||||
remote_file.model_dump(mode="json"),
|
||||
201,
|
||||
)
|
||||
|
||||
@ -9639,6 +9639,27 @@ Bedrock retrieval test (internal use only)
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Success | **application/json**: [TrialDatasetListResponse](#trialdatasetlistresponse)<br> |
|
||||
|
||||
### [POST] /trial-apps/{app_id}/files/upload
|
||||
**Upload a file into the tenant that owns the trial app**
|
||||
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| app_id | path | | Yes | string (uuid) |
|
||||
|
||||
#### Request Body
|
||||
|
||||
| Required | Schema |
|
||||
| -------- | ------ |
|
||||
| Yes | **multipart/form-data**: { **"file"**: binary, **"source"**: string, <br>**Available values:** "datasets" }<br> |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 201 | File uploaded successfully | **application/json**: [FileResponse](#fileresponse)<br> |
|
||||
|
||||
### [GET] /trial-apps/{app_id}/messages/{message_id}/suggested-questions
|
||||
#### Parameters
|
||||
|
||||
@ -9668,6 +9689,27 @@ Bedrock retrieval test (internal use only)
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Success | **application/json**: [Parameters](#parameters)<br> |
|
||||
|
||||
### [POST] /trial-apps/{app_id}/remote-files/upload
|
||||
**Upload a remote file into the tenant that owns the trial app**
|
||||
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| app_id | path | | Yes | string (uuid) |
|
||||
|
||||
#### Request Body
|
||||
|
||||
| Required | Schema |
|
||||
| -------- | ------ |
|
||||
| Yes | **application/json**: [RemoteFileUploadPayload](#remotefileuploadpayload)<br> |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 201 | File uploaded successfully | **application/json**: [FileWithSignedUrl](#filewithsignedurl)<br> |
|
||||
|
||||
### [GET] /trial-apps/{app_id}/site
|
||||
**Retrieve app site info**
|
||||
|
||||
|
||||
@ -189,6 +189,44 @@ def test_trial_app_detail_serializes_with_explicit_session(app: Flask, monkeypat
|
||||
module.TrialAppDetailResponse.model_validate.assert_called_once_with(response_view, from_attributes=True)
|
||||
|
||||
|
||||
class TestTrialAppFileUploadApi:
|
||||
def test_upload_uses_trial_app_tenant(self, app: Flask, account: Account) -> None:
|
||||
api = module.TrialAppFileUploadApi()
|
||||
method = unwrap(api.post)
|
||||
app_model = SimpleNamespace(tenant_id="app-tenant-id")
|
||||
upload_file = MagicMock()
|
||||
|
||||
with (
|
||||
app.test_request_context("/", method="POST"),
|
||||
patch.object(module, "upload_file_from_request", return_value=upload_file) as upload,
|
||||
patch.object(module, "dump_response", return_value={"id": "upload-file-id"}),
|
||||
):
|
||||
response, status = method(api, account, app_model)
|
||||
|
||||
assert status == 201
|
||||
assert response == {"id": "upload-file-id"}
|
||||
upload.assert_called_once_with(current_user=account, resource_tenant_id="app-tenant-id")
|
||||
|
||||
|
||||
class TestTrialAppRemoteFileUploadApi:
|
||||
def test_upload_uses_trial_app_tenant(self, app: Flask, account: Account) -> None:
|
||||
api = module.TrialAppRemoteFileUploadApi()
|
||||
method = unwrap(api.post)
|
||||
app_model = SimpleNamespace(tenant_id="app-tenant-id")
|
||||
remote_file = MagicMock()
|
||||
remote_file.model_dump.return_value = {"id": "upload-file-id"}
|
||||
|
||||
with (
|
||||
app.test_request_context("/", method="POST", json={"url": "https://example.com/file.txt"}),
|
||||
patch.object(module, "upload_remote_file_from_request", return_value=remote_file) as upload,
|
||||
):
|
||||
response, status = method(api, account, app_model)
|
||||
|
||||
assert status == 201
|
||||
assert response == {"id": "upload-file-id"}
|
||||
upload.assert_called_once_with(current_user=account, resource_tenant_id="app-tenant-id")
|
||||
|
||||
|
||||
class TestTrialAppWorkflowRunApi:
|
||||
def test_not_workflow_app(self, app: Flask, account: Account) -> None:
|
||||
api = module.TrialAppWorkflowRunApi()
|
||||
|
||||
@ -18,6 +18,7 @@ from controllers.console.files import (
|
||||
FileApi,
|
||||
FilePreviewApi,
|
||||
FileSupportTypeApi,
|
||||
upload_file_from_request,
|
||||
)
|
||||
from models import Account
|
||||
from models.account import AccountStatus, TenantAccountRole
|
||||
@ -181,6 +182,22 @@ class TestFileApiPost:
|
||||
assert response["id"] == "file-id-123"
|
||||
assert response["name"] == "test.txt"
|
||||
|
||||
def test_upload_with_resource_tenant(self, app: Flask, mock_account_context, mock_file_service):
|
||||
upload_file = MagicMock()
|
||||
mock_file_service.upload_file.return_value = upload_file
|
||||
|
||||
with app.test_request_context(
|
||||
method="POST",
|
||||
data={"file": (io.BytesIO(b"hello"), "test.txt")},
|
||||
):
|
||||
result = upload_file_from_request(
|
||||
current_user=mock_account_context,
|
||||
resource_tenant_id="app-tenant-id",
|
||||
)
|
||||
|
||||
assert result is upload_file
|
||||
assert mock_file_service.upload_file.call_args.kwargs["tenant_id"] == "app-tenant-id"
|
||||
|
||||
def test_upload_with_invalid_source(self, app: Flask, mock_account_context, mock_file_service):
|
||||
"""Test that invalid source parameter gets normalized to None"""
|
||||
api = FileApi()
|
||||
|
||||
@ -186,6 +186,39 @@ def test_remote_file_upload_success_when_fetch_falls_back_to_get(app: Flask, mon
|
||||
content=b"fallback-content",
|
||||
mimetype="text/plain",
|
||||
user=current_user,
|
||||
tenant_id=None,
|
||||
source_url=url,
|
||||
)
|
||||
|
||||
|
||||
def test_remote_file_upload_assigns_resource_tenant(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
url = "https://example.com/report.txt"
|
||||
response = _FakeResponse(status_code=200, method="GET", content=b"content")
|
||||
monkeypatch.setattr(remote_files_module.remote_fetcher, "make_request", MagicMock(return_value=response))
|
||||
|
||||
file_service_cls, current_user = _mock_upload_dependencies(monkeypatch)
|
||||
file_service_cls.return_value.upload_file.return_value = SimpleNamespace(
|
||||
id="file-1",
|
||||
name="report.txt",
|
||||
size=7,
|
||||
extension=".txt",
|
||||
mime_type="text/plain",
|
||||
created_by="u1",
|
||||
created_at=datetime(2024, 1, 1, tzinfo=UTC),
|
||||
)
|
||||
|
||||
with app.test_request_context(method="POST", json={"url": url}):
|
||||
remote_files_module.upload_remote_file_from_request(
|
||||
current_user=current_user,
|
||||
resource_tenant_id="app-tenant-id",
|
||||
)
|
||||
|
||||
file_service_cls.return_value.upload_file.assert_called_once_with(
|
||||
filename="report.txt",
|
||||
content=b"content",
|
||||
mimetype="text/plain",
|
||||
user=current_user,
|
||||
tenant_id="app-tenant-id",
|
||||
source_url=url,
|
||||
)
|
||||
|
||||
|
||||
@ -24,6 +24,12 @@ import {
|
||||
zPostTrialAppsByAppIdCompletionMessagesBody,
|
||||
zPostTrialAppsByAppIdCompletionMessagesPath,
|
||||
zPostTrialAppsByAppIdCompletionMessagesResponse,
|
||||
zPostTrialAppsByAppIdFilesUploadBody,
|
||||
zPostTrialAppsByAppIdFilesUploadPath,
|
||||
zPostTrialAppsByAppIdFilesUploadResponse,
|
||||
zPostTrialAppsByAppIdRemoteFilesUploadBody,
|
||||
zPostTrialAppsByAppIdRemoteFilesUploadPath,
|
||||
zPostTrialAppsByAppIdRemoteFilesUploadResponse,
|
||||
zPostTrialAppsByAppIdTextToAudioBody,
|
||||
zPostTrialAppsByAppIdTextToAudioPath,
|
||||
zPostTrialAppsByAppIdTextToAudioResponse,
|
||||
@ -109,6 +115,35 @@ export const datasets = {
|
||||
get,
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a file into the tenant that owns the trial app
|
||||
*/
|
||||
export const post4 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
operationId: 'postTrialAppsByAppIdFilesUpload',
|
||||
path: '/trial-apps/{app_id}/files/upload',
|
||||
successStatus: 201,
|
||||
summary: 'Upload a file into the tenant that owns the trial app',
|
||||
tags: ['console'],
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
body: zPostTrialAppsByAppIdFilesUploadBody,
|
||||
params: zPostTrialAppsByAppIdFilesUploadPath,
|
||||
}),
|
||||
)
|
||||
.output(zPostTrialAppsByAppIdFilesUploadResponse)
|
||||
|
||||
export const upload = {
|
||||
post: post4,
|
||||
}
|
||||
|
||||
export const files = {
|
||||
upload,
|
||||
}
|
||||
|
||||
export const get2 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
@ -151,6 +186,35 @@ export const parameters = {
|
||||
get: get3,
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a remote file into the tenant that owns the trial app
|
||||
*/
|
||||
export const post5 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
operationId: 'postTrialAppsByAppIdRemoteFilesUpload',
|
||||
path: '/trial-apps/{app_id}/remote-files/upload',
|
||||
successStatus: 201,
|
||||
summary: 'Upload a remote file into the tenant that owns the trial app',
|
||||
tags: ['console'],
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
body: zPostTrialAppsByAppIdRemoteFilesUploadBody,
|
||||
params: zPostTrialAppsByAppIdRemoteFilesUploadPath,
|
||||
}),
|
||||
)
|
||||
.output(zPostTrialAppsByAppIdRemoteFilesUploadResponse)
|
||||
|
||||
export const upload2 = {
|
||||
post: post5,
|
||||
}
|
||||
|
||||
export const remoteFiles = {
|
||||
upload: upload2,
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve app site info
|
||||
*
|
||||
@ -174,7 +238,7 @@ export const site = {
|
||||
get: get4,
|
||||
}
|
||||
|
||||
export const post4 = oc
|
||||
export const post6 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
@ -191,13 +255,13 @@ export const post4 = oc
|
||||
.output(zPostTrialAppsByAppIdTextToAudioResponse)
|
||||
|
||||
export const textToAudio = {
|
||||
post: post4,
|
||||
post: post6,
|
||||
}
|
||||
|
||||
/**
|
||||
* Run workflow
|
||||
*/
|
||||
export const post5 = oc
|
||||
export const post7 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
@ -215,13 +279,13 @@ export const post5 = oc
|
||||
.output(zPostTrialAppsByAppIdWorkflowsRunResponse)
|
||||
|
||||
export const run = {
|
||||
post: post5,
|
||||
post: post7,
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop workflow task
|
||||
*/
|
||||
export const post6 = oc
|
||||
export const post8 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
@ -234,7 +298,7 @@ export const post6 = oc
|
||||
.output(zPostTrialAppsByAppIdWorkflowsTasksByTaskIdStopResponse)
|
||||
|
||||
export const stop = {
|
||||
post: post6,
|
||||
post: post8,
|
||||
}
|
||||
|
||||
export const byTaskId = {
|
||||
@ -287,8 +351,10 @@ export const byAppId = {
|
||||
chatMessages,
|
||||
completionMessages,
|
||||
datasets,
|
||||
files,
|
||||
messages,
|
||||
parameters,
|
||||
remoteFiles,
|
||||
site,
|
||||
textToAudio,
|
||||
workflows,
|
||||
|
||||
@ -64,6 +64,24 @@ export type TrialDatasetListResponse = {
|
||||
total: number
|
||||
}
|
||||
|
||||
export type FileResponse = {
|
||||
conversation_id?: string | null
|
||||
created_at?: number | null
|
||||
created_by?: string | null
|
||||
extension?: string | null
|
||||
file_key?: string | null
|
||||
id: string
|
||||
mime_type?: string | null
|
||||
name: string
|
||||
original_url?: string | null
|
||||
preview_url?: string | null
|
||||
reference?: string | null
|
||||
size: number
|
||||
source_url?: string | null
|
||||
tenant_id?: string | null
|
||||
user_id?: string | null
|
||||
}
|
||||
|
||||
export type SuggestedQuestionsResponse = {
|
||||
data: Array<string>
|
||||
}
|
||||
@ -83,6 +101,21 @@ export type Parameters = {
|
||||
user_input_form: Array<JsonObject>
|
||||
}
|
||||
|
||||
export type RemoteFileUploadPayload = {
|
||||
url: string
|
||||
}
|
||||
|
||||
export type FileWithSignedUrl = {
|
||||
created_at: number | null
|
||||
created_by: string | null
|
||||
extension: string | null
|
||||
id: string
|
||||
mime_type: string | null
|
||||
name: string
|
||||
size: number
|
||||
url: string | null
|
||||
}
|
||||
|
||||
export type Site = {
|
||||
chat_color_theme?: string | null
|
||||
chat_color_theme_inverted: boolean
|
||||
@ -378,6 +411,25 @@ export type GetTrialAppsByAppIdDatasetsResponses = {
|
||||
export type GetTrialAppsByAppIdDatasetsResponse =
|
||||
GetTrialAppsByAppIdDatasetsResponses[keyof GetTrialAppsByAppIdDatasetsResponses]
|
||||
|
||||
export type PostTrialAppsByAppIdFilesUploadData = {
|
||||
body: {
|
||||
file: Blob | File
|
||||
source?: 'datasets'
|
||||
}
|
||||
path: {
|
||||
app_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/trial-apps/{app_id}/files/upload'
|
||||
}
|
||||
|
||||
export type PostTrialAppsByAppIdFilesUploadResponses = {
|
||||
201: FileResponse
|
||||
}
|
||||
|
||||
export type PostTrialAppsByAppIdFilesUploadResponse =
|
||||
PostTrialAppsByAppIdFilesUploadResponses[keyof PostTrialAppsByAppIdFilesUploadResponses]
|
||||
|
||||
export type GetTrialAppsByAppIdMessagesByMessageIdSuggestedQuestionsData = {
|
||||
body?: never
|
||||
path: {
|
||||
@ -411,6 +463,22 @@ export type GetTrialAppsByAppIdParametersResponses = {
|
||||
export type GetTrialAppsByAppIdParametersResponse =
|
||||
GetTrialAppsByAppIdParametersResponses[keyof GetTrialAppsByAppIdParametersResponses]
|
||||
|
||||
export type PostTrialAppsByAppIdRemoteFilesUploadData = {
|
||||
body: RemoteFileUploadPayload
|
||||
path: {
|
||||
app_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/trial-apps/{app_id}/remote-files/upload'
|
||||
}
|
||||
|
||||
export type PostTrialAppsByAppIdRemoteFilesUploadResponses = {
|
||||
201: FileWithSignedUrl
|
||||
}
|
||||
|
||||
export type PostTrialAppsByAppIdRemoteFilesUploadResponse =
|
||||
PostTrialAppsByAppIdRemoteFilesUploadResponses[keyof PostTrialAppsByAppIdRemoteFilesUploadResponses]
|
||||
|
||||
export type GetTrialAppsByAppIdSiteData = {
|
||||
body?: never
|
||||
path: {
|
||||
|
||||
@ -32,6 +32,27 @@ export const zCompletionRequest = z.object({
|
||||
retriever_from: z.string().optional().default('explore_app'),
|
||||
})
|
||||
|
||||
/**
|
||||
* FileResponse
|
||||
*/
|
||||
export const zFileResponse = z.object({
|
||||
conversation_id: z.string().nullish(),
|
||||
created_at: z.int().nullish(),
|
||||
created_by: z.string().nullish(),
|
||||
extension: z.string().nullish(),
|
||||
file_key: z.string().nullish(),
|
||||
id: z.string(),
|
||||
mime_type: z.string().nullish(),
|
||||
name: z.string(),
|
||||
original_url: z.string().nullish(),
|
||||
preview_url: z.string().nullish(),
|
||||
reference: z.string().nullish(),
|
||||
size: z.int(),
|
||||
source_url: z.string().nullish(),
|
||||
tenant_id: z.string().nullish(),
|
||||
user_id: z.string().nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* SuggestedQuestionsResponse
|
||||
*/
|
||||
@ -39,6 +60,27 @@ export const zSuggestedQuestionsResponse = z.object({
|
||||
data: z.array(z.string()),
|
||||
})
|
||||
|
||||
/**
|
||||
* RemoteFileUploadPayload
|
||||
*/
|
||||
export const zRemoteFileUploadPayload = z.object({
|
||||
url: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* FileWithSignedUrl
|
||||
*/
|
||||
export const zFileWithSignedUrl = z.object({
|
||||
created_at: z.int().nullable(),
|
||||
created_by: z.string().nullable(),
|
||||
extension: z.string().nullable(),
|
||||
id: z.string(),
|
||||
mime_type: z.string().nullable(),
|
||||
name: z.string(),
|
||||
size: z.int(),
|
||||
url: z.string().nullable(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Site
|
||||
*/
|
||||
@ -414,6 +456,20 @@ export const zGetTrialAppsByAppIdDatasetsQuery = z.object({
|
||||
*/
|
||||
export const zGetTrialAppsByAppIdDatasetsResponse = zTrialDatasetListResponse
|
||||
|
||||
export const zPostTrialAppsByAppIdFilesUploadBody = z.object({
|
||||
file: z.custom<Blob | File>(),
|
||||
source: z.enum(['datasets']).optional(),
|
||||
})
|
||||
|
||||
export const zPostTrialAppsByAppIdFilesUploadPath = z.object({
|
||||
app_id: z.uuid(),
|
||||
})
|
||||
|
||||
/**
|
||||
* File uploaded successfully
|
||||
*/
|
||||
export const zPostTrialAppsByAppIdFilesUploadResponse = zFileResponse
|
||||
|
||||
export const zGetTrialAppsByAppIdMessagesByMessageIdSuggestedQuestionsPath = z.object({
|
||||
app_id: z.uuid(),
|
||||
message_id: z.uuid(),
|
||||
@ -434,6 +490,17 @@ export const zGetTrialAppsByAppIdParametersPath = z.object({
|
||||
*/
|
||||
export const zGetTrialAppsByAppIdParametersResponse = zParameters
|
||||
|
||||
export const zPostTrialAppsByAppIdRemoteFilesUploadBody = zRemoteFileUploadPayload
|
||||
|
||||
export const zPostTrialAppsByAppIdRemoteFilesUploadPath = z.object({
|
||||
app_id: z.uuid(),
|
||||
})
|
||||
|
||||
/**
|
||||
* File uploaded successfully
|
||||
*/
|
||||
export const zPostTrialAppsByAppIdRemoteFilesUploadResponse = zFileWithSignedUrl
|
||||
|
||||
export const zGetTrialAppsByAppIdSitePath = z.object({
|
||||
app_id: z.uuid(),
|
||||
})
|
||||
|
||||
@ -9,12 +9,20 @@ const mockNavigationState = vi.hoisted(() => ({
|
||||
params: {} as { token?: string },
|
||||
pathname: '/chat',
|
||||
}))
|
||||
const mockFileUploadContext = vi.hoisted(() => ({
|
||||
localUploadUrl: undefined as string | undefined,
|
||||
remoteUploadUrl: undefined as string | undefined,
|
||||
}))
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useParams: () => mockNavigationState.params,
|
||||
usePathname: () => mockNavigationState.pathname,
|
||||
}))
|
||||
|
||||
vi.mock('../upload-context', () => ({
|
||||
useFileUploadContext: () => mockFileUploadContext,
|
||||
}))
|
||||
|
||||
vi.mock('@langgenius/dify-ui/toast', () => ({
|
||||
toast: {
|
||||
error: (message: string) => mockNotify({ type: 'error', message }),
|
||||
@ -124,6 +132,8 @@ describe('useFile', () => {
|
||||
mockStoreFiles = []
|
||||
mockNavigationState.params = {}
|
||||
mockNavigationState.pathname = '/chat'
|
||||
mockFileUploadContext.localUploadUrl = undefined
|
||||
mockFileUploadContext.remoteUploadUrl = undefined
|
||||
mockIsAllowedFileExtension.mockReturnValue(true)
|
||||
mockGetSupportFileType.mockReturnValue('document')
|
||||
})
|
||||
@ -369,6 +379,21 @@ describe('useFile', () => {
|
||||
expect(mockUploadRemoteFileInfo).toHaveBeenCalledWith('https://example.com/file.txt', false)
|
||||
})
|
||||
|
||||
it('should upload a remote file through the configured resource-scoped endpoint', () => {
|
||||
mockFileUploadContext.remoteUploadUrl = '/trial-apps/app-id/remote-files/upload'
|
||||
mockUploadRemoteFileInfo.mockReturnValue(new Promise(() => {}))
|
||||
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
result.current.handleLoadFileFromLink('https://example.com/file.txt')
|
||||
|
||||
expect(mockUploadRemoteFileInfo).toHaveBeenCalledWith(
|
||||
'https://example.com/file.txt',
|
||||
false,
|
||||
undefined,
|
||||
'/trial-apps/app-id/remote-files/upload',
|
||||
)
|
||||
})
|
||||
|
||||
it('should use human input form remote upload on form page', () => {
|
||||
mockNavigationState.params = { token: 'form-token' }
|
||||
mockNavigationState.pathname = '/form/form-token'
|
||||
@ -782,6 +807,20 @@ describe('useFile', () => {
|
||||
expect(mockSetFiles).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should upload through the configured resource-scoped endpoint', () => {
|
||||
const file = new File(['content'], 'test.txt', { type: 'text/plain' })
|
||||
mockFileUploadContext.localUploadUrl = '/trial-apps/app-id/files/upload'
|
||||
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
result.current.handleLocalFileUpload(file)
|
||||
|
||||
expect(mockFileUpload).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
false,
|
||||
'/trial-apps/app-id/files/upload',
|
||||
)
|
||||
})
|
||||
|
||||
it('should use human input form local upload on form page', () => {
|
||||
mockNavigationState.params = { token: 'form-token' }
|
||||
mockNavigationState.pathname = '/form/form-token'
|
||||
|
||||
@ -22,6 +22,7 @@ import { uploadHumanInputFormLocalFile, uploadHumanInputFormRemoteFileInfo } fro
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import { formatFileSize } from '@/utils/format'
|
||||
import { useFileStore } from './store'
|
||||
import { useFileUploadContext } from './upload-context'
|
||||
import {
|
||||
fileUpload,
|
||||
getFileUploadErrorMessage,
|
||||
@ -54,6 +55,7 @@ export const useFile = (fileConfig: FileUpload, noNeedToCheckEnable = true) => {
|
||||
const fileStore = useFileStore()
|
||||
const params = useParams()
|
||||
const pathname = usePathname()
|
||||
const { localUploadUrl, remoteUploadUrl } = useFileUploadContext()
|
||||
const { imgSizeLimit, docSizeLimit, audioSizeLimit, videoSizeLimit } = useFileSizeLimit(
|
||||
fileConfig.fileUploadConfig,
|
||||
)
|
||||
@ -196,11 +198,11 @@ export const useFile = (fileConfig: FileUpload, noNeedToCheckEnable = true) => {
|
||||
...uploadParams,
|
||||
})
|
||||
} else {
|
||||
fileUpload(uploadParams, !!params.token)
|
||||
fileUpload(uploadParams, !!params.token, localUploadUrl)
|
||||
}
|
||||
}
|
||||
},
|
||||
[fileStore, t, handleUpdateFile, isHumanInputFormPage, formToken, params.token],
|
||||
[fileStore, t, handleUpdateFile, isHumanInputFormPage, formToken, params.token, localUploadUrl],
|
||||
)
|
||||
|
||||
const startProgressTimer = useCallback(
|
||||
@ -234,9 +236,11 @@ export const useFile = (fileConfig: FileUpload, noNeedToCheckEnable = true) => {
|
||||
handleAddFile(uploadingFile)
|
||||
startProgressTimer(uploadingFile.id)
|
||||
|
||||
const remoteUpload = isHumanInputFormPage
|
||||
? uploadHumanInputFormRemoteFileInfo(formToken!, url)
|
||||
: uploadRemoteFileInfo(url, !!params.token)
|
||||
let remoteUpload
|
||||
if (isHumanInputFormPage) remoteUpload = uploadHumanInputFormRemoteFileInfo(formToken!, url)
|
||||
else if (remoteUploadUrl)
|
||||
remoteUpload = uploadRemoteFileInfo(url, !!params.token, undefined, remoteUploadUrl)
|
||||
else remoteUpload = uploadRemoteFileInfo(url, !!params.token)
|
||||
|
||||
remoteUpload
|
||||
.then((res) => {
|
||||
@ -287,6 +291,7 @@ export const useFile = (fileConfig: FileUpload, noNeedToCheckEnable = true) => {
|
||||
isHumanInputFormPage,
|
||||
formToken,
|
||||
params.token,
|
||||
remoteUploadUrl,
|
||||
],
|
||||
)
|
||||
|
||||
@ -374,7 +379,7 @@ export const useFile = (fileConfig: FileUpload, noNeedToCheckEnable = true) => {
|
||||
...uploadParams,
|
||||
})
|
||||
} else {
|
||||
fileUpload(uploadParams, !!params.token)
|
||||
fileUpload(uploadParams, !!params.token, localUploadUrl)
|
||||
}
|
||||
},
|
||||
false,
|
||||
@ -397,6 +402,7 @@ export const useFile = (fileConfig: FileUpload, noNeedToCheckEnable = true) => {
|
||||
isHumanInputFormPage,
|
||||
formToken,
|
||||
params.token,
|
||||
localUploadUrl,
|
||||
fileConfig?.allowed_file_types,
|
||||
fileConfig?.allowed_file_extensions,
|
||||
fileConfig?.enabled,
|
||||
|
||||
10
web/app/components/base/file-uploader/upload-context.ts
Normal file
10
web/app/components/base/file-uploader/upload-context.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { createContext, use } from 'react'
|
||||
|
||||
export const FileUploadContext = createContext<{
|
||||
localUploadUrl?: string
|
||||
remoteUploadUrl?: string
|
||||
}>({})
|
||||
|
||||
export function useFileUploadContext() {
|
||||
return use(FileUploadContext)
|
||||
}
|
||||
@ -1,8 +1,8 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import type { AppData } from '@/models/share'
|
||||
import type { TryAppInfo } from '@/service/try-app'
|
||||
import * as React from 'react'
|
||||
import { memo } from 'react'
|
||||
import { FileUploadContext } from '@/app/components/base/file-uploader/upload-context'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import Chat from './chat'
|
||||
import TextGeneration from './text-generation'
|
||||
@ -12,30 +12,37 @@ type Props = Readonly<{
|
||||
appDetail: TryAppInfo
|
||||
}>
|
||||
|
||||
const TryApp: FC<Props> = ({ appId, appDetail }) => {
|
||||
function TryApp({ appId, appDetail }: Props) {
|
||||
const mode = appDetail?.mode
|
||||
const isChat = ['chat', 'advanced-chat', 'agent-chat'].includes(mode!)
|
||||
const isCompletion = !isChat
|
||||
|
||||
useDocumentTitle(appDetail?.site?.title || '')
|
||||
return (
|
||||
<div className="flex size-full">
|
||||
{isChat && <Chat appId={appId} appDetail={appDetail} className="h-full grow" />}
|
||||
{isCompletion && (
|
||||
<TextGeneration
|
||||
appId={appId}
|
||||
className="h-full grow"
|
||||
isWorkflow={mode === 'workflow'}
|
||||
appData={
|
||||
{
|
||||
app_id: appId,
|
||||
custom_config: {},
|
||||
...appDetail,
|
||||
} as AppData
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<FileUploadContext
|
||||
value={{
|
||||
localUploadUrl: `/trial-apps/${appId}/files/upload`,
|
||||
remoteUploadUrl: `/trial-apps/${appId}/remote-files/upload`,
|
||||
}}
|
||||
>
|
||||
<div className="flex size-full">
|
||||
{isChat && <Chat appId={appId} appDetail={appDetail} className="h-full grow" />}
|
||||
{isCompletion && (
|
||||
<TextGeneration
|
||||
appId={appId}
|
||||
className="h-full grow"
|
||||
isWorkflow={mode === 'workflow'}
|
||||
appData={
|
||||
{
|
||||
app_id: appId,
|
||||
custom_config: {},
|
||||
...appDetail,
|
||||
} as AppData
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</FileUploadContext>
|
||||
)
|
||||
}
|
||||
export default React.memo(TryApp)
|
||||
export default memo(TryApp)
|
||||
|
||||
@ -223,9 +223,10 @@ export const uploadRemoteFileInfo = (
|
||||
url: string,
|
||||
isPublic?: boolean,
|
||||
silent?: boolean,
|
||||
uploadEndpoint?: string,
|
||||
): Promise<{ id: string; name: string; size: number; mime_type: string; url: string }> => {
|
||||
return post<{ id: string; name: string; size: number; mime_type: string; url: string }>(
|
||||
'/remote-files/upload',
|
||||
uploadEndpoint || '/remote-files/upload',
|
||||
{ body: { url } },
|
||||
{ isPublicAPI: isPublic, silent },
|
||||
)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user