diff --git a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowResource.scala b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowResource.scala index ee559048c97..02cf836dc84 100644 --- a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowResource.scala +++ b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowResource.scala @@ -77,6 +77,12 @@ object WorkflowResource { ) private def workflowOfProjectDao = new WorkflowOfProjectDao(context.configuration) + /** Max length of a stored cover-image data URL. */ + private val COVER_IMAGE_MAX_CHARS: Int = 4 * 1024 * 1024 + + /** JSON body/response for a workflow's cover image data URL. */ + case class CoverImageRequest(image: String) + def getWorkflowName(wid: Integer): String = { val workflow = workflowDao.fetchOneByWid(wid) if (workflow == null) { @@ -711,6 +717,73 @@ class WorkflowResource extends LazyLogging { workflowDao.update(workflow) } + /** Returns the workflow's cover image; 404 if none set. */ + @GET + @RolesAllowed(Array("REGULAR", "ADMIN")) + @Path("/{wid}/cover") + def getCoverImage(@PathParam("wid") wid: Integer, @Auth user: SessionUser): CoverImageRequest = { + if (!WorkflowAccessResource.hasReadAccess(wid, user.getUid)) { + throw new ForbiddenException(s"You do not have access to workflow $wid") + } + val image = context + .select(WORKFLOW_COVER_IMAGE.IMAGE) + .from(WORKFLOW_COVER_IMAGE) + .where(WORKFLOW_COVER_IMAGE.WID.eq(wid)) + .fetchOne(WORKFLOW_COVER_IMAGE.IMAGE) + if (image == null) { + throw new NotFoundException(s"Workflow $wid has no cover image") + } + CoverImageRequest(image) + } + + /** Sets or replaces the workflow's cover image. */ + @PUT + @RolesAllowed(Array("REGULAR", "ADMIN")) + @Path("/{wid}/cover") + def setCoverImage( + @PathParam("wid") wid: Integer, + request: CoverImageRequest, + @Auth user: SessionUser + ): Unit = { + if (!WorkflowAccessResource.hasWriteAccess(wid, user.getUid)) { + throw new ForbiddenException(s"You do not have permission to modify workflow $wid") + } + val image = Option(request.image).map(_.trim).getOrElse("") + if (image.isEmpty) { + throw new BadRequestException("Cover image is required") + } + if (!image.startsWith("data:image/")) { + throw new BadRequestException("Cover image must be an image data URL") + } + if (image.length > COVER_IMAGE_MAX_CHARS) { + throw new BadRequestException( + s"Cover image is too large (limit ${COVER_IMAGE_MAX_CHARS / (1024 * 1024)} MB)" + ) + } + context + .insertInto(WORKFLOW_COVER_IMAGE) + .set(WORKFLOW_COVER_IMAGE.WID, wid) + .set(WORKFLOW_COVER_IMAGE.IMAGE, image) + .onConflict(WORKFLOW_COVER_IMAGE.WID) + .doUpdate() + .set(WORKFLOW_COVER_IMAGE.IMAGE, image) + .execute() + } + + /** Removes the workflow's cover image. Idempotent. */ + @DELETE + @RolesAllowed(Array("REGULAR", "ADMIN")) + @Path("/{wid}/cover") + def deleteCoverImage(@PathParam("wid") wid: Integer, @Auth user: SessionUser): Unit = { + if (!WorkflowAccessResource.hasWriteAccess(wid, user.getUid)) { + throw new ForbiddenException(s"You do not have permission to modify workflow $wid") + } + context + .deleteFrom(WORKFLOW_COVER_IMAGE) + .where(WORKFLOW_COVER_IMAGE.WID.eq(wid)) + .execute() + } + @GET @RolesAllowed(Array("REGULAR", "ADMIN")) @Path("/type/{wid}") diff --git a/amber/src/test/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowResourceCoverSpec.scala b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowResourceCoverSpec.scala new file mode 100644 index 00000000000..14ff81eb4d8 --- /dev/null +++ b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowResourceCoverSpec.scala @@ -0,0 +1,253 @@ +/* + * 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. + */ + +package org.apache.texera.web.resource.dashboard.user.workflow + +import org.apache.texera.auth.SessionUser +import org.apache.texera.dao.MockTexeraDB +import org.apache.texera.dao.jooq.generated.Tables._ +import org.apache.texera.dao.jooq.generated.enums.PrivilegeEnum +import org.apache.texera.dao.jooq.generated.tables.daos.{ + UserDao, + WorkflowDao, + WorkflowOfUserDao, + WorkflowUserAccessDao +} +import org.apache.texera.dao.jooq.generated.tables.pojos.{ + User, + Workflow, + WorkflowOfUser, + WorkflowUserAccess +} +import org.apache.texera.web.resource.dashboard.user.workflow.WorkflowResource.CoverImageRequest +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers +import org.scalatest.{BeforeAndAfterAll, BeforeAndAfterEach} + +import java.sql.Timestamp +import javax.ws.rs.{BadRequestException, ForbiddenException, NotFoundException} + +class WorkflowResourceCoverSpec + extends AnyFlatSpec + with Matchers + with BeforeAndAfterAll + with BeforeAndAfterEach + with MockTexeraDB { + + private val ownerUid = 1000 + scala.util.Random.nextInt(1000) + private val readerUid = 2000 + scala.util.Random.nextInt(1000) + private val strangerUid = 3000 + scala.util.Random.nextInt(1000) + private val testWid = 5000 + scala.util.Random.nextInt(1000) + + private val sampleImage = "data:image/jpeg;base64,/9j/4AAQSkZJRg==" + + private var owner: User = _ + private var reader: User = _ + private var stranger: User = _ + + private var userDao: UserDao = _ + private var workflowDao: WorkflowDao = _ + private var workflowOfUserDao: WorkflowOfUserDao = _ + private var workflowUserAccessDao: WorkflowUserAccessDao = _ + private var resource: WorkflowResource = _ + + override protected def beforeAll(): Unit = { + initializeDBAndReplaceDSLContext() + } + + override protected def afterAll(): Unit = shutdownDB() + + override protected def beforeEach(): Unit = { + userDao = new UserDao(getDSLContext.configuration()) + workflowDao = new WorkflowDao(getDSLContext.configuration()) + workflowOfUserDao = new WorkflowOfUserDao(getDSLContext.configuration()) + workflowUserAccessDao = new WorkflowUserAccessDao(getDSLContext.configuration()) + resource = new WorkflowResource() + + owner = makeUser(ownerUid, "cover_owner") + reader = makeUser(readerUid, "cover_reader") + stranger = makeUser(strangerUid, "cover_stranger") + + val workflow = new Workflow + workflow.setWid(testWid) + workflow.setName("cover_test_workflow") + workflow.setContent("{}") + workflow.setDescription("desc") + workflow.setIsPublic(false) + workflow.setCreationTime(new Timestamp(System.currentTimeMillis())) + workflow.setLastModifiedTime(new Timestamp(System.currentTimeMillis())) + + cleanupTestData() + + userDao.insert(owner) + userDao.insert(reader) + userDao.insert(stranger) + workflowDao.insert(workflow) + + val ownership = new WorkflowOfUser + ownership.setUid(ownerUid) + ownership.setWid(testWid) + workflowOfUserDao.insert(ownership) + + grantAccess(ownerUid, PrivilegeEnum.WRITE) + grantAccess(readerUid, PrivilegeEnum.READ) + } + + override protected def afterEach(): Unit = cleanupTestData() + + private def makeUser(uid: Int, name: String): User = { + val user = new User + user.setUid(uid) + user.setName(name) + user.setEmail(s"$name@test.com") + user.setPassword("password") + user + } + + private def grantAccess(uid: Int, privilege: PrivilegeEnum): Unit = { + val access = new WorkflowUserAccess + access.setUid(uid) + access.setWid(testWid) + access.setPrivilege(privilege) + workflowUserAccessDao.insert(access) + } + + private def session(user: User): SessionUser = new SessionUser(user) + + private def cleanupTestData(): Unit = { + getDSLContext + .deleteFrom(WORKFLOW_COVER_IMAGE) + .where(WORKFLOW_COVER_IMAGE.WID.eq(testWid)) + .execute() + getDSLContext + .deleteFrom(WORKFLOW_USER_ACCESS) + .where(WORKFLOW_USER_ACCESS.WID.eq(testWid)) + .execute() + getDSLContext + .deleteFrom(WORKFLOW_OF_USER) + .where(WORKFLOW_OF_USER.WID.eq(testWid)) + .execute() + getDSLContext + .deleteFrom(WORKFLOW) + .where(WORKFLOW.WID.eq(testWid)) + .execute() + getDSLContext + .deleteFrom(USER) + .where(USER.UID.in(ownerUid, readerUid, strangerUid)) + .execute() + } + + "getCoverImage" should "throw NotFoundException when no cover is set" in { + assertThrows[NotFoundException] { + resource.getCoverImage(testWid, session(owner)) + } + } + + it should "return the stored cover after it is set" in { + resource.setCoverImage(testWid, CoverImageRequest(sampleImage), session(owner)) + resource.getCoverImage(testWid, session(owner)).image shouldBe sampleImage + } + + it should "be readable by a user with read access" in { + resource.setCoverImage(testWid, CoverImageRequest(sampleImage), session(owner)) + resource.getCoverImage(testWid, session(reader)).image shouldBe sampleImage + } + + it should "throw ForbiddenException for a user without read access" in { + resource.setCoverImage(testWid, CoverImageRequest(sampleImage), session(owner)) + assertThrows[ForbiddenException] { + resource.getCoverImage(testWid, session(stranger)) + } + } + + "setCoverImage" should "replace an existing cover (upsert)" in { + resource.setCoverImage(testWid, CoverImageRequest(sampleImage), session(owner)) + val replacement = "data:image/png;base64,iVBORw0KGgo=" + resource.setCoverImage(testWid, CoverImageRequest(replacement), session(owner)) + resource.getCoverImage(testWid, session(owner)).image shouldBe replacement + } + + it should "throw BadRequestException for an empty or blank image" in { + assertThrows[BadRequestException] { + resource.setCoverImage(testWid, CoverImageRequest(""), session(owner)) + } + assertThrows[BadRequestException] { + resource.setCoverImage(testWid, CoverImageRequest(" "), session(owner)) + } + } + + it should "throw BadRequestException for a null image" in { + assertThrows[BadRequestException] { + resource.setCoverImage(testWid, CoverImageRequest(null), session(owner)) + } + } + + it should "throw BadRequestException when the value is not an image data URL" in { + assertThrows[BadRequestException] { + resource.setCoverImage( + testWid, + CoverImageRequest("https://example.com/a.png"), + session(owner) + ) + } + } + + it should "throw BadRequestException when the data URL is too large" in { + val tooLarge = "data:image/jpeg;base64," + ("a" * (4 * 1024 * 1024 + 1)) + assertThrows[BadRequestException] { + resource.setCoverImage(testWid, CoverImageRequest(tooLarge), session(owner)) + } + } + + it should "throw ForbiddenException for a user with only read access" in { + assertThrows[ForbiddenException] { + resource.setCoverImage(testWid, CoverImageRequest(sampleImage), session(reader)) + } + } + + it should "not persist a cover when validation fails" in { + assertThrows[BadRequestException] { + resource.setCoverImage(testWid, CoverImageRequest("not-a-data-url"), session(owner)) + } + assertThrows[NotFoundException] { + resource.getCoverImage(testWid, session(owner)) + } + } + + "deleteCoverImage" should "remove an existing cover" in { + resource.setCoverImage(testWid, CoverImageRequest(sampleImage), session(owner)) + resource.deleteCoverImage(testWid, session(owner)) + assertThrows[NotFoundException] { + resource.getCoverImage(testWid, session(owner)) + } + } + + it should "be idempotent when no cover is set" in { + noException should be thrownBy resource.deleteCoverImage(testWid, session(owner)) + } + + it should "throw ForbiddenException for a user with only read access" in { + resource.setCoverImage(testWid, CoverImageRequest(sampleImage), session(owner)) + assertThrows[ForbiddenException] { + resource.deleteCoverImage(testWid, session(reader)) + } + // The cover must still be present after the rejected delete. + resource.getCoverImage(testWid, session(owner)).image shouldBe sampleImage + } +} diff --git a/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.html b/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.html index f44f25597ec..a457f6e10f4 100644 --- a/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.html +++ b/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.html @@ -36,6 +36,39 @@ [(ngModel)]="entry.checked" (ngModelChange)="onCheckboxChange(entry)"> + +
+ + + +
= {}): DashboardEntry { @@ -69,16 +71,23 @@ describe("CardItemComponent", () => { let component: CardItemComponent; let fixture: ComponentFixture; let workflowPersistService: Mocked; + let workflowCoverService: Mocked; let datasetService: Mocked; beforeEach(async () => { const workflowPersistServiceSpy = { updateWorkflowName: vi.fn(), updateWorkflowDescription: vi.fn() }; + const workflowCoverServiceSpy = { + getCover: vi.fn().mockReturnValue(of(undefined)), + setCoverFromFile: vi.fn(), + clearCover: vi.fn().mockReturnValue(of(undefined)), + }; const datasetServiceSpy = { getDatasetCoverUrl: vi.fn() }; await TestBed.configureTestingModule({ imports: [CardItemComponent, HttpClientTestingModule, BrowserAnimationsModule, RouterTestingModule], providers: [ { provide: WorkflowPersistService, useValue: workflowPersistServiceSpy }, + { provide: WorkflowCoverService, useValue: workflowCoverServiceSpy }, { provide: DatasetService, useValue: datasetServiceSpy }, { provide: UserService, useClass: StubUserService }, NzModalService, @@ -89,6 +98,7 @@ describe("CardItemComponent", () => { fixture = TestBed.createComponent(CardItemComponent); component = fixture.componentInstance; workflowPersistService = TestBed.inject(WorkflowPersistService) as unknown as Mocked; + workflowCoverService = TestBed.inject(WorkflowCoverService) as unknown as Mocked; datasetService = TestBed.inject(DatasetService) as unknown as Mocked; component.entry = makeWorkflowEntry(); fixture.detectChanges(); @@ -179,6 +189,103 @@ describe("CardItemComponent", () => { expect(spy).toHaveBeenCalledTimes(1); }); + it("should show cover controls only for an owned workflow in private search", () => { + component.isPrivateSearch = true; + component.entry = makeWorkflowEntry({ workflow: { isOwner: true } } as any); + expect(component.canEditCover).toBe(true); + + component.entry = makeWorkflowEntry({ workflow: { isOwner: false } } as any); + expect(component.canEditCover).toBe(false); + + component.entry = makeWorkflowEntry({ workflow: { isOwner: true } } as any); + component.isPrivateSearch = false; + expect(component.canEditCover).toBe(false); + }); + + it("should load the stored cover on initialization and use it as the preview image", () => { + const cover = "data:image/jpeg;base64,abc"; + workflowCoverService.getCover.mockReturnValue(of(cover)); + component.entry = makeWorkflowEntry({ id: 7 }); + + component.ngOnChanges({ entry: { currentValue: component.entry } as any }); + + expect(workflowCoverService.getCover).toHaveBeenCalledWith(7); + expect(component.hasCustomImage).toBe(true); + expect(component.previewImage).toBe(cover); + }); + + it("should fall back to the default preview image when no cover is set", () => { + workflowCoverService.getCover.mockReturnValue(of(undefined)); + component.entry = makeWorkflowEntry({ id: 7 }); + + component.ngOnChanges({ entry: { currentValue: component.entry } as any }); + + expect(component.hasCustomImage).toBe(false); + expect(component.previewImage).toBe(CardItemComponent.DEFAULT_PREVIEW_IMAGE); + }); + + it("should upload a selected image and use the returned data URL as the cover", async () => { + const dataUrl = "data:image/jpeg;base64,xyz"; + workflowCoverService.setCoverFromFile.mockResolvedValue(dataUrl); + component.entry = makeWorkflowEntry({ id: 7 }); + const file = new File(["x"], "pic.png", { type: "image/png" }); + + await component.onImageSelected({ target: { files: [file], value: "pic.png" } } as any); + + expect(workflowCoverService.setCoverFromFile).toHaveBeenCalledWith(7, file); + expect(component.previewImage).toBe(dataUrl); + expect(component.hasCustomImage).toBe(true); + }); + + it("should reject a non-image file and not upload it", async () => { + const notificationService = TestBed.inject(NotificationService); + const errorSpy = vi.spyOn(notificationService, "error"); + const file = new File(["x"], "notes.txt", { type: "text/plain" }); + + await component.onImageSelected({ target: { files: [file], value: "notes.txt" } } as any); + + expect(workflowCoverService.setCoverFromFile).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalled(); + }); + + it("should notify on upload failure and keep the previous preview image", async () => { + const notificationService = TestBed.inject(NotificationService); + const errorSpy = vi.spyOn(notificationService, "error"); + workflowCoverService.setCoverFromFile.mockRejectedValue(new Error("boom")); + component.entry = makeWorkflowEntry({ id: 7 }); + const file = new File(["x"], "pic.png", { type: "image/png" }); + + await component.onImageSelected({ target: { files: [file], value: "pic.png" } } as any); + + expect(errorSpy).toHaveBeenCalled(); + expect(component.previewImage).toBe(CardItemComponent.DEFAULT_PREVIEW_IMAGE); + }); + + it("should clear the cover and revert to the default image on reset", () => { + workflowCoverService.clearCover.mockReturnValue(of(undefined)); + component.entry = makeWorkflowEntry({ id: 7 }); + (component as any).customImage = "data:image/jpeg;base64,abc"; + + component.resetImage(); + + expect(workflowCoverService.clearCover).toHaveBeenCalledWith(7); + expect(component.hasCustomImage).toBe(false); + expect(component.previewImage).toBe(CardItemComponent.DEFAULT_PREVIEW_IMAGE); + }); + + it("should notify and keep the cover when reset fails", () => { + const notificationService = TestBed.inject(NotificationService); + const errorSpy = vi.spyOn(notificationService, "error"); + workflowCoverService.clearCover.mockReturnValue(throwError(() => new Error("boom"))); + component.entry = makeWorkflowEntry({ id: 7 }); + (component as any).customImage = "data:image/jpeg;base64,abc"; + + component.resetImage(); + + expect(errorSpy).toHaveBeenCalled(); + expect(component.hasCustomImage).toBe(true); + }); + it("should load the dataset cover into the preview when the entry has a cover", () => { datasetService.getDatasetCoverUrl.mockReturnValue(of({ url: "https://cover.example/img.png" })); component.entry = makeDatasetEntry({ id: 5, coverImageUrl: "cover/path.png" }); diff --git a/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.ts b/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.ts index 14f43f3ab46..9d7f6cb54fd 100644 --- a/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.ts +++ b/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.ts @@ -55,6 +55,7 @@ import { formatSize } from "src/app/common/util/size-formatter.util"; import { formatRelativeTime, formatCount } from "src/app/common/util/format.util"; import { DatasetService, DEFAULT_DATASET_NAME } from "../../../../service/user/dataset/dataset.service"; import { NotificationService } from "../../../../../common/service/notification/notification.service"; +import { WorkflowCoverService } from "../../../../service/user/workflow-cover/workflow-cover.service"; import { HUB_DATASET_RESULT_DETAIL, HUB_WORKFLOW_RESULT_DETAIL, @@ -108,6 +109,10 @@ export class CardItemComponent implements OnChanges { /** Resolved preview/cover image; stays the placeholder until a dataset cover loads. */ coverImageSrc: string = CardItemComponent.DEFAULT_PREVIEW_IMAGE; + /** The workflow's custom cover image data URL, if one has been set. */ + private customImage?: string; + @ViewChild("backgroundInput") backgroundInput!: ElementRef; + @Input() get entry(): DashboardEntry { if (!this._entry) { @@ -133,9 +138,66 @@ export class CardItemComponent implements OnChanges { private hubService: HubService, private downloadService: DownloadService, private cdr: ChangeDetectorRef, - private notificationService: NotificationService + private notificationService: NotificationService, + private workflowCoverService: WorkflowCoverService ) {} + /** The top image src: the user's custom cover if present, otherwise the default. */ + get previewImage(): string { + return this.customImage ?? CardItemComponent.DEFAULT_PREVIEW_IMAGE; + } + + get hasCustomImage(): boolean { + return this.customImage !== undefined; + } + + /** Whether the cover-image controls are shown: an editable workflow in private search. */ + get canEditCover(): boolean { + return this.isPrivateSearch && this.entry.type === "workflow" && this.entry.workflow.isOwner; + } + + openImagePicker(): void { + this.backgroundInput?.nativeElement.click(); + } + + async onImageSelected(event: Event): Promise { + const input = event.target as HTMLInputElement; + const file = input.files?.[0]; + input.value = ""; // allow re-selecting the same file + if (!file) { + return; + } + if (!file.type.startsWith("image/")) { + this.notificationService.error("Please choose an image file."); + return; + } + if (typeof this.entry.id !== "number") { + return; + } + try { + this.customImage = await this.workflowCoverService.setCoverFromFile(this.entry.id, file); + this.cdr.markForCheck(); + } catch (e) { + this.notificationService.error("Failed to set the cover image."); + } + } + + resetImage(): void { + if (typeof this.entry.id !== "number") { + return; + } + this.workflowCoverService + .clearCover(this.entry.id) + .pipe(untilDestroyed(this)) + .subscribe({ + next: () => { + this.customImage = undefined; + this.cdr.markForCheck(); + }, + error: () => this.notificationService.error("Failed to reset the cover image."), + }); + } + initializeEntry() { if (this.entry.type === "workflow") { if (typeof this.entry.id === "number") { @@ -147,6 +209,13 @@ export class CardItemComponent implements OnChanges { this.entryLink = [HUB_WORKFLOW_RESULT_DETAIL, String(this.entry.id)]; } this.size = this.entry.size; + this.workflowCoverService + .getCover(this.entry.id) + .pipe(untilDestroyed(this)) + .subscribe(image => { + this.customImage = image; + this.cdr.markForCheck(); + }); } this.iconType = "project"; } else if (this.entry.type === "project") { diff --git a/frontend/src/app/dashboard/service/user/workflow-cover/workflow-cover.service.spec.ts b/frontend/src/app/dashboard/service/user/workflow-cover/workflow-cover.service.spec.ts new file mode 100644 index 00000000000..751d9782895 --- /dev/null +++ b/frontend/src/app/dashboard/service/user/workflow-cover/workflow-cover.service.spec.ts @@ -0,0 +1,87 @@ +/** + * 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. + */ + +import { TestBed } from "@angular/core/testing"; +import { HttpClientTestingModule, HttpTestingController } from "@angular/common/http/testing"; +import { WorkflowCoverService } from "./workflow-cover.service"; +import { AppSettings } from "../../../../common/app-setting"; + +describe("WorkflowCoverService", () => { + let service: WorkflowCoverService; + let httpMock: HttpTestingController; + const coverUrl = (wid: number) => `${AppSettings.getApiEndpoint()}/workflow/${wid}/cover`; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [WorkflowCoverService], + }); + service = TestBed.inject(WorkflowCoverService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + }); + + it("getCover returns the stored image data URL", () => { + let result: string | undefined; + service.getCover(7).subscribe(image => (result = image)); + const req = httpMock.expectOne(coverUrl(7)); + expect(req.request.method).toBe("GET"); + req.flush({ image: "data:image/jpeg;base64,abc" }); + expect(result).toBe("data:image/jpeg;base64,abc"); + }); + + it("getCover resolves to undefined when no cover exists (404)", () => { + let result: string | undefined = "unset"; + service.getCover(7).subscribe(image => (result = image)); + httpMock.expectOne(coverUrl(7)).flush(null, { status: 404, statusText: "Not Found" }); + expect(result).toBeUndefined(); + }); + + it("clearCover issues a DELETE", () => { + let completed = false; + service.clearCover(7).subscribe(() => (completed = true)); + const req = httpMock.expectOne(coverUrl(7)); + expect(req.request.method).toBe("DELETE"); + req.flush(null); + expect(completed).toBe(true); + }); + + it("setCoverFromFile PUTs the resized data URL and resolves with it", async () => { + const dataUrl = "data:image/jpeg;base64,resized"; + // The resize step relies on canvas/Image decoding, which jsdom cannot run; + // stub it so the test exercises the upload wiring deterministically. + (service as any).fileToResizedDataUrl = vi.fn().mockResolvedValue(dataUrl); + const file = new File(["x"], "pic.png", { type: "image/png" }); + + const resultPromise = service.setCoverFromFile(7, file); + // Let the stubbed resize promise settle so the HTTP request is issued. + await Promise.resolve(); + await Promise.resolve(); + + const req = httpMock.expectOne(coverUrl(7)); + expect(req.request.method).toBe("PUT"); + expect(req.request.body).toEqual({ image: dataUrl }); + req.flush(null); + + await expect(resultPromise).resolves.toBe(dataUrl); + }); +}); diff --git a/frontend/src/app/dashboard/service/user/workflow-cover/workflow-cover.service.ts b/frontend/src/app/dashboard/service/user/workflow-cover/workflow-cover.service.ts new file mode 100644 index 00000000000..42cb070538b --- /dev/null +++ b/frontend/src/app/dashboard/service/user/workflow-cover/workflow-cover.service.ts @@ -0,0 +1,87 @@ +/** + * 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. + */ + +import { HttpClient } from "@angular/common/http"; +import { Injectable } from "@angular/core"; +import { Observable, firstValueFrom, of } from "rxjs"; +import { catchError, map } from "rxjs/operators"; +import { AppSettings } from "../../../../common/app-setting"; + +export const WORKFLOW_COVER_URL = "workflow"; + +/** Longest edge (px) a custom cover image is downscaled to before being stored. */ +const MAX_IMAGE_EDGE = 640; +/** JPEG quality used when re-encoding a custom cover image for storage. */ +const IMAGE_QUALITY = 0.8; + +/** Stores an optional custom cover image per workflow on the backend, downscaled and re-encoded as a JPEG data URL. */ +@Injectable({ + providedIn: "root", +}) +export class WorkflowCoverService { + constructor(private http: HttpClient) {} + + /** The workflow's custom cover image data URL, or undefined if it has none. */ + getCover(wid: number): Observable { + return this.http.get<{ image: string }>(`${AppSettings.getApiEndpoint()}/${WORKFLOW_COVER_URL}/${wid}/cover`).pipe( + map(response => response.image), + catchError(() => of(undefined)) + ); + } + + /** Downscales/re-encodes the chosen image, stores it as the workflow's cover, and resolves with the data URL. */ + async setCoverFromFile(wid: number, file: File): Promise { + const dataUrl = await this.fileToResizedDataUrl(file); + await firstValueFrom( + this.http.put(`${AppSettings.getApiEndpoint()}/${WORKFLOW_COVER_URL}/${wid}/cover`, { image: dataUrl }) + ); + return dataUrl; + } + + /** Removes the workflow's custom cover image. */ + clearCover(wid: number): Observable { + return this.http.delete(`${AppSettings.getApiEndpoint()}/${WORKFLOW_COVER_URL}/${wid}/cover`); + } + + private fileToResizedDataUrl(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onerror = () => reject(new Error("Failed to read the selected image.")); + reader.onload = () => { + const img = new Image(); + img.onerror = () => reject(new Error("The selected file is not a valid image.")); + img.onload = () => { + const scale = Math.min(1, MAX_IMAGE_EDGE / Math.max(img.width, img.height)); + const canvas = document.createElement("canvas"); + canvas.width = Math.max(1, Math.round(img.width * scale)); + canvas.height = Math.max(1, Math.round(img.height * scale)); + const ctx = canvas.getContext("2d"); + if (!ctx) { + reject(new Error("Unable to process the selected image.")); + return; + } + ctx.drawImage(img, 0, 0, canvas.width, canvas.height); + resolve(canvas.toDataURL("image/jpeg", IMAGE_QUALITY)); + }; + img.src = reader.result as string; + }; + reader.readAsDataURL(file); + }); + } +} diff --git a/sql/changelog.xml b/sql/changelog.xml index e216caf3d00..ce7d50ff3ec 100644 --- a/sql/changelog.xml +++ b/sql/changelog.xml @@ -38,6 +38,11 @@ + + + + +