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 44ce22dfb1d..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 @@ -57,7 +57,7 @@ import org.jooq.{DSLContext, EnumType} import org.jooq.impl.DSL import org.jooq.impl.DSL.{inline => inl} 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 @@ -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, @@ -177,6 +197,8 @@ object DatasetResource { fileNodes: List[DatasetFileNode], size: Long ) + + case class CoverImageRequest(coverImage: String) } @Produces(Array(MediaType.APPLICATION_JSON, "image/jpeg", "application/pdf")) @@ -186,6 +208,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 */ @@ -1742,4 +1767,111 @@ class DatasetResource { Response.ok(Map("message" -> "Multipart upload aborted successfully")).build() } } + + /** + * 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, + request: CoverImageRequest, + @Auth sessionUser: SessionUser + ): Response = { + 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) + } + + if (request.coverImage == null || request.coverImage.trim.isEmpty) { + throw new BadRequestException("Cover image path is required") + } + + val normalized = DatasetResource.validateSafePath(request.coverImage) + + val extension = FilenameUtils.getExtension(normalized) + if (extension == null || !ALLOWED_IMAGE_EXTENSIONS.contains(s".$extension".toLowerCase)) { + throw new BadRequestException("Invalid file type") + } + + val owner = getOwner(ctx, did) + val document = DocumentFactory + .openReadonlyDocument( + FileResolver.resolve(s"${owner.getEmail}/${dataset.getName}/$normalized") + ) + .asInstanceOf[OnDataset] + + val fileSize = LakeFSStorageClient.getFileSize( + document.getRepositoryName(), + document.getVersionHash(), + document.getFileRelativePath() + ) + + 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" + ) + } + + dataset.setCoverImage(normalized) + new DatasetDao(ctx.configuration()).update(dataset) + Response.ok(Map("coverImage" -> normalized)).build() + } + } + + /** + * Get the cover image for a 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, + @Auth sessionUser: Optional[SessionUser] + ): Response = { + withTransaction(context) { ctx => + val dataset = getDatasetByID(ctx, did) + + 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( + 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/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(" ") + } + } } 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/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 bfc97379ecd..53a3c67391e 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 @@ -696,4 +696,26 @@ 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( + err instanceof HttpErrorResponse + ? err.error?.message || "Failed to set cover image" + : "Failed to set cover image" + ); + }, + }); + } } 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..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 @@ -19,9 +19,14 @@ 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"; +const IMAGE_EXTENSIONS = [".jpg", ".jpeg", ".png", ".gif", ".webp"] as const; + @UntilDestroy() @Component({ selector: "texera-user-dataset-version-filetree", @@ -40,6 +45,9 @@ export class UserDatasetVersionFiletreeComponent implements AfterViewInit { @ViewChild("tree") tree: any; + @Output() + setCoverImage = new EventEmitter(); + public fileTreeDisplayOptions: ITreeOptions = { displayField: "name", hasChildrenField: "children", @@ -74,4 +82,12 @@ export class UserDatasetVersionFiletreeComponent implements AfterViewInit { this.tree.treeModel.expandAll(); } } + + isImageFile(fileName: string): boolean { + return IMAGE_EXTENSIONS.some(ext => fileName.toLowerCase().endsWith(ext)); + } + + onSetCover(nodeData: DatasetFileNode): void { + this.setCoverImage.emit(getRelativePathFromDatasetFileNode(nodeData)); + } } 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 97b2e264b75..64c6cb0b368 100644 --- a/frontend/src/app/dashboard/service/user/dataset/dataset.service.ts +++ b/frontend/src/app/dashboard/service/user/dataset/dataset.service.ts @@ -559,4 +559,10 @@ export class DatasetService { public retrieveOwners(): Observable { return this.http.get(`${AppSettings.getApiEndpoint()}/${DATASET_GET_OWNERS_URL}`); } + + 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/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..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" /> (); + 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 { @@ -89,4 +94,25 @@ export class BrowseSectionComponent implements OnInit, OnChanges { throw new Error("Unexpected type in DashboardEntry."); } } + + 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 { + return this.coverImageUrls.get(entity.id!) || this.defaultBackground; + } } diff --git a/sql/texera_ddl.sql b/sql/texera_ddl.sql index 57ac69b6876..07206afc308 100644 --- a/sql/texera_ddl.sql +++ b/sql/texera_ddl.sql @@ -252,6 +252,7 @@ CREATE TABLE IF NOT EXISTS dataset is_downloadable BOOLEAN NOT NULL DEFAULT TRUE, description VARCHAR(512) NOT NULL, creation_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + cover_image varchar(255), FOREIGN KEY (owner_uid) REFERENCES "user"(uid) ON DELETE CASCADE ); 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;