diff --git a/api/app/controllers/file_storage_controller.py b/api/app/controllers/file_storage_controller.py index 14962a72..4e1ba74c 100644 --- a/api/app/controllers/file_storage_controller.py +++ b/api/app/controllers/file_storage_controller.py @@ -574,8 +574,12 @@ async def get_file_url( # For local storage, generate signed URL with expiration url = generate_signed_url(str(file_id), expires) else: - # For remote storage (OSS/S3), get presigned URL - url = await storage_service.get_file_url(file_key, expires=expires) + # For remote storage (OSS/S3), get presigned URL with forced download + url = await storage_service.get_file_url( + file_key, + expires=expires, + file_name=file_metadata.file_name, + ) url = _match_scheme(request, url) api_logger.info(f"Generated file URL: file_id={file_id}") @@ -786,7 +790,7 @@ async def permanent_download_file( # For remote storage, redirect to presigned URL with long expiration try: # Use a very long expiration (7 days max for most cloud providers) - presigned_url = await storage_service.get_file_url(file_key, expires=604800) + presigned_url = await storage_service.get_file_url(file_key, expires=604800, file_name=file_metadata.file_name) presigned_url = _match_scheme(request, presigned_url) return RedirectResponse(url=presigned_url, status_code=status.HTTP_302_FOUND) except Exception as e: diff --git a/api/app/controllers/public_share_controller.py b/api/app/controllers/public_share_controller.py index 33d7b60c..f5284b46 100644 --- a/api/app/controllers/public_share_controller.py +++ b/api/app/controllers/public_share_controller.py @@ -669,6 +669,7 @@ async def config_query( content = { "app_type": release.app.type, "variables": release.config.get("variables"), + "memory": release.config.get("memory", {}).get("enabled"), "features": release.config.get("features") } elif release.app.type == AppType.MULTI_AGENT: diff --git a/api/app/core/storage/base.py b/api/app/core/storage/base.py index 8ab0fcde..09824c3f 100644 --- a/api/app/core/storage/base.py +++ b/api/app/core/storage/base.py @@ -109,17 +109,13 @@ class StorageBackend(ABC): pass @abstractmethod - async def get_url(self, file_key: str, expires: int = 3600) -> str: - """ - Get an access URL for the file. - - Args: - file_key: Unique identifier for the file in the storage system. - expires: URL validity period in seconds (default: 1 hour). - - Returns: - URL for accessing the file. - """ + async def get_url( + self, + file_key: str, + expires: int = 3600, + file_name: Optional[str] = None + ) -> str: + """Get an access URL for the file.""" pass async def get_permanent_url(self, file_key: str) -> Optional[str]: diff --git a/api/app/core/storage/local.py b/api/app/core/storage/local.py index 4b8ae829..13adfc20 100644 --- a/api/app/core/storage/local.py +++ b/api/app/core/storage/local.py @@ -210,7 +210,12 @@ class LocalStorage(StorageBackend): cause=e, ) - async def get_url(self, file_key: str, expires: int = 3600) -> str: + async def get_url( + self, + file_key: str, + expires: int = 3600, + file_name: Optional[str] = None + ) -> str: """ Get an access URL for the file. @@ -220,6 +225,7 @@ class LocalStorage(StorageBackend): Args: file_key: Unique identifier for the file in the storage system. expires: URL validity period in seconds (not used for local storage). + file_name: If set, adds Content-Disposition: attachment to force download. Returns: A relative URL path for accessing the file. diff --git a/api/app/core/storage/oss.py b/api/app/core/storage/oss.py index 27669ffa..1db86fef 100644 --- a/api/app/core/storage/oss.py +++ b/api/app/core/storage/oss.py @@ -7,6 +7,7 @@ Storage Service (OSS) using the oss2 SDK. import io import logging +import urllib.parse from typing import AsyncIterator, Optional import oss2 @@ -242,24 +243,33 @@ class OSSStorage(StorageBackend): logger.error(f"Failed to check file existence in OSS {file_key}: {e}") return False - async def get_url(self, file_key: str, expires: int = 3600) -> str: + async def get_url( + self, + file_key: str, + expires: int = 3600, + file_name: Optional[str] = None, + ) -> str: """ Get a presigned URL for accessing the file. Args: file_key: Unique identifier for the file in the storage system. expires: URL validity period in seconds (default: 1 hour). + file_name: If set, adds Content-Disposition: attachment to force download. Returns: A presigned URL for accessing the file. """ try: - url = self.bucket.sign_url("GET", file_key, expires) + params = {} + if file_name: + filename_encoded = urllib.parse.quote(file_name.encode("utf-8")) + params["response-content-disposition"] = f"attachment; filename*=UTF-8''{filename_encoded}" + url = self.bucket.sign_url("GET", file_key, expires, params=params if params else None) logger.debug(f"Generated presigned URL for {file_key}, expires in {expires}s") return url except Exception as e: logger.error(f"Failed to generate presigned URL for {file_key}: {e}") - # Return a basic URL format as fallback return f"https://{self.bucket_name}.{self.endpoint.replace('https://', '').replace('http://', '')}/{file_key}" async def get_permanent_url(self, file_key: str) -> str: diff --git a/api/app/core/storage/s3.py b/api/app/core/storage/s3.py index c7b33ffe..f156f4a7 100644 --- a/api/app/core/storage/s3.py +++ b/api/app/core/storage/s3.py @@ -6,6 +6,7 @@ using the boto3 SDK. """ import io +import urllib.parse import logging from typing import AsyncIterator, Optional @@ -352,31 +353,37 @@ class S3Storage(StorageBackend): logger.error(f"Failed to check file existence in S3 {file_key}: {e}") return False - async def get_url(self, file_key: str, expires: int = 3600) -> str: + async def get_url( + self, + file_key: str, + expires: int = 3600, + file_name: Optional[str] = None, + ) -> str: """ Get a presigned URL for accessing the file. Args: file_key: Unique identifier for the file in the storage system. expires: URL validity period in seconds (default: 1 hour). + file_name: If set, adds Content-Disposition: attachment to force download. Returns: A presigned URL for accessing the file. """ try: + params = {"Bucket": self.bucket_name, "Key": file_key} + if file_name: + filename_encoded = urllib.parse.quote(file_name.encode("utf-8")) + params["ResponseContentDisposition"] = f"attachment; filename*=UTF-8''{filename_encoded}" url = self.client.generate_presigned_url( "get_object", - Params={ - "Bucket": self.bucket_name, - "Key": file_key, - }, + Params=params, ExpiresIn=expires, ) logger.debug(f"Generated presigned URL for {file_key}, expires in {expires}s") return url except Exception as e: logger.error(f"Failed to generate presigned URL for {file_key}: {e}") - # Return a basic URL format as fallback return f"https://{self.bucket_name}.s3.{self.region}.amazonaws.com/{file_key}" async def get_permanent_url(self, file_key: str) -> str: diff --git a/api/app/services/file_storage_service.py b/api/app/services/file_storage_service.py index 2ebc5d9a..5897936b 100644 --- a/api/app/services/file_storage_service.py +++ b/api/app/services/file_storage_service.py @@ -325,27 +325,30 @@ class FileStorageService: ) raise - async def get_file_url(self, file_key: str, expires: int = 3600) -> str: + async def get_file_url( + self, + file_key: str, + expires: int = 3600, + file_name: Optional[str] = None, + ) -> str: """ Get an access URL for a file. Args: file_key: The file key. expires: URL validity period in seconds (default: 1 hour). + file_name: If set, adds Content-Disposition: attachment to force download. Returns: URL for accessing the file. """ logger.debug(f"Getting file URL: file_key={file_key}, expires={expires}s") - try: - url = await self.storage.get_url(file_key, expires) + url = await self.storage.get_url(file_key, expires, file_name=file_name) logger.debug(f"File URL generated: file_key={file_key}") return url except Exception as e: - logger.error( - f"Error getting file URL: file_key={file_key}, error={str(e)}" - ) + logger.error(f"Error getting file URL: file_key={file_key}, error={str(e)}") raise