From 39fb4e301f7530548ab3c952d2885e7c788c9d38 Mon Sep 17 00:00:00 2001 From: Xuan Gu <162244362+xuang7@users.noreply.github.com> Date: Sat, 6 Dec 2025 14:31:44 -0800 Subject: [PATCH 1/8] save... --- .../service/resource/DatasetResource.scala | 24 +++++++++++++ frontend/src/app/common/type/dataset.ts | 1 + .../user-dataset-file-renderer.component.ts | 3 ++ .../user-dataset-version-creator.component.ts | 1 + ...er-dataset-version-filetree.component.html | 15 +++++++- ...er-dataset-version-filetree.component.scss | 2 +- ...user-dataset-version-filetree.component.ts | 19 ++++++++++- .../service/user/dataset/dataset.service.ts | 10 ++++++ .../src/app/dashboard/type/dashboard-entry.ts | 2 ++ .../browse-section.component.html | 2 +- .../browse-section.component.ts | 4 +++ sql/updates/16.sql | 34 +++++++++++++++++++ 12 files changed, 113 insertions(+), 4 deletions(-) create mode 100644 sql/updates/16.sql diff --git a/file-service/src/main/scala/org/apache/texera/service/resource/DatasetResource.scala b/file-service/src/main/scala/org/apache/texera/service/resource/DatasetResource.scala index 2a67440cf0e..b49fa8cc476 100644 --- a/file-service/src/main/scala/org/apache/texera/service/resource/DatasetResource.scala +++ b/file-service/src/main/scala/org/apache/texera/service/resource/DatasetResource.scala @@ -1372,4 +1372,28 @@ class DatasetResource { Right(response) } } + + @POST + @RolesAllowed(Array("REGULAR", "ADMIN")) + @Path("/{did}/update/cover") + @Consumes(Array(MediaType.TEXT_PLAIN)) + def updateDatasetCoverImage( + @PathParam("did") did: Integer, + coverImage: String, + @Auth sessionUser: SessionUser + ): Response = { + withTransaction(context) { ctx => + val uid = sessionUser.getUid + val datasetDao = new DatasetDao(ctx.configuration()) + val dataset = getDatasetByID(ctx, did) + + if (!userHasWriteAccess(ctx, did, uid)) { + throw new ForbiddenException(ERR_USER_HAS_NO_ACCESS_TO_DATASET_MESSAGE) + } + + dataset.setCoverImage(coverImage) + datasetDao.update(dataset) + Response.ok().build() + } + } } diff --git a/frontend/src/app/common/type/dataset.ts b/frontend/src/app/common/type/dataset.ts index 7825ca27976..97ff370302c 100644 --- a/frontend/src/app/common/type/dataset.ts +++ b/frontend/src/app/common/type/dataset.ts @@ -38,4 +38,5 @@ export interface Dataset { storagePath: string | undefined; description: string; creationTime: number | undefined; + coverImage: string | undefined; } diff --git a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-file-renderer/user-dataset-file-renderer.component.ts b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-file-renderer/user-dataset-file-renderer.component.ts index c851a8284ea..576de576dff 100644 --- a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-file-renderer/user-dataset-file-renderer.component.ts +++ b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-file-renderer/user-dataset-file-renderer.component.ts @@ -131,6 +131,9 @@ export class UserDatasetFileRendererComponent implements OnInit, OnChanges, OnDe @Output() loadFile = new EventEmitter<{ file: string; prefix: string }>(); + @Output() + setCoverImage = new EventEmitter(); + constructor( private datasetService: DatasetService, private sanitizer: DomSanitizer, diff --git a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-version-creator/user-dataset-version-creator.component.ts b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-version-creator/user-dataset-version-creator.component.ts index c1f9cffc352..1d59e851e34 100644 --- a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-version-creator/user-dataset-version-creator.component.ts +++ b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-version-creator/user-dataset-version-creator.component.ts @@ -174,6 +174,7 @@ export class UserDatasetVersionCreatorComponent implements OnInit { ownerUid: undefined, storagePath: undefined, creationTime: undefined, + coverImage: undefined, }; this.datasetService .createDataset(ds) diff --git a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-version-filetree/user-dataset-version-filetree.component.html b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-version-filetree/user-dataset-version-filetree.component.html index f09cdb37d4f..ee74c294410 100644 --- a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-version-filetree/user-dataset-version-filetree.component.html +++ b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-version-filetree/user-dataset-version-filetree.component.html @@ -41,13 +41,26 @@ nz-button nzType="link" *ngIf="isTreeNodeDeletable && !node.data.children" - class="delete-button" + class="icon-button" (click)="onNodeDeleted(node.data)"> + + diff --git a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-version-filetree/user-dataset-version-filetree.component.scss b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-version-filetree/user-dataset-version-filetree.component.scss index edfaab61285..54cbcd44af4 100644 --- a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-version-filetree/user-dataset-version-filetree.component.scss +++ b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-version-filetree/user-dataset-version-filetree.component.scss @@ -22,7 +22,7 @@ } /* Styles for the delete button */ -.delete-button { +.icon-button { width: 15px; margin-left: 5px; } diff --git a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-version-filetree/user-dataset-version-filetree.component.ts b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-version-filetree/user-dataset-version-filetree.component.ts index f3e3e67e1af..cc0820ea544 100644 --- a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-version-filetree/user-dataset-version-filetree.component.ts +++ b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-version-filetree/user-dataset-version-filetree.component.ts @@ -19,7 +19,10 @@ import { UntilDestroy } from "@ngneat/until-destroy"; import { AfterViewInit, Component, EventEmitter, Input, Output, ViewChild } from "@angular/core"; -import { DatasetFileNode } from "../../../../../../common/type/datasetVersionFileTree"; +import { + DatasetFileNode, + getRelativePathFromDatasetFileNode, +} from "../../../../../../common/type/datasetVersionFileTree"; import { ITreeOptions, TREE_ACTIONS } from "@ali-hm/angular-tree-component"; @UntilDestroy() @@ -40,6 +43,9 @@ export class UserDatasetVersionFiletreeComponent implements AfterViewInit { @ViewChild("tree") tree: any; + @Output() + setCoverImage = new EventEmitter(); + public fileTreeDisplayOptions: ITreeOptions = { displayField: "name", hasChildrenField: "children", @@ -74,4 +80,15 @@ export class UserDatasetVersionFiletreeComponent implements AfterViewInit { this.tree.treeModel.expandAll(); } } + + isImageFile(fileName: string): boolean { + const imageExts = [".jpg", ".jpeg", ".png", ".gif", ".webp"]; + return imageExts.some(ext => fileName.toLowerCase().endsWith(ext)); + } + + onSetCover(nodeData: DatasetFileNode): void { + const path = getRelativePathFromDatasetFileNode(nodeData); + console.log('Setting cover to:', path); + this.setCoverImage.emit(path); + } } diff --git a/frontend/src/app/dashboard/service/user/dataset/dataset.service.ts b/frontend/src/app/dashboard/service/user/dataset/dataset.service.ts index c09125d73b1..d5e458337ac 100644 --- a/frontend/src/app/dashboard/service/user/dataset/dataset.service.ts +++ b/frontend/src/app/dashboard/service/user/dataset/dataset.service.ts @@ -539,4 +539,14 @@ export class DatasetService { public retrieveOwners(): Observable { return this.http.get(`${AppSettings.getApiEndpoint()}/${DATASET_GET_OWNERS_URL}`); } + + public updateDatasetCoverImage(did: number, coverImagePath: string): Observable { + return this.http.post( + `${AppSettings.getApiEndpoint()}/${DATASET_BASE_URL}/${did}/update/cover`, + coverImagePath, + { + headers: { "Content-Type": "text/plain" }, + } + ); + } } diff --git a/frontend/src/app/dashboard/type/dashboard-entry.ts b/frontend/src/app/dashboard/type/dashboard-entry.ts index e526ea01bae..6dfb46cc1cd 100644 --- a/frontend/src/app/dashboard/type/dashboard-entry.ts +++ b/frontend/src/app/dashboard/type/dashboard-entry.ts @@ -48,6 +48,7 @@ export class DashboardEntry { likeCount: number; isLiked: boolean; accessibleUserIds: number[]; + coverImageUrl?: string; constructor(public value: DashboardWorkflow | DashboardProject | DashboardFile | DashboardDataset) { if (isDashboardWorkflow(value)) { @@ -122,6 +123,7 @@ export class DashboardEntry { this.likeCount = 0; this.isLiked = false; this.accessibleUserIds = []; + this.coverImageUrl = value.dataset.coverImage; } else { throw new Error("Unexpected type in DashboardEntry."); } diff --git a/frontend/src/app/hub/component/browse-section/browse-section.component.html b/frontend/src/app/hub/component/browse-section/browse-section.component.html index 3d7080e0eb7..71e308b5f89 100644 --- a/frontend/src/app/hub/component/browse-section/browse-section.component.html +++ b/frontend/src/app/hub/component/browse-section/browse-section.component.html @@ -44,7 +44,7 @@

{{ entity.name }}

example + [src]="getCoverImage(entity)" /> Date: Sat, 6 Dec 2025 22:27:31 -0800 Subject: [PATCH 2/8] update. --- .../service/resource/DatasetResource.scala | 12 +++++------- .../dataset-detail.component.html | 3 ++- .../dataset-detail.component.ts | 18 ++++++++++++++++++ .../user-dataset-version-filetree.component.ts | 1 - .../service/user/dataset/dataset.service.ts | 8 +------- .../browse-section.component.html | 3 ++- .../browse-section/browse-section.component.ts | 6 +++++- sql/texera_ddl.sql | 1 + sql/updates/16.sql | 2 +- 9 files changed, 35 insertions(+), 19 deletions(-) diff --git a/file-service/src/main/scala/org/apache/texera/service/resource/DatasetResource.scala b/file-service/src/main/scala/org/apache/texera/service/resource/DatasetResource.scala index b49fa8cc476..05aa4178ed7 100644 --- a/file-service/src/main/scala/org/apache/texera/service/resource/DatasetResource.scala +++ b/file-service/src/main/scala/org/apache/texera/service/resource/DatasetResource.scala @@ -1376,15 +1376,13 @@ class DatasetResource { @POST @RolesAllowed(Array("REGULAR", "ADMIN")) @Path("/{did}/update/cover") - @Consumes(Array(MediaType.TEXT_PLAIN)) def updateDatasetCoverImage( - @PathParam("did") did: Integer, - coverImage: String, - @Auth sessionUser: SessionUser - ): Response = { + @PathParam("did") did: Integer, + coverImage: String, + @Auth sessionUser: SessionUser + ): Response = { withTransaction(context) { ctx => val uid = sessionUser.getUid - val datasetDao = new DatasetDao(ctx.configuration()) val dataset = getDatasetByID(ctx, did) if (!userHasWriteAccess(ctx, did, uid)) { @@ -1392,7 +1390,7 @@ class DatasetResource { } dataset.setCoverImage(coverImage) - datasetDao.update(dataset) + new DatasetDao(ctx.configuration()).update(dataset) Response.ok().build() } } diff --git a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.html b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.html index d4dddf94f6d..79ced02f864 100644 --- a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.html +++ b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.html @@ -263,7 +263,8 @@
Choose a Version:
[fileTreeNodes]="fileTreeNodeList" [isTreeNodeDeletable]="true" (selectedTreeNode)="onVersionFileTreeNodeSelected($event)" - (deletedTreeNode)="onPreviouslyUploadedFileDeleted($event)"> + (deletedTreeNode)="onPreviouslyUploadedFileDeleted($event)" + (setCoverImage)="onSetCoverImage($event)"> diff --git a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.ts b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.ts index b4d12f5a28e..ebbd3a94a75 100644 --- a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.ts +++ b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.ts @@ -657,4 +657,22 @@ export class DatasetDetailComponent implements OnInit { changeViewDisplayStyle() { this.displayPreciseViewCount = !this.displayPreciseViewCount; } + + onSetCoverImage(filePath: string): void { + if (!this.did || !this.selectedVersion) { + return; + } + + this.datasetService + .updateDatasetCoverImage(this.did, `${this.selectedVersion.name}/${filePath}`) + .pipe(untilDestroyed(this)) + .subscribe({ + next: () => { + this.notificationService.success("Cover image set successfully"); + }, + error: (err: unknown) => { + this.notificationService.error("Failed to set cover image"); + }, + }); + } } diff --git a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-version-filetree/user-dataset-version-filetree.component.ts b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-version-filetree/user-dataset-version-filetree.component.ts index cc0820ea544..cbf490e0c61 100644 --- a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-version-filetree/user-dataset-version-filetree.component.ts +++ b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-version-filetree/user-dataset-version-filetree.component.ts @@ -88,7 +88,6 @@ export class UserDatasetVersionFiletreeComponent implements AfterViewInit { onSetCover(nodeData: DatasetFileNode): void { const path = getRelativePathFromDatasetFileNode(nodeData); - console.log('Setting cover to:', path); this.setCoverImage.emit(path); } } diff --git a/frontend/src/app/dashboard/service/user/dataset/dataset.service.ts b/frontend/src/app/dashboard/service/user/dataset/dataset.service.ts index d5e458337ac..a289c28315f 100644 --- a/frontend/src/app/dashboard/service/user/dataset/dataset.service.ts +++ b/frontend/src/app/dashboard/service/user/dataset/dataset.service.ts @@ -541,12 +541,6 @@ export class DatasetService { } public updateDatasetCoverImage(did: number, coverImagePath: string): Observable { - return this.http.post( - `${AppSettings.getApiEndpoint()}/${DATASET_BASE_URL}/${did}/update/cover`, - coverImagePath, - { - headers: { "Content-Type": "text/plain" }, - } - ); + return this.http.post(`${AppSettings.getApiEndpoint()}/dataset/${did}/update/cover`, coverImagePath); } } diff --git a/frontend/src/app/hub/component/browse-section/browse-section.component.html b/frontend/src/app/hub/component/browse-section/browse-section.component.html index 71e308b5f89..2fd8f37525b 100644 --- a/frontend/src/app/hub/component/browse-section/browse-section.component.html +++ b/frontend/src/app/hub/component/browse-section/browse-section.component.html @@ -44,7 +44,8 @@

{{ entity.name }}

example + [src]="getCoverImage(entity)" + (error)="$any($event.target).src = defaultBackground" /> Date: Sat, 6 Dec 2025 22:38:35 -0800 Subject: [PATCH 3/8] update.. --- .../user-dataset-file-renderer.component.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-file-renderer/user-dataset-file-renderer.component.ts b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-file-renderer/user-dataset-file-renderer.component.ts index 576de576dff..c851a8284ea 100644 --- a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-file-renderer/user-dataset-file-renderer.component.ts +++ b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-file-renderer/user-dataset-file-renderer.component.ts @@ -131,9 +131,6 @@ export class UserDatasetFileRendererComponent implements OnInit, OnChanges, OnDe @Output() loadFile = new EventEmitter<{ file: string; prefix: string }>(); - @Output() - setCoverImage = new EventEmitter(); - constructor( private datasetService: DatasetService, private sanitizer: DomSanitizer, From 430fa9b69f3fb5e187eaced785f30d96e1908f66 Mon Sep 17 00:00:00 2001 From: Xuan Gu <162244362+xuang7@users.noreply.github.com> Date: Sun, 7 Dec 2025 17:04:54 -0800 Subject: [PATCH 4/8] update with cover file size limit. --- .../service/resource/DatasetResource.scala | 20 ++++++++++++++++++- .../dataset-detail.component.ts | 4 ++-- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/file-service/src/main/scala/org/apache/texera/service/resource/DatasetResource.scala b/file-service/src/main/scala/org/apache/texera/service/resource/DatasetResource.scala index 05aa4178ed7..16fad2fa3cf 100644 --- a/file-service/src/main/scala/org/apache/texera/service/resource/DatasetResource.scala +++ b/file-service/src/main/scala/org/apache/texera/service/resource/DatasetResource.scala @@ -1384,11 +1384,29 @@ class DatasetResource { withTransaction(context) { ctx => val uid = sessionUser.getUid val dataset = getDatasetByID(ctx, did) - if (!userHasWriteAccess(ctx, did, uid)) { throw new ForbiddenException(ERR_USER_HAS_NO_ACCESS_TO_DATASET_MESSAGE) } + val document = DocumentFactory + .openReadonlyDocument( + FileResolver.resolve(s"${getOwner(ctx, did).getEmail}/${dataset.getName}/$coverImage") + ) + .asInstanceOf[OnDataset] + + val file = LakeFSStorageClient.getFileFromRepo( + document.getRepositoryName(), + document.getVersionHash(), + document.getFileRelativePath() + ) + val coverSizeLimit = 10 * 1024 * 1024 // 10 MB + + if (file.length() > coverSizeLimit) { + throw new BadRequestException( + s"Cover image must be less than ${coverSizeLimit / (1024 * 1024)} MB" + ) + } + dataset.setCoverImage(coverImage) new DatasetDao(ctx.configuration()).update(dataset) Response.ok().build() diff --git a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.ts b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.ts index ebbd3a94a75..dc495599af4 100644 --- a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.ts +++ b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.ts @@ -670,8 +670,8 @@ export class DatasetDetailComponent implements OnInit { next: () => { this.notificationService.success("Cover image set successfully"); }, - error: (err: unknown) => { - this.notificationService.error("Failed to set cover image"); + error: (err: HttpErrorResponse) => { + this.notificationService.error(err.error?.message || "Failed to set cover image"); }, }); } From c742730eb38b39e6b1b96d9dec0bd5d24286e089 Mon Sep 17 00:00:00 2001 From: Xuan Gu <162244362+xuang7@users.noreply.github.com> Date: Sat, 3 Jan 2026 16:38:15 -0800 Subject: [PATCH 5/8] update. --- .../user-dataset-version-filetree.component.ts | 8 ++++---- sql/updates/{16.sql => 17.sql} | 0 2 files changed, 4 insertions(+), 4 deletions(-) rename sql/updates/{16.sql => 17.sql} (100%) diff --git a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-version-filetree/user-dataset-version-filetree.component.ts b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-version-filetree/user-dataset-version-filetree.component.ts index cbf490e0c61..c9203034740 100644 --- a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-version-filetree/user-dataset-version-filetree.component.ts +++ b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-version-filetree/user-dataset-version-filetree.component.ts @@ -25,6 +25,8 @@ import { } from "../../../../../../common/type/datasetVersionFileTree"; import { ITreeOptions, TREE_ACTIONS } from "@ali-hm/angular-tree-component"; +const IMAGE_EXTENSIONS = [".jpg", ".jpeg", ".png", ".gif", ".webp"] as const; + @UntilDestroy() @Component({ selector: "texera-user-dataset-version-filetree", @@ -82,12 +84,10 @@ export class UserDatasetVersionFiletreeComponent implements AfterViewInit { } isImageFile(fileName: string): boolean { - const imageExts = [".jpg", ".jpeg", ".png", ".gif", ".webp"]; - return imageExts.some(ext => fileName.toLowerCase().endsWith(ext)); + return IMAGE_EXTENSIONS.some(ext => fileName.toLowerCase().endsWith(ext)); } onSetCover(nodeData: DatasetFileNode): void { - const path = getRelativePathFromDatasetFileNode(nodeData); - this.setCoverImage.emit(path); + this.setCoverImage.emit(getRelativePathFromDatasetFileNode(nodeData)); } } diff --git a/sql/updates/16.sql b/sql/updates/17.sql similarity index 100% rename from sql/updates/16.sql rename to sql/updates/17.sql From 8d8fd1bc7d720ad3222b168f13cdc963ab05fd78 Mon Sep 17 00:00:00 2001 From: Xuan Gu <162244362+xuang7@users.noreply.github.com> Date: Mon, 5 Jan 2026 15:45:43 -0800 Subject: [PATCH 6/8] new endpoint. --- .../service/resource/DatasetResource.scala | 100 ++++++++++++++---- .../dataset-detail.component.ts | 8 +- .../service/user/dataset/dataset.service.ts | 6 +- .../browse-section.component.ts | 28 ++++- sql/updates/{17.sql => 16.sql} | 0 sql/updates/18.sql | 30 ++++++ 6 files changed, 141 insertions(+), 31 deletions(-) rename sql/updates/{17.sql => 16.sql} (100%) create mode 100644 sql/updates/18.sql diff --git a/file-service/src/main/scala/org/apache/texera/service/resource/DatasetResource.scala b/file-service/src/main/scala/org/apache/texera/service/resource/DatasetResource.scala index 78120b50e7e..9257e9eeec5 100644 --- a/file-service/src/main/scala/org/apache/texera/service/resource/DatasetResource.scala +++ b/file-service/src/main/scala/org/apache/texera/service/resource/DatasetResource.scala @@ -35,28 +35,17 @@ import org.apache.texera.dao.jooq.generated.tables.Dataset.DATASET import org.apache.texera.dao.jooq.generated.tables.DatasetUserAccess.DATASET_USER_ACCESS import org.apache.texera.dao.jooq.generated.tables.DatasetVersion.DATASET_VERSION import org.apache.texera.dao.jooq.generated.tables.User.USER -import org.apache.texera.dao.jooq.generated.tables.daos.{ - DatasetDao, - DatasetUserAccessDao, - DatasetVersionDao -} -import org.apache.texera.dao.jooq.generated.tables.pojos.{ - Dataset, - DatasetUserAccess, - DatasetVersion -} +import org.apache.texera.dao.jooq.generated.tables.daos.{DatasetDao, DatasetUserAccessDao, DatasetVersionDao} +import org.apache.texera.dao.jooq.generated.tables.pojos.{Dataset, DatasetUserAccess, DatasetVersion} import org.apache.texera.service.`type`.DatasetFileNode import org.apache.texera.service.resource.DatasetAccessResource._ import org.apache.texera.service.resource.DatasetResource.{context, _} import org.apache.texera.service.util.S3StorageClient -import org.apache.texera.service.util.S3StorageClient.{ - MAXIMUM_NUM_OF_MULTIPART_S3_PARTS, - MINIMUM_NUM_OF_MULTIPART_S3_PART -} +import org.apache.texera.service.util.S3StorageClient.{MAXIMUM_NUM_OF_MULTIPART_S3_PARTS, MINIMUM_NUM_OF_MULTIPART_S3_PART} import org.jooq.{DSLContext, EnumType} import java.io.{InputStream, OutputStream} -import java.net.{HttpURLConnection, URL, URLDecoder} +import java.net.{HttpURLConnection, URI, URL, URLDecoder} import java.nio.charset.StandardCharsets import java.nio.file.{Files, Paths} import java.util @@ -169,6 +158,8 @@ object DatasetResource { fileNodes: List[DatasetFileNode], size: Long ) + + case class CoverImageRequest(coverImage: String) } @Produces(Array(MediaType.APPLICATION_JSON, "image/jpeg", "application/pdf")) @@ -178,6 +169,9 @@ class DatasetResource { private val ERR_DATASET_VERSION_NOT_FOUND_MESSAGE = "The version of the dataset not found" private val EXPIRATION_MINUTES = 5 + private val COVER_IMAGE_SIZE_LIMIT_BYTES: Long = 10 * 1024 * 1024 // 10 MB + private val ALLOWED_IMAGE_EXTENSIONS: Set[String] = Set(".jpg", ".jpeg", ".png", ".gif", ".webp") + /** * Helper function to get the dataset from DB with additional information including user access privilege and owner email */ @@ -1327,12 +1321,23 @@ class DatasetResource { } } + /** + * Updates the cover image for a dataset. + * + * @param did Dataset ID + * @param request Cover image request containing the relative file path + * @param sessionUser Authenticated user session + * @return Response with updated cover image path + * + * Expected coverImage format: "version/folder/image.jpg" (relative to dataset root) + */ @POST @RolesAllowed(Array("REGULAR", "ADMIN")) @Path("/{did}/update/cover") + @Consumes(Array(MediaType.APPLICATION_JSON)) def updateDatasetCoverImage( @PathParam("did") did: Integer, - coverImage: String, + request: CoverImageRequest, @Auth sessionUser: SessionUser ): Response = { withTransaction(context) { ctx => @@ -1342,9 +1347,23 @@ class DatasetResource { throw new ForbiddenException(ERR_USER_HAS_NO_ACCESS_TO_DATASET_MESSAGE) } + if (request == null || request.coverImage == null || request.coverImage.trim.isEmpty) { + throw new BadRequestException("Cover image path is required") + } + + val normalized = Paths.get(request.coverImage).normalize().toString + if (normalized.startsWith("..") || normalized.startsWith("/")) { + throw new BadRequestException("Invalid file path") + } + + if (!ALLOWED_IMAGE_EXTENSIONS.exists(ext => normalized.toLowerCase.endsWith(ext))) { + throw new BadRequestException("Invalid file type") + } + + val owner = getOwner(ctx, did) val document = DocumentFactory .openReadonlyDocument( - FileResolver.resolve(s"${getOwner(ctx, did).getEmail}/${dataset.getName}/$coverImage") + FileResolver.resolve(s"${owner.getEmail}/${dataset.getName}/$normalized") ) .asInstanceOf[OnDataset] @@ -1353,17 +1372,54 @@ class DatasetResource { document.getVersionHash(), document.getFileRelativePath() ) - val coverSizeLimit = 10 * 1024 * 1024 // 10 MB - if (file.length() > coverSizeLimit) { + if (file.length() > COVER_IMAGE_SIZE_LIMIT_BYTES) { throw new BadRequestException( - s"Cover image must be less than ${coverSizeLimit / (1024 * 1024)} MB" + s"Cover image must be less than ${COVER_IMAGE_SIZE_LIMIT_BYTES / (1024 * 1024)} MB" ) } - dataset.setCoverImage(coverImage) + dataset.setCoverImage(normalized) new DatasetDao(ctx.configuration()).update(dataset) - Response.ok().build() + Response.ok(Map("coverImage" -> normalized)).build() + } + } + + /** + * Get the cover image for a public dataset. + * Returns a 307 redirect to the presigned S3 URL. + * + * @param did Dataset ID + * @return 307 Temporary Redirect to cover image + */ + @GET + @Path("/{did}/cover") + def getDatasetCover(@PathParam("did") did: Integer): Response = { + withTransaction(context) { ctx => + val dataset = getDatasetByID(ctx, did) + + if (!dataset.getIsPublic) { + throw new ForbiddenException("Access denied") + } + + val coverImage = Option(dataset.getCoverImage).getOrElse( + throw new NotFoundException("No cover image") + ) + + val owner = getOwner(ctx, did) + val fullPath = s"${owner.getEmail}/${dataset.getName}/$coverImage" + + val document = DocumentFactory + .openReadonlyDocument(FileResolver.resolve(fullPath)) + .asInstanceOf[OnDataset] + + val presignedUrl = LakeFSStorageClient.getFilePresignedUrl( + document.getRepositoryName(), + document.getVersionHash(), + document.getFileRelativePath() + ) + + Response.temporaryRedirect(new URI(presignedUrl)).build() } } } diff --git a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.ts b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.ts index dc495599af4..993edfafe30 100644 --- a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.ts +++ b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.ts @@ -670,8 +670,12 @@ export class DatasetDetailComponent implements OnInit { next: () => { this.notificationService.success("Cover image set successfully"); }, - error: (err: HttpErrorResponse) => { - this.notificationService.error(err.error?.message || "Failed to set cover image"); + error: (err: unknown) => { + this.notificationService.error( + err instanceof HttpErrorResponse + ? err.error?.message || "Failed to set cover image" + : "Failed to set cover image" + ); }, }); } diff --git a/frontend/src/app/dashboard/service/user/dataset/dataset.service.ts b/frontend/src/app/dashboard/service/user/dataset/dataset.service.ts index a289c28315f..c4e01332d0b 100644 --- a/frontend/src/app/dashboard/service/user/dataset/dataset.service.ts +++ b/frontend/src/app/dashboard/service/user/dataset/dataset.service.ts @@ -540,7 +540,9 @@ export class DatasetService { return this.http.get(`${AppSettings.getApiEndpoint()}/${DATASET_GET_OWNERS_URL}`); } - public updateDatasetCoverImage(did: number, coverImagePath: string): Observable { - return this.http.post(`${AppSettings.getApiEndpoint()}/dataset/${did}/update/cover`, coverImagePath); + public updateDatasetCoverImage(did: number, coverImage: string): Observable { + return this.http.post(`${AppSettings.getApiEndpoint()}/dataset/${did}/update/cover`, { + coverImage: coverImage, + }); } } diff --git a/frontend/src/app/hub/component/browse-section/browse-section.component.ts b/frontend/src/app/hub/component/browse-section/browse-section.component.ts index 0d7de74f23e..4e97ff4af8c 100644 --- a/frontend/src/app/hub/component/browse-section/browse-section.component.ts +++ b/frontend/src/app/hub/component/browse-section/browse-section.component.ts @@ -28,6 +28,7 @@ import { DASHBOARD_USER_DATASET, DASHBOARD_USER_WORKSPACE, } from "../../../app-routing.constant"; +import { AppSettings } from "../../../common/app-setting"; @UntilDestroy() @Component({ @@ -47,6 +48,8 @@ export class BrowseSectionComponent implements OnInit, OnChanges { protected readonly DASHBOARD_USER_DATASET = DASHBOARD_USER_DATASET; entityRoutes: { [key: number]: string[] } = {}; + private coverImageUrls = new Map(); + constructor( private workflowPersistService: WorkflowPersistService, private datasetService: DatasetService, @@ -57,12 +60,14 @@ export class BrowseSectionComponent implements OnInit, OnChanges { this.entities.forEach(entity => { this.initializeEntry(entity); }); + this.loadCoverImages(); } ngOnChanges(changes: SimpleChanges): void { this.entities.forEach(entity => { this.initializeEntry(entity); }); + this.loadCoverImages(); } private initializeEntry(entity: DashboardEntry): void { @@ -90,11 +95,24 @@ export class BrowseSectionComponent implements OnInit, OnChanges { } } + private loadCoverImages(): void { + if (!this.entities) return; + + this.entities + .filter( + (entity): entity is DashboardEntry & { id: number } => + entity.type === "dataset" && + entity.coverImageUrl !== undefined && + entity.id !== undefined && + !this.coverImageUrls.has(entity.id) + ) + .forEach(entity => { + const coverUrl = `${AppSettings.getApiEndpoint()}/dataset/${entity.id}/cover`; + this.coverImageUrls.set(entity.id, coverUrl); + }); + } + getCoverImage(entity: DashboardEntry): string { - if (entity.type === "dataset" && entity.coverImageUrl) { - const fullPath = `${entity.ownerEmail}/${entity.name}/${entity.coverImageUrl}`; - return `/api/dataset/file?path=${encodeURIComponent(fullPath)}`; - } - return this.defaultBackground; + return this.coverImageUrls.get(entity.id!) || this.defaultBackground; } } diff --git a/sql/updates/17.sql b/sql/updates/16.sql similarity index 100% rename from sql/updates/17.sql rename to sql/updates/16.sql diff --git a/sql/updates/18.sql b/sql/updates/18.sql new file mode 100644 index 00000000000..501bbae8e55 --- /dev/null +++ b/sql/updates/18.sql @@ -0,0 +1,30 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +\c texera_db + +SET search_path TO texera_db; + +BEGIN; + +-- 1. Add new column cover_image to dataset table. +ALTER TABLE dataset + ADD COLUMN cover_image varchar(246); + +COMMIT; From bc1da4dd5eadb7f267daeee1eaee7b98157bd18e Mon Sep 17 00:00:00 2001 From: Xuan Gu <162244362+xuang7@users.noreply.github.com> Date: Mon, 5 Jan 2026 15:55:42 -0800 Subject: [PATCH 7/8] update. --- .../service/resource/DatasetResource.scala | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/file-service/src/main/scala/org/apache/texera/service/resource/DatasetResource.scala b/file-service/src/main/scala/org/apache/texera/service/resource/DatasetResource.scala index 403d9beb001..960738ee69b 100644 --- a/file-service/src/main/scala/org/apache/texera/service/resource/DatasetResource.scala +++ b/file-service/src/main/scala/org/apache/texera/service/resource/DatasetResource.scala @@ -35,13 +35,24 @@ import org.apache.texera.dao.jooq.generated.tables.Dataset.DATASET import org.apache.texera.dao.jooq.generated.tables.DatasetUserAccess.DATASET_USER_ACCESS import org.apache.texera.dao.jooq.generated.tables.DatasetVersion.DATASET_VERSION import org.apache.texera.dao.jooq.generated.tables.User.USER -import org.apache.texera.dao.jooq.generated.tables.daos.{DatasetDao, DatasetUserAccessDao, DatasetVersionDao} -import org.apache.texera.dao.jooq.generated.tables.pojos.{Dataset, DatasetUserAccess, DatasetVersion} +import org.apache.texera.dao.jooq.generated.tables.daos.{ + DatasetDao, + DatasetUserAccessDao, + DatasetVersionDao +} +import org.apache.texera.dao.jooq.generated.tables.pojos.{ + Dataset, + DatasetUserAccess, + DatasetVersion +} import org.apache.texera.service.`type`.DatasetFileNode import org.apache.texera.service.resource.DatasetAccessResource._ import org.apache.texera.service.resource.DatasetResource.{context, _} import org.apache.texera.service.util.S3StorageClient -import org.apache.texera.service.util.S3StorageClient.{MAXIMUM_NUM_OF_MULTIPART_S3_PARTS, MINIMUM_NUM_OF_MULTIPART_S3_PART} +import org.apache.texera.service.util.S3StorageClient.{ + MAXIMUM_NUM_OF_MULTIPART_S3_PARTS, + MINIMUM_NUM_OF_MULTIPART_S3_PART +} import org.jooq.{DSLContext, EnumType} import org.jooq.impl.DSL import org.jooq.impl.DSL.{inline => inl} @@ -1736,8 +1747,8 @@ class DatasetResource { Response.ok(Map("message" -> "Multipart upload aborted successfully")).build() } } - - /** + + /** * Updates the cover image for a dataset. * * @param did Dataset ID From f88f0947041e71c52f6078b4f533ee771a444404 Mon Sep 17 00:00:00 2001 From: Xuan Gu <162244362+xuang7@users.noreply.github.com> Date: Tue, 6 Jan 2026 14:24:42 -0800 Subject: [PATCH 8/8] update. --- .../storage/util/LakeFSStorageClient.scala | 19 ++ .../service/resource/DatasetResource.scala | 49 +++-- .../resource/DatasetResourceSpec.scala | 176 ++++++++++++++++++ 3 files changed, 232 insertions(+), 12 deletions(-) diff --git a/common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/util/LakeFSStorageClient.scala b/common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/util/LakeFSStorageClient.scala index d01e820259d..09fa6f3eb30 100644 --- a/common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/util/LakeFSStorageClient.scala +++ b/common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/util/LakeFSStorageClient.scala @@ -402,4 +402,23 @@ object LakeFSStorageClient { (bucket, key) } + /** + * Get file size. + * + * @param repoName Repository name. + * @param commitHash Commit hash of the version. + * @param filePath Path to the file in the repository. + * @return File size in bytes + */ + def getFileSize( + repoName: String, + commitHash: String, + filePath: String + ): Long = { + objectsApi + .statObject(repoName, commitHash, filePath) + .execute() + .getSizeBytes + .longValue() + } } diff --git a/file-service/src/main/scala/org/apache/texera/service/resource/DatasetResource.scala b/file-service/src/main/scala/org/apache/texera/service/resource/DatasetResource.scala index 960738ee69b..59437f23a46 100644 --- a/file-service/src/main/scala/org/apache/texera/service/resource/DatasetResource.scala +++ b/file-service/src/main/scala/org/apache/texera/service/resource/DatasetResource.scala @@ -70,6 +70,7 @@ import org.apache.texera.dao.jooq.generated.tables.DatasetUploadSession.DATASET_ import org.apache.texera.dao.jooq.generated.tables.DatasetUploadSessionPart.DATASET_UPLOAD_SESSION_PART import org.jooq.exception.DataAccessException import software.amazon.awssdk.services.s3.model.UploadPartResponse +import org.apache.commons.io.FilenameUtils import java.sql.SQLException import scala.util.Try @@ -144,6 +145,25 @@ object DatasetResource { .toScala } + /** + * Validates a file path using Apache Commons IO. + */ + def validateSafePath(path: String): String = { + if (path == null || path.trim.isEmpty) { + throw new BadRequestException("Path cannot be empty") + } + + val normalized = FilenameUtils.normalize(path, true) + if (normalized == null) { + throw new BadRequestException("Invalid path") + } + + if (FilenameUtils.getPrefixLength(normalized) > 0) { + throw new BadRequestException("Absolute paths not allowed") + } + normalized + } + case class DashboardDataset( dataset: Dataset, ownerEmail: String, @@ -1774,16 +1794,14 @@ class DatasetResource { throw new ForbiddenException(ERR_USER_HAS_NO_ACCESS_TO_DATASET_MESSAGE) } - if (request == null || request.coverImage == null || request.coverImage.trim.isEmpty) { + if (request.coverImage == null || request.coverImage.trim.isEmpty) { throw new BadRequestException("Cover image path is required") } - val normalized = Paths.get(request.coverImage).normalize().toString - if (normalized.startsWith("..") || normalized.startsWith("/")) { - throw new BadRequestException("Invalid file path") - } + val normalized = DatasetResource.validateSafePath(request.coverImage) - if (!ALLOWED_IMAGE_EXTENSIONS.exists(ext => normalized.toLowerCase.endsWith(ext))) { + val extension = FilenameUtils.getExtension(normalized) + if (extension == null || !ALLOWED_IMAGE_EXTENSIONS.contains(s".$extension".toLowerCase)) { throw new BadRequestException("Invalid file type") } @@ -1794,13 +1812,13 @@ class DatasetResource { ) .asInstanceOf[OnDataset] - val file = LakeFSStorageClient.getFileFromRepo( + val fileSize = LakeFSStorageClient.getFileSize( document.getRepositoryName(), document.getVersionHash(), document.getFileRelativePath() ) - if (file.length() > COVER_IMAGE_SIZE_LIMIT_BYTES) { + if (fileSize > COVER_IMAGE_SIZE_LIMIT_BYTES) { throw new BadRequestException( s"Cover image must be less than ${COVER_IMAGE_SIZE_LIMIT_BYTES / (1024 * 1024)} MB" ) @@ -1813,7 +1831,7 @@ class DatasetResource { } /** - * Get the cover image for a public dataset. + * Get the cover image for a dataset. * Returns a 307 redirect to the presigned S3 URL. * * @param did Dataset ID @@ -1821,12 +1839,19 @@ class DatasetResource { */ @GET @Path("/{did}/cover") - def getDatasetCover(@PathParam("did") did: Integer): Response = { + def getDatasetCover( + @PathParam("did") did: Integer, + @Auth sessionUser: Optional[SessionUser] + ): Response = { withTransaction(context) { ctx => val dataset = getDatasetByID(ctx, did) - if (!dataset.getIsPublic) { - throw new ForbiddenException("Access denied") + val requesterUid = if (sessionUser.isPresent) Some(sessionUser.get().getUid) else None + + if (requesterUid.isEmpty && !dataset.getIsPublic) { + throw new ForbiddenException(ERR_USER_HAS_NO_ACCESS_TO_DATASET_MESSAGE) + } else if (requesterUid.exists(uid => !userHasReadAccess(ctx, did, uid))) { + throw new ForbiddenException(ERR_USER_HAS_NO_ACCESS_TO_DATASET_MESSAGE) } val coverImage = Option(dataset.getCoverImage).getOrElse( diff --git a/file-service/src/test/scala/org/apache/texera/service/resource/DatasetResourceSpec.scala b/file-service/src/test/scala/org/apache/texera/service/resource/DatasetResourceSpec.scala index 3f72c574861..43ddbee1cb6 100644 --- a/file-service/src/test/scala/org/apache/texera/service/resource/DatasetResourceSpec.scala +++ b/file-service/src/test/scala/org/apache/texera/service/resource/DatasetResourceSpec.scala @@ -1328,4 +1328,180 @@ class DatasetResourceSpec val part1 = fetchPartRows(uploadId).find(_.getPartNumber == 1).get part1.getEtag.trim should not be "" } + + // =========================================================================== + // Cover Image Tests + // =========================================================================== + + "updateDatasetCoverImage" should "reject path traversal attempts" in { + val maliciousPaths = Seq( + "../../../etc/passwd", + "v1/../../secret.txt", + "../escape.jpg" + ) + + maliciousPaths.foreach { path => + val request = DatasetResource.CoverImageRequest(path) + + assertThrows[BadRequestException] { + datasetResource.updateDatasetCoverImage( + baseDataset.getDid, + request, + sessionUser + ) + } + } + } + + it should "reject absolute paths" in { + val absolutePaths = Seq( + "/etc/passwd", + "/var/log/system.log" + ) + + absolutePaths.foreach { path => + val request = DatasetResource.CoverImageRequest(path) + + assertThrows[BadRequestException] { + datasetResource.updateDatasetCoverImage( + baseDataset.getDid, + request, + sessionUser + ) + } + } + } + + it should "reject invalid file types" in { + val invalidPaths = Seq( + "v1/script.js", + "v1/document.pdf", + "v1/data.csv" + ) + + invalidPaths.foreach { path => + val request = DatasetResource.CoverImageRequest(path) + + assertThrows[BadRequestException] { + datasetResource.updateDatasetCoverImage( + baseDataset.getDid, + request, + sessionUser + ) + } + } + } + + it should "reject empty or null cover image path" in { + assertThrows[BadRequestException] { + datasetResource.updateDatasetCoverImage( + baseDataset.getDid, + DatasetResource.CoverImageRequest(""), + sessionUser + ) + } + + assertThrows[BadRequestException] { + datasetResource.updateDatasetCoverImage( + baseDataset.getDid, + DatasetResource.CoverImageRequest(null), + sessionUser + ) + } + } + + it should "reject when user lacks WRITE access" in { + val request = DatasetResource.CoverImageRequest("v1/cover.jpg") + + assertThrows[ForbiddenException] { + datasetResource.updateDatasetCoverImage( + baseDataset.getDid, + request, + sessionUser2 + ) + } + } + + "getDatasetCover" should "reject private dataset cover for anonymous users" in { + val dataset = datasetDao.fetchOneByDid(baseDataset.getDid) + dataset.setIsPublic(false) + dataset.setCoverImage("v1/cover.jpg") + datasetDao.update(dataset) + + assertThrows[ForbiddenException] { + datasetResource.getDatasetCover(baseDataset.getDid, Optional.empty()) + } + } + + it should "reject private dataset cover for users without access" in { + val dataset = datasetDao.fetchOneByDid(baseDataset.getDid) + dataset.setOwnerUid(ownerUser.getUid) + dataset.setIsPublic(false) + dataset.setCoverImage("v1/cover.jpg") + datasetDao.update(dataset) + + assertThrows[ForbiddenException] { + datasetResource.getDatasetCover(baseDataset.getDid, Optional.of(sessionUser2)) + } + } + + it should "return 404 when no cover image is set" in { + val dataset = datasetDao.fetchOneByDid(baseDataset.getDid) + dataset.setCoverImage(null) + dataset.setIsPublic(true) + datasetDao.update(dataset) + + assertThrows[NotFoundException] { + datasetResource.getDatasetCover(baseDataset.getDid, Optional.of(sessionUser)) + } + } + + "validateSafePath" should "accept valid relative paths" in { + DatasetResource.validateSafePath("v1/image.jpg") shouldEqual "v1/image.jpg" + DatasetResource.validateSafePath("v1/folder/photo.png") shouldEqual "v1/folder/photo.png" + } + + it should "normalize safe internal navigation" in { + DatasetResource.validateSafePath("v1/../v2/img.jpg") shouldEqual "v2/img.jpg" + DatasetResource.validateSafePath("./v1/image.jpg") shouldEqual "v1/image.jpg" + DatasetResource.validateSafePath("v1/./image.jpg") shouldEqual "v1/image.jpg" + } + + it should "reject path traversal" in { + assertThrows[BadRequestException] { + DatasetResource.validateSafePath("../escape.txt") + } + + assertThrows[BadRequestException] { + DatasetResource.validateSafePath("../../etc/passwd") + } + + assertThrows[BadRequestException] { + DatasetResource.validateSafePath("v1/../../escape.txt") + } + } + + it should "reject absolute paths" in { + assertThrows[BadRequestException] { + DatasetResource.validateSafePath("/etc/passwd") + } + + assertThrows[BadRequestException] { + DatasetResource.validateSafePath("C:\\windows\\system32") + } + } + + it should "reject empty or null paths" in { + assertThrows[BadRequestException] { + DatasetResource.validateSafePath(null) + } + + assertThrows[BadRequestException] { + DatasetResource.validateSafePath("") + } + + assertThrows[BadRequestException] { + DatasetResource.validateSafePath(" ") + } + } }