This is an automated email from the ASF dual-hosted git repository.
aicam pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/texera.git
The following commit(s) were added to refs/heads/master by this push:
new eeaaa96d76 chore: revert "feat: switch single-file dataset downloads
to use browser native downloads (#3621)" (#3669)
eeaaa96d76 is described below
commit eeaaa96d76b40171b03b486a1f320dac60b51870
Author: Madison Lin <[email protected]>
AuthorDate: Sun Aug 17 19:44:35 2025 -0700
chore: revert "feat: switch single-file dataset downloads to use browser
native downloads (#3621)" (#3669)
This PR reverts commit f2214a1828370745fa4434a95f4b72cd96cd6c21 due to
issues with the single-file dataset download for Texera when hosted on
Kubernetes.
---
.../texera/service/resource/DatasetResource.scala | 78 ++--------------------
.../ics/texera/service/util/S3StorageClient.scala | 67 +------------------
.../dataset-detail.component.ts | 3 +-
.../service/user/dataset/dataset.service.ts | 23 -------
.../service/user/download/download.service.spec.ts | 42 ++++++++++++
.../service/user/download/download.service.ts | 13 +++-
6 files changed, 60 insertions(+), 166 deletions(-)
diff --git
a/core/file-service/src/main/scala/edu/uci/ics/texera/service/resource/DatasetResource.scala
b/core/file-service/src/main/scala/edu/uci/ics/texera/service/resource/DatasetResource.scala
index d279cfe12c..3570f05020 100644
---
a/core/file-service/src/main/scala/edu/uci/ics/texera/service/resource/DatasetResource.scala
+++
b/core/file-service/src/main/scala/edu/uci/ics/texera/service/resource/DatasetResource.scala
@@ -39,7 +39,8 @@ import edu.uci.ics.texera.dao.jooq.generated.tables.daos.{
import edu.uci.ics.texera.dao.jooq.generated.tables.pojos.{
Dataset,
DatasetUserAccess,
- DatasetVersion
+ DatasetVersion,
+ User
}
import edu.uci.ics.texera.service.`type`.DatasetFileNode
import edu.uci.ics.texera.service.resource.DatasetAccessResource.{
@@ -195,7 +196,6 @@ object DatasetResource {
class DatasetResource {
private val ERR_USER_HAS_NO_ACCESS_TO_DATASET_MESSAGE = "User has no access
to this dataset"
private val ERR_DATASET_VERSION_NOT_FOUND_MESSAGE = "The version of the
dataset not found"
- private val EXPIRATION_MINUTES = 5
/**
* Helper function to get the dataset from DB with additional information
including user access privilege and owner email
@@ -573,19 +573,6 @@ class DatasetResource {
generatePresignedResponse(encodedUrl, datasetName, commitHash, uid)
}
- @GET
- @RolesAllowed(Array("REGULAR", "ADMIN"))
- @Path("/presign-download-s3")
- def getPresignedUrlWithS3(
- @QueryParam("filePath") encodedUrl: String,
- @QueryParam("datasetName") datasetName: String,
- @QueryParam("commitHash") commitHash: String,
- @Auth user: SessionUser
- ): Response = {
- val uid = user.getUid
- generatePresignedResponseWithS3(encodedUrl, datasetName, commitHash, uid)
- }
-
@GET
@Path("/public-presign-download")
def getPublicPresignedUrl(
@@ -593,17 +580,9 @@ class DatasetResource {
@QueryParam("datasetName") datasetName: String,
@QueryParam("commitHash") commitHash: String
): Response = {
- generatePresignedResponse(encodedUrl, datasetName, commitHash, null)
- }
-
- @GET
- @Path("/public-presign-download-s3")
- def getPublicPresignedUrlWithS3(
- @QueryParam("filePath") encodedUrl: String,
- @QueryParam("datasetName") datasetName: String,
- @QueryParam("commitHash") commitHash: String
- ): Response = {
- generatePresignedResponseWithS3(encodedUrl, datasetName, commitHash, null)
+ val user = new SessionUser(new User())
+ val uid = user.getUid
+ generatePresignedResponse(encodedUrl, datasetName, commitHash, uid)
}
@DELETE
@@ -1293,53 +1272,6 @@ class DatasetResource {
}
}
- private def generatePresignedResponseWithS3(
- encodedUrl: String,
- datasetName: String,
- commitHash: String,
- uid: Integer
- ): Response = {
- resolveDatasetAndPath(encodedUrl, datasetName, commitHash, uid) match {
- case Left(errorResponse) =>
- errorResponse
-
- case Right((resolvedDatasetName, resolvedCommitHash, resolvedFilePath))
=>
- // Additional download permission check for S3 downloads
- if (uid != null) {
- withTransaction(context) { ctx =>
- val datasetDao = new DatasetDao(ctx.configuration())
- val datasets =
datasetDao.fetchByName(resolvedDatasetName).asScala.toList
- if (datasets.nonEmpty) {
- val dataset = datasets.head
- // Non-owners can only download public and downloadable datasets
- if (
- !userOwnDataset(
- ctx,
- dataset.getDid,
- uid
- ) && (!dataset.getIsPublic || !dataset.getIsDownloadable)
- ) {
- throw new ForbiddenException("Dataset download is not allowed")
- }
- }
- }
- }
-
- val fileName =
resolvedFilePath.split("/").lastOption.getOrElse("download")
- val contentType = "application/octet-stream"
- val url = S3StorageClient.getFilePresignedUrl(
- resolvedDatasetName,
- resolvedCommitHash,
- resolvedFilePath,
- fileName,
- contentType,
- EXPIRATION_MINUTES
- )
-
- Response.ok(Map("presignedUrl" -> url)).build()
- }
- }
-
private def resolveDatasetAndPath(
encodedUrl: String,
datasetName: String,
diff --git
a/core/file-service/src/main/scala/edu/uci/ics/texera/service/util/S3StorageClient.scala
b/core/file-service/src/main/scala/edu/uci/ics/texera/service/util/S3StorageClient.scala
index ae110fec52..2b1afd1165 100644
---
a/core/file-service/src/main/scala/edu/uci/ics/texera/service/util/S3StorageClient.scala
+++
b/core/file-service/src/main/scala/edu/uci/ics/texera/service/util/S3StorageClient.scala
@@ -24,12 +24,7 @@ import
software.amazon.awssdk.auth.credentials.{AwsBasicCredentials, StaticCrede
import software.amazon.awssdk.regions.Region
import software.amazon.awssdk.services.s3.{S3Client, S3Configuration}
import software.amazon.awssdk.services.s3.model._
-import software.amazon.awssdk.services.s3.presigner.S3Presigner
-import
software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest
-import software.amazon.awssdk.services.s3.model.GetObjectRequest
-import java.net.URI
-import java.time.Duration
import java.security.MessageDigest
import scala.jdk.CollectionConverters._
@@ -41,10 +36,10 @@ import scala.jdk.CollectionConverters._
object S3StorageClient {
val MINIMUM_NUM_OF_MULTIPART_S3_PART: Long = 5L * 1024 * 1024 // 5 MiB
val MAXIMUM_NUM_OF_MULTIPART_S3_PARTS = 10_000
- val credentials = AwsBasicCredentials.create(StorageConfig.s3Username,
StorageConfig.s3Password)
// Initialize MinIO-compatible S3 Client
private lazy val s3Client: S3Client = {
+ val credentials = AwsBasicCredentials.create(StorageConfig.s3Username,
StorageConfig.s3Password)
S3Client
.builder()
.credentialsProvider(StaticCredentialsProvider.create(credentials))
@@ -56,29 +51,6 @@ object S3StorageClient {
.build()
}
- // Initialize S3-compatible presigner for LakeFS S3 Gateway
- private lazy val s3Presigner: S3Presigner = {
- val fullUri = new URI(StorageConfig.lakefsEndpoint)
- val baseUri = new URI(
- fullUri.getScheme,
- null,
- fullUri.getHost,
- fullUri.getPort,
- null,
- null,
- null
- ) // Extract just the base (scheme + host + port)
- S3Presigner
- .builder()
- .credentialsProvider(StaticCredentialsProvider.create(credentials))
- .region(Region.of(StorageConfig.s3Region))
- .endpointOverride(baseUri) // LakeFS base URL ("http://localhost:8000"
on local)
- .serviceConfiguration(
- S3Configuration.builder().pathStyleAccessEnabled(true).build()
- )
- .build()
- }
-
/**
* Checks if a directory (prefix) exists within an S3 bucket.
*
@@ -167,41 +139,4 @@ object S3StorageClient {
s3Client.deleteObjects(deleteObjectsRequest)
}
}
-
- /**
- * Retrieves file content from a specific commit and path.
- *
- * @param repoName Repository name.
- * @param commitHash Commit hash of the version.
- * @param filePath Path to the file in the repository.
- * @param fileName Name of the file downloaded via the presigned
URL.
- * @param contentType Type of the file downloaded via the presigned
URL.
- * @param expirationMinutes Duration in minutes that the presigned URL is
valid.
- */
- def getFilePresignedUrl(
- repoName: String,
- commitHash: String,
- filePath: String,
- fileName: String,
- contentType: String,
- expirationMinutes: Long
- ): String = {
- val getObjectRequest = GetObjectRequest
- .builder()
- .bucket(repoName)
- .key(s"$commitHash/$filePath")
- .responseContentDisposition(s"attachment; filename='$fileName'")
- .responseContentType(contentType)
- .build()
-
- val presignRequest = GetObjectPresignRequest
- .builder()
- .signatureDuration(Duration.ofMinutes(expirationMinutes))
- .getObjectRequest(getObjectRequest)
- .build()
-
- val presignedUrl =
s3Presigner.presignGetObject(presignRequest).url().toString
- s3Presigner.close()
- presignedUrl
- }
}
diff --git
a/core/gui/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.ts
b/core/gui/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.ts
index f563142e0c..f9d6d475b3 100644
---
a/core/gui/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.ts
+++
b/core/gui/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.ts
@@ -307,7 +307,8 @@ export class DatasetDetailComponent implements OnInit {
onClickDownloadCurrentFile = (): void => {
if (!this.did || !this.selectedVersion?.dvid) return;
- this.downloadService.downloadSingleFile(this.currentDisplayedFileName);
+
+
this.downloadService.downloadSingleFile(this.currentDisplayedFileName).pipe(untilDestroyed(this)).subscribe();
};
onClickScaleTheView() {
diff --git a/core/gui/src/app/dashboard/service/user/dataset/dataset.service.ts
b/core/gui/src/app/dashboard/service/user/dataset/dataset.service.ts
index e4c9fbbf20..329a6e5947 100644
--- a/core/gui/src/app/dashboard/service/user/dataset/dataset.service.ts
+++ b/core/gui/src/app/dashboard/service/user/dataset/dataset.service.ts
@@ -95,29 +95,6 @@ export class DatasetService {
.pipe(switchMap(({ presignedUrl }) => this.http.get(presignedUrl, {
responseType: "blob" })));
}
- /**
- * Retrieves a single file from a dataset version using a pre-signed URL.
- * @param filePath Relative file path within the dataset.
- * @param isLogin Determine whether a user is currently logged in
- * @returns void File is downloaded natively by the browser.
- */
- public retrieveDatasetVersionSingleFileViaBrowser(filePath: string, isLogin:
boolean = true): void {
- const endpointSegment = isLogin ? "presign-download-s3" :
"public-presign-download-s3";
- const endpoint =
`${AppSettings.getApiEndpoint()}/${DATASET_BASE_URL}/${endpointSegment}?filePath=${encodeURIComponent(filePath)}`;
-
- this.http.get<{ presignedUrl: string }>(endpoint).subscribe({
- next: response => {
- const presignedUrl = response.presignedUrl;
- const downloadUrl = document.createElement("a");
-
- downloadUrl.href = presignedUrl;
- document.body.appendChild(downloadUrl);
- downloadUrl.click();
- downloadUrl.remove();
- },
- });
- }
-
/**
* Retrieves a zip file of a dataset version.
* @param did Dataset ID
diff --git
a/core/gui/src/app/dashboard/service/user/download/download.service.spec.ts
b/core/gui/src/app/dashboard/service/user/download/download.service.spec.ts
index d1c55a434f..f6d5a4b4b1 100644
--- a/core/gui/src/app/dashboard/service/user/download/download.service.spec.ts
+++ b/core/gui/src/app/dashboard/service/user/download/download.service.spec.ts
@@ -60,6 +60,48 @@ describe("DownloadService", () => {
notificationServiceSpy = TestBed.inject(NotificationService) as
jasmine.SpyObj<NotificationService>;
});
+ it("should download a single file successfully", (done: DoneFn) => {
+ const filePath = "test/file.txt";
+ const mockBlob = new Blob(["test content"], { type: "text/plain" });
+
+
datasetServiceSpy.retrieveDatasetVersionSingleFile.and.returnValue(of(mockBlob));
+
+ downloadService.downloadSingleFile(filePath).subscribe({
+ next: blob => {
+ expect(blob).toBe(mockBlob);
+ expect(notificationServiceSpy.info).toHaveBeenCalledWith("Starting to
download file test/file.txt");
+
expect(datasetServiceSpy.retrieveDatasetVersionSingleFile).toHaveBeenCalledWith(filePath);
+ expect(fileSaverServiceSpy.saveAs).toHaveBeenCalledWith(mockBlob,
"file.txt");
+ expect(notificationServiceSpy.success).toHaveBeenCalledWith("File
test/file.txt has been downloaded");
+ done();
+ },
+ error: (error: unknown) => {
+ fail("Should not have thrown an error: " + error);
+ },
+ });
+ });
+
+ it("should handle download failure correctly", done => {
+ const filePath = "test/file.txt";
+ const errorMessage = "Download failed";
+
+
datasetServiceSpy.retrieveDatasetVersionSingleFile.and.returnValue(throwError(()
=> new Error(errorMessage)));
+
+ downloadService.downloadSingleFile(filePath).subscribe({
+ next: () => {
+ fail("Should have thrown an error");
+ },
+ error: (error: unknown) => {
+ expect(error).toBeTruthy();
+ expect(notificationServiceSpy.info).toHaveBeenCalledWith("Starting to
download file test/file.txt");
+
expect(datasetServiceSpy.retrieveDatasetVersionSingleFile).toHaveBeenCalledWith(filePath);
+ expect(fileSaverServiceSpy.saveAs).not.toHaveBeenCalled();
+ expect(notificationServiceSpy.error).toHaveBeenCalledWith("Error
downloading file 'test/file.txt'");
+ done();
+ },
+ });
+ });
+
it("should download a dataset successfully", done => {
const datasetId = 1;
const datasetName = "TestDataset";
diff --git
a/core/gui/src/app/dashboard/service/user/download/download.service.ts
b/core/gui/src/app/dashboard/service/user/download/download.service.ts
index 39b89f0f01..813dea08e2 100644
--- a/core/gui/src/app/dashboard/service/user/download/download.service.ts
+++ b/core/gui/src/app/dashboard/service/user/download/download.service.ts
@@ -93,9 +93,16 @@ export class DownloadService {
);
}
- downloadSingleFile(filePath: string): void {
- this.notificationService.info(`Starting to download file ${filePath}`);
- this.datasetService.retrieveDatasetVersionSingleFileViaBrowser(filePath);
+ downloadSingleFile(filePath: string): Observable<Blob> {
+ const DEFAULT_FILE_NAME = "download";
+ const fileName = filePath.split("/").pop() || DEFAULT_FILE_NAME;
+ return this.downloadWithNotification(
+ () => this.datasetService.retrieveDatasetVersionSingleFile(filePath),
+ fileName,
+ `Starting to download file ${filePath}`,
+ `File ${filePath} has been downloaded`,
+ `Error downloading file '${filePath}'`
+ );
}
downloadWorkflowsAsZip(workflowEntries: Array<{ id: number; name: string
}>): Observable<Blob> {