"""File endpoints reached with an AppDeploy file grant. Upload, remote-upload, produce, and resolve authenticate with a Bearer grant; content authenticates with a per-file token in the query string, because an ```` cannot carry a header. They stay on the public ``files`` blueprint rather than moving under ``inner_api``, whose contract is a fully trusted caller holding the master key. None of the five meets it: ``content`` is a browser-facing surface, ``upload`` carries an end user's payload, and ``produced`` and ``resolve`` are called by workers executing third-party plugin code. The one genuinely server-to-server step, minting the grant, already lives in ``inner_api``. """ from __future__ import annotations from typing import IO from urllib.parse import quote from uuid import UUID from flask import Response, request from flask_restx import Resource from pydantic import BaseModel, Field, HttpUrl, ValidationError from werkzeug.datastructures import FileStorage import services from controllers.common.errors import ( BlockedFileExtensionError, FilenameNotExistsError, FileTooLargeError, NoFileUploadedError, RemoteFileUploadError, TooManyFilesError, UnsupportedFileTypeError, ) from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models from controllers.files import files_ns from controllers.files.wraps import FileGrantInvalidError, GrantedFileNotFoundError, file_grant_required from extensions.ext_application_services import application_services from fields.base import ResponseModel from fields.file_fields import FileResponse from fields.file_grant_fields import ResolvedFileResponse from libs.exception import BaseHTTPException from libs.helper import dump_response from services.entities.file_grant_entities import ( FileContent, FileGrantClaims, FileGrantContext, FileGrantScope, FileKind, FileRef, StoredUpload, ) from services.errors.file_grant import ( EndUserNotFoundError, InvalidFileGrantError, RemoteFileUnavailableError, TooManyFileRefsError, ) from services.file_grant_service import MAX_FILE_GRANT_REFS # Everything outside this whitelist is served as an attachment. Produced files # carry a plugin-declared MIME type, so SVG and the rest of the XML family must # never render in the viewer's origin. INLINE_MIME_TYPES = frozenset({"image/png", "image/jpeg", "image/gif", "image/webp"}) class InvalidFileRequestError(BaseHTTPException): error_code = "invalid_request" description = "The file request is malformed." code = 400 class FileRefPayload(BaseModel): id: str kind: FileKind class RemoteFileUploadPayload(BaseModel): url: HttpUrl = Field(description="Remote file URL to fetch and store") class FileResolvePayload(BaseModel): files: list[FileRefPayload] = Field(default_factory=list, max_length=MAX_FILE_GRANT_REFS) class FileContentQuery(BaseModel): token: str = Field(description="Signed content token scoped to this file") class RemoteFileUploadResponse(FileResponse): """Dify's upload shape plus the ``url`` key its remote-upload clients read. Dify has no service-api remote upload for this endpoint to stand in for, and the web and console one it does have answers under ``url``. Carrying both names lets a client of either move over untouched; they hold one URL. """ url: str class ProducedFileResponse(ResponseModel): id: str name: str size: int mime_type: str | None = None url: str internal_url: str class FileResolveResponse(ResponseModel): files: list[ResolvedFileResponse] register_schema_models(files_ns, RemoteFileUploadPayload, FileResolvePayload) register_response_schema_models( files_ns, FileResponse, RemoteFileUploadResponse, ProducedFileResponse, FileResolveResponse ) @files_ns.route("/appdeploy/upload") class GrantedFileUploadApi(Resource): """Store one uploaded file against the grant's end user.""" @file_grant_required(FileGrantScope.UPLOAD) @files_ns.doc("grant_upload_file") @files_ns.doc( responses={ 201: "File uploaded", 400: "No file uploaded, the file has no name, or its extension is blocked", 401: "Invalid grant", 403: "Grant lacks the upload scope", 413: "File too large", 415: "Unsupported file type", } ) @files_ns.response(201, "File uploaded", files_ns.models[FileResponse.__name__]) def post(self, grant: FileGrantClaims): upload = _single_upload() upload_file = _store_upload( grant, filename=upload.filename or "", stream=upload.stream, mimetype=upload.mimetype, ) return _granted_file_response(upload_file), 201 @files_ns.route("/appdeploy/remote-upload") class GrantedRemoteFileUploadApi(Resource): """Fetch a remote URL through the SSRF-safe fetcher and store it.""" @file_grant_required(FileGrantScope.UPLOAD) @files_ns.doc("grant_upload_remote_file") @files_ns.expect(files_ns.models[RemoteFileUploadPayload.__name__]) @files_ns.doc( responses={ 201: "Remote file uploaded", 400: "Invalid URL, unfetchable remote file, or a blocked extension", 401: "Invalid grant", 403: "Grant lacks the upload scope", 413: "File too large", 415: "Unsupported file type", } ) @files_ns.response(201, "Remote file uploaded", files_ns.models[RemoteFileUploadResponse.__name__]) def post(self, grant: FileGrantClaims): try: payload = RemoteFileUploadPayload.model_validate(files_ns.payload or {}) except ValidationError as exc: raise InvalidFileRequestError(str(exc)) from exc try: upload_file = application_services().file_grants.store_remote_upload( context=_grant_context(grant), url=str(payload.url), ) except RemoteFileUnavailableError as exc: raise RemoteFileUploadError(f"Failed to fetch file from {payload.url}") from exc except EndUserNotFoundError as exc: raise GrantedFileNotFoundError() from exc except services.errors.file.FileTooLargeError as exc: raise FileTooLargeError(exc.description) from exc except services.errors.file.BlockedFileExtensionError as exc: raise BlockedFileExtensionError(exc.description) from exc except services.errors.file.UnsupportedFileTypeError: raise UnsupportedFileTypeError() return _remote_granted_file_response(upload_file), 201 @files_ns.route("/appdeploy/produced") class ProducedFileApi(Resource): """Store one file produced by a running workflow node.""" @file_grant_required(FileGrantScope.PRODUCE) @files_ns.doc("grant_upload_produced_file") @files_ns.doc( responses={ 201: "Produced file stored", 400: "No file uploaded", 401: "Invalid grant", 403: "Grant lacks the produce scope", 413: "File too large", } ) @files_ns.response(201, "Produced file stored", files_ns.models[ProducedFileResponse.__name__]) def post(self, grant: FileGrantClaims): upload = _single_upload() try: tool_file, access = application_services().file_grants.store_produced( context=_grant_context(grant), filename=upload.filename, stream=upload.stream, mimetype=upload.mimetype, ) except services.errors.file.FileTooLargeError as exc: raise FileTooLargeError(exc.description) except EndUserNotFoundError as exc: raise GrantedFileNotFoundError() from exc return ProducedFileResponse( id=tool_file.id, name=tool_file.name, size=tool_file.size, mime_type=tool_file.mime_type, url=access.external_url, internal_url=access.internal_url, ).model_dump(mode="json"), 201 @files_ns.route("/appdeploy/resolve") class GrantedFileResolveApi(Resource): """Re-check ownership and sign fresh URLs at the moment of use.""" @file_grant_required(FileGrantScope.RESOLVE) @files_ns.doc("grant_resolve_files") @files_ns.expect(files_ns.models[FileResolvePayload.__name__]) @files_ns.doc( responses={ 200: "Files resolved", 400: "Malformed request", 401: "Invalid grant", 403: "Grant lacks the resolve scope", } ) @files_ns.response(200, "Files resolved", files_ns.models[FileResolveResponse.__name__]) def post(self, grant: FileGrantClaims): try: payload = FileResolvePayload.model_validate(files_ns.payload or {}) except ValidationError as exc: raise InvalidFileRequestError(str(exc)) from exc refs = [FileRef(id=ref.id, kind=ref.kind) for ref in payload.files] try: resolved = application_services().file_grants.resolve_file_access( context=_grant_context(grant), refs=refs, ) except EndUserNotFoundError as exc: raise GrantedFileNotFoundError() from exc except TooManyFileRefsError as exc: raise InvalidFileRequestError(str(exc)) from exc return FileResolveResponse( files=[ResolvedFileResponse.from_resolved(ref.id, file) for ref, file in zip(refs, resolved, strict=True)] ).model_dump(mode="json") @files_ns.route("/appdeploy//content") class GrantedFileContentApi(Resource): """Stream one file's bytes to a holder of its content token.""" @files_ns.doc("grant_file_content") @files_ns.doc(params=query_params_from_model(FileContentQuery)) @files_ns.doc( responses={ 200: "File stream returned", 401: "Invalid or expired content token", 404: "File not found", } ) def get(self, file_id: UUID): try: query = FileContentQuery.model_validate(request.args.to_dict(flat=True)) except ValidationError as exc: raise FileGrantInvalidError() from exc try: content = application_services().file_grants.load_content( token=query.token, requested_file_id=str(file_id), ) except InvalidFileGrantError as exc: raise FileGrantInvalidError() from exc if content is None: raise GrantedFileNotFoundError() return _content_response(content) def _content_response(content: FileContent) -> Response: mime_type = _normalized_mime_type(content.mime_type) inline = mime_type in INLINE_MIME_TYPES response = Response( content.stream, mimetype=mime_type if inline else "application/octet-stream", direct_passthrough=True, headers={}, ) response.headers["X-Content-Type-Options"] = "nosniff" if not inline: encoded_filename = quote(content.name or "") response.headers["Content-Disposition"] = f"attachment; filename*=UTF-8''{encoded_filename}" response.headers["Content-Type"] = "application/octet-stream" # The sibling preview endpoints advertise `Accept-Ranges` for audio and video. # Every such type downloads here, so the hint could only ever ride on a # response no player will seek. if content.size > 0: response.headers["Content-Length"] = str(content.size) return response def _normalized_mime_type(mime_type: str | None) -> str: return mime_type.split(";", 1)[0].strip().lower() if mime_type else "" def _single_upload() -> FileStorage: if "file" not in request.files: raise NoFileUploadedError() if len(request.files) > 1: raise TooManyFilesError() upload = request.files["file"] if not upload.filename: raise FilenameNotExistsError() return upload def _store_upload( grant: FileGrantClaims, *, filename: str, stream: IO[bytes], mimetype: str, ) -> StoredUpload: try: return application_services().file_grants.store_upload( context=_grant_context(grant), filename=filename, stream=stream, mimetype=mimetype, ) except EndUserNotFoundError as exc: raise GrantedFileNotFoundError() from exc except services.errors.file.FileTooLargeError as exc: raise FileTooLargeError(exc.description) except services.errors.file.BlockedFileExtensionError as exc: raise BlockedFileExtensionError(exc.description) from exc except services.errors.file.UnsupportedFileTypeError: raise UnsupportedFileTypeError() def _grant_context(grant: FileGrantClaims) -> FileGrantContext: return FileGrantContext(tenant_id=grant.tenant_id, app_id=grant.app_id, end_user_id=grant.sub) def _granted_file_response(upload_file: StoredUpload) -> dict[str, object]: """Answer an upload exactly as dify's own upload endpoints answer it. A client moving off ``POST /v1/files/upload`` must not have to read a second shape, so the same model reads the same ``upload_files`` row: every key dify leaves null for such a row is null here too. Only the value of ``source_url`` is ours. Dify signs a ``file-preview`` URL there and this channel signs a content-token URL, which keeps that key's promise of a signed URL that retrieves the file while keeping the grant its only way in. """ signed_url, _ = application_services().file_grants.content_urls(file_id=upload_file.id, kind=FileKind.UPLOAD) return dump_response(FileResponse, upload_file) | {"source_url": signed_url} def _remote_granted_file_response(upload_file: StoredUpload) -> dict[str, object]: """Answer a remote upload with the upload shape plus dify's ``url`` key. Reuses the URL already signed for ``source_url`` rather than signing a second one, so the two keys are one value under the two names dify's two kinds of client look for. """ response = _granted_file_response(upload_file) return response | {"url": response["source_url"]} __all__ = [ "GrantedFileContentApi", "GrantedFileResolveApi", "GrantedFileUploadApi", "GrantedRemoteFileUploadApi", "ProducedFileApi", ]