This is an automated email from the ASF dual-hosted git repository.

kunwp1 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/texera.git


The following commit(s) were added to refs/heads/main by this push:
     new 4d3c788b81 feat: prevent export of results from non-downloadable 
datasets (#3772)
4d3c788b81 is described below

commit 4d3c788b8141435ba36fe2d229be813e2ef83772
Author: Seongjin Yoon <[email protected]>
AuthorDate: Fri Oct 10 15:21:41 2025 -0700

    feat: prevent export of results from non-downloadable datasets (#3772)
    
    ### Description:
    Implemented restriction on `export result` to prevent users from
    exporting workflow results that depend on non-downloadable datasets they
    don't own. This ensures dataset download cannot be circumvented through
    workflow execution and result export.
    
    Closes #3766
    
    ### Changes:
    **Backend**
    - Added server-side validation to analyze workflow dependencies and
    block export of operators that depend on non-downloadable datasets
    - Implemented algorithm to propagate restrictions to downstream
    operators
    
    **Frontend**
    - Updated export dialog component to show restriction warnings, filter
    exportable operators, and display blocking dataset
      information
    
    ### Video:
    The video demonstrates how `export result` behaves on:
    - workflows with downloadable datasets
    - workflows with non-downloadable datasets
    - workflows with both downloadable and non-downloadable datasets
    
    
    
https://github.com/user-attachments/assets/56b78aeb-dbcc-40fc-89b4-9c4238f8bc56
    
    ---------
    
    Signed-off-by: Seongjin Yoon 
<[email protected]>
    Co-authored-by: Seongjin Yoon <[email protected]>
    Co-authored-by: Xinyuan Lin <[email protected]>
    Co-authored-by: Seongjin Yoon <[email protected]>
    Co-authored-by: Seongjin Yoon <[email protected]>
    Co-authored-by: Seongjin Yoon <[email protected]>
    Co-authored-by: Jiadong Bai <[email protected]>
---
 .../user/workflow/WorkflowExecutionsResource.scala | 195 +++++++++++++++-
 .../service/user/download/download.service.ts      |  17 ++
 .../result-exportation.component.html              | 248 +++++++++++----------
 .../result-exportation.component.ts                | 106 +++++++--
 .../workflow-result-export.service.spec.ts         |  25 ++-
 .../workflow-result-export.service.ts              | 235 ++++++++++++++++---
 .../uci/ics/amber/core/storage/FileResolver.scala  |  54 ++++-
 7 files changed, 703 insertions(+), 177 deletions(-)

diff --git 
a/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/user/workflow/WorkflowExecutionsResource.scala
 
b/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/user/workflow/WorkflowExecutionsResource.scala
index 24433b76cd..7192f7f3fd 100644
--- 
a/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/user/workflow/WorkflowExecutionsResource.scala
+++ 
b/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/user/workflow/WorkflowExecutionsResource.scala
@@ -19,7 +19,12 @@
 
 package edu.uci.ics.texera.web.resource.dashboard.user.workflow
 
-import edu.uci.ics.amber.core.storage.{DocumentFactory, VFSResourceType, 
VFSURIFactory}
+import edu.uci.ics.amber.core.storage.{
+  DocumentFactory,
+  FileResolver,
+  VFSResourceType,
+  VFSURIFactory
+}
 import edu.uci.ics.amber.core.tuple.Tuple
 import edu.uci.ics.amber.core.virtualidentity._
 import edu.uci.ics.amber.core.workflow.{GlobalPortIdentity, PortIdentity}
@@ -27,12 +32,13 @@ import 
edu.uci.ics.amber.engine.architecture.logreplay.{ReplayDestination, Repla
 import edu.uci.ics.amber.engine.common.Utils.{maptoStatusCode, 
stringToAggregatedState}
 import edu.uci.ics.amber.engine.common.storage.SequentialRecordStorage
 import edu.uci.ics.amber.util.serde.GlobalPortIdentitySerde.SerdeOps
+import edu.uci.ics.amber.util.JSONUtils.objectMapper
 import edu.uci.ics.texera.auth.SessionUser
 import edu.uci.ics.texera.dao.SqlServer
 import edu.uci.ics.texera.dao.SqlServer.withTransaction
 import edu.uci.ics.texera.dao.jooq.generated.Tables._
 import edu.uci.ics.texera.dao.jooq.generated.tables.daos.WorkflowExecutionsDao
-import edu.uci.ics.texera.dao.jooq.generated.tables.pojos.WorkflowExecutions
+import edu.uci.ics.texera.dao.jooq.generated.tables.pojos.{User => UserPojo, 
WorkflowExecutions}
 import edu.uci.ics.texera.web.model.http.request.result.ResultExportRequest
 import 
edu.uci.ics.texera.web.resource.dashboard.user.workflow.WorkflowExecutionsResource._
 import edu.uci.ics.texera.web.service.{ExecutionsMetadataPersistService, 
ResultExportService}
@@ -100,6 +106,131 @@ object WorkflowExecutionsResource {
     }
   }
 
+  /**
+    * Computes which operators in a workflow are restricted due to dataset 
access controls.
+    *
+    * This function:
+    * 1. Parses the workflow JSON to find all operators and their dataset 
dependencies
+    * 2. Identifies operators using non-downloadable datasets that the user 
doesn't own
+    * 3. Uses BFS to propagate restrictions through the workflow graph
+    * 4. Returns a map of operator IDs to the restricted datasets they depend 
on
+    *
+    * @param wid The workflow ID
+    * @param currentUser The current user making the export request
+    * @return Map of operator ID -> Set of (ownerEmail, datasetName) tuples 
that block its export
+    */
+  private def getNonDownloadableOperatorMap(
+      wid: Int,
+      currentUser: UserPojo
+  ): Map[String, Set[(String, String)]] = {
+    // Load workflow
+    val workflowRecord = context
+      .select(WORKFLOW.CONTENT)
+      .from(WORKFLOW)
+      
.where(WORKFLOW.WID.eq(wid).and(WORKFLOW.CONTENT.isNotNull).and(WORKFLOW.CONTENT.ne("")))
+      .fetchOne()
+
+    if (workflowRecord == null) {
+      return Map.empty
+    }
+
+    val content = workflowRecord.value1()
+
+    val rootNode =
+      try {
+        objectMapper.readTree(content)
+      } catch {
+        case _: Exception => return Map.empty
+      }
+
+    val operatorsNode = rootNode.path("operators")
+    val linksNode = rootNode.path("links")
+
+    // Collect all datasets used by operators (that user doesn't own)
+    val operatorDatasets = mutable.Map.empty[String, (String, String)]
+
+    operatorsNode.elements().asScala.foreach { operatorNode =>
+      val operatorId = operatorNode.path("operatorID").asText("")
+      if (operatorId.nonEmpty) {
+        val fileNameNode = 
operatorNode.path("operatorProperties").path("fileName")
+        if (fileNameNode.isTextual) {
+          FileResolver.parseDatasetOwnerAndName(fileNameNode.asText()).foreach 
{
+            case (ownerEmail, datasetName) =>
+              val isOwner =
+                Option(currentUser.getEmail)
+                  .exists(_.equalsIgnoreCase(ownerEmail))
+              if (!isOwner) {
+                operatorDatasets.update(operatorId, (ownerEmail, datasetName))
+              }
+          }
+        }
+      }
+    }
+
+    if (operatorDatasets.isEmpty) {
+      return Map.empty
+    }
+
+    // Query all datasets
+    val uniqueDatasets = operatorDatasets.values.toSet
+    val conditions = uniqueDatasets.map {
+      case (ownerEmail, datasetName) =>
+        
USER.EMAIL.equalIgnoreCase(ownerEmail).and(DATASET.NAME.equalIgnoreCase(datasetName))
+    }
+
+    val nonDownloadableDatasets = context
+      .select(USER.EMAIL, DATASET.NAME)
+      .from(DATASET)
+      .join(USER)
+      .on(DATASET.OWNER_UID.eq(USER.UID))
+      .where(conditions.reduce((a, b) => a.or(b)))
+      .and(DATASET.IS_DOWNLOADABLE.eq(false))
+      .fetch()
+      .asScala
+      .map(record => (record.value1(), record.value2()))
+      .toSet
+
+    // Filter to only operators with non-downloadable datasets
+    val restrictedSourceMap = operatorDatasets.filter {
+      case (_, dataset) =>
+        nonDownloadableDatasets.contains(dataset)
+    }
+
+    // Build dependency graph
+    val adjacency = mutable.Map.empty[String, mutable.ListBuffer[String]]
+
+    linksNode.elements().asScala.foreach { linkNode =>
+      val sourceId = linkNode.path("source").path("operatorID").asText("")
+      val targetId = linkNode.path("target").path("operatorID").asText("")
+      if (sourceId.nonEmpty && targetId.nonEmpty) {
+        adjacency.getOrElseUpdate(sourceId, mutable.ListBuffer.empty[String]) 
+= targetId
+      }
+    }
+
+    // BFS to propagate restrictions
+    val restrictionMap = mutable.Map.empty[String, Set[(String, String)]]
+    val queue = mutable.Queue.empty[(String, Set[(String, String)])]
+
+    restrictedSourceMap.foreach {
+      case (operatorId, dataset) =>
+        queue.enqueue(operatorId -> Set(dataset))
+    }
+
+    while (queue.nonEmpty) {
+      val (currentOperatorId, datasetSet) = queue.dequeue()
+      val existing = restrictionMap.getOrElse(currentOperatorId, Set.empty)
+      val merged = existing ++ datasetSet
+      if (merged != existing) {
+        restrictionMap.update(currentOperatorId, merged)
+        adjacency
+          .get(currentOperatorId)
+          .foreach(_.foreach(nextOperator => queue.enqueue(nextOperator -> 
merged)))
+      }
+    }
+
+    restrictionMap.toMap
+  }
+
   def insertOperatorPortResultUri(
       eid: ExecutionIdentity,
       globalPortId: GlobalPortIdentity,
@@ -660,6 +791,37 @@ class WorkflowExecutionsResource {
     executionsDao.update(execution)
   }
 
+  /**
+    * Returns which operators are restricted from export due to dataset access 
controls.
+    * This endpoint allows the frontend to check restrictions before 
attempting export.
+    *
+    * @param wid The workflow ID to check
+    * @param user The authenticated user
+    * @return JSON map of operator ID -> array of {ownerEmail, datasetName} 
that block its export
+    */
+  @GET
+  @Path("/{wid}/result/downloadability")
+  @Produces(Array(MediaType.APPLICATION_JSON))
+  @RolesAllowed(Array("REGULAR", "ADMIN"))
+  def getWorkflowResultDownloadability(
+      @PathParam("wid") wid: Integer,
+      @Auth user: SessionUser
+  ): Response = {
+    validateUserCanAccessWorkflow(user.getUser.getUid, wid)
+
+    val datasetRestrictions = getNonDownloadableOperatorMap(wid, user.user)
+
+    // Convert to frontend-friendly format: Map[operatorId -> 
Array[datasetLabel]]
+    val restrictionMap = datasetRestrictions.map {
+      case (operatorId, datasets) =>
+        operatorId -> datasets.map {
+          case (ownerEmail, datasetName) => s"$datasetName ($ownerEmail)"
+        }.toArray
+    }.asJava
+
+    Response.ok(restrictionMap).build()
+  }
+
   @POST
   @Path("/result/export")
   @RolesAllowed(Array("REGULAR", "ADMIN"))
@@ -675,6 +837,35 @@ class WorkflowExecutionsResource {
         .entity(Map("error" -> "No operator selected").asJava)
         .build()
 
+    // Get ALL non-downloadable in workflow
+    val datasetRestrictions = 
getNonDownloadableOperatorMap(request.workflowId, user.user)
+    // Filter to only user's selection
+    val restrictedOperators = request.operators.filter(op => 
datasetRestrictions.contains(op.id))
+    // Check if any selected operator is restricted
+    if (restrictedOperators.nonEmpty) {
+      val restrictedDatasets = restrictedOperators.flatMap { op =>
+        datasetRestrictions(op.id).map {
+          case (ownerEmail, datasetName) =>
+            Map(
+              "operatorId" -> op.id,
+              "ownerEmail" -> ownerEmail,
+              "datasetName" -> datasetName
+            ).asJava
+        }
+      }
+
+      return Response
+        .status(Response.Status.FORBIDDEN)
+        .`type`(MediaType.APPLICATION_JSON)
+        .entity(
+          Map(
+            "error" -> "Export blocked due to dataset restrictions",
+            "restrictedDatasets" -> restrictedDatasets.asJava
+          ).asJava
+        )
+        .build()
+    }
+
     try {
       request.destination match {
         case "local" =>
diff --git 
a/core/gui/src/app/dashboard/service/user/download/download.service.ts 
b/core/gui/src/app/dashboard/service/user/download/download.service.ts
index f02a2411b5..aafb14a44c 100644
--- a/core/gui/src/app/dashboard/service/user/download/download.service.ts
+++ b/core/gui/src/app/dashboard/service/user/download/download.service.ts
@@ -33,6 +33,7 @@ import { DashboardWorkflowComputingUnit } from 
"../../../../workspace/types/work
 var contentDisposition = require("content-disposition");
 
 export const EXPORT_BASE_URL = "result/export";
+export const DOWNLOADABILITY_BASE_URL = "result/downloadability";
 
 interface DownloadableItem {
   blob: Blob;
@@ -44,6 +45,10 @@ export interface ExportWorkflowJsonResponse {
   message: string;
 }
 
+export interface WorkflowResultDownloadabilityResponse {
+  [operatorId: string]: string[]; // operatorId -> array of dataset labels 
blocking export
+}
+
 @Injectable({
   providedIn: "root",
 })
@@ -115,6 +120,18 @@ export class DownloadService {
     );
   }
 
+  /**
+   * Retrieves workflow result downloadability information from the backend.
+   * Returns a map of operator IDs to arrays of dataset labels that block 
their export.
+   *
+   * @param workflowId The workflow ID to check
+   * @returns Observable of downloadability information
+   */
+  public getWorkflowResultDownloadability(workflowId: number): 
Observable<WorkflowResultDownloadabilityResponse> {
+    const urlPath = 
`${WORKFLOW_EXECUTIONS_API_BASE_URL}/${workflowId}/${DOWNLOADABILITY_BASE_URL}`;
+    return this.http.get<WorkflowResultDownloadabilityResponse>(urlPath);
+  }
+
   /**
    * Export the workflow result. If destination = "local", the server returns 
a BLOB (file).
    * Otherwise, it returns JSON with a status message.
diff --git 
a/core/gui/src/app/workspace/component/result-exportation/result-exportation.component.html
 
b/core/gui/src/app/workspace/component/result-exportation/result-exportation.component.html
index 756e5f5411..1624a53a04 100644
--- 
a/core/gui/src/app/workspace/component/result-exportation/result-exportation.component.html
+++ 
b/core/gui/src/app/workspace/component/result-exportation/result-exportation.component.html
@@ -18,126 +18,144 @@
 -->
 
 <div class="centered-container">
-  <div class="input-wrapper">
-    <form nz-form>
-      <nz-row *ngIf="exportType !== 'data'">
-        <nz-col [nzSpan]="24">
-          <nz-form-item>
-            <nz-form-label nzFor="exportTypeInput">Export Type</nz-form-label>
-            <nz-form-control>
-              <nz-select
-                id="exportTypeInput"
-                [(ngModel)]="exportType"
-                name="exportType"
-                nzPlaceHolder="Select export type">
-                <nz-option
-                  *ngIf="isTableOutput"
-                  nzValue="arrow"
-                  nzLabel="Binary Format (.arrow)"></nz-option>
-                <nz-option
-                  *ngIf="isTableOutput && !containsBinaryData"
-                  nzValue="csv"
-                  nzLabel="Comma Separated Values (.csv)"></nz-option>
-                <nz-option
-                  *ngIf="isVisualizationOutput"
-                  nzValue="html"
-                  nzLabel="Web Page (.html)"></nz-option>
-                <nz-option
-                  nzValue="parquet"
-                  nzLabel="Parquet (.parquet)"></nz-option>
-              </nz-select>
-            </nz-form-control>
-          </nz-form-item>
-        </nz-col>
-      </nz-row>
-      <nz-row *ngIf="exportType === 'data'">
-        <nz-col [nzSpan]="24">
-          <nz-form-item>
-            <nz-form-label nzFor="filenameInput">Filename</nz-form-label>
-            <nz-form-control>
-              <input
-                id="filenameInput"
-                [(ngModel)]="inputFileName"
-                name="filename"
-                type="text"
-                nz-input
-                placeholder="Enter filename for binary data" />
-            </nz-form-control>
-          </nz-form-item>
-        </nz-col>
-      </nz-row>
-      <nz-row>
-        <nz-col [nzSpan]="24">
-          <nz-form-item>
-            <nz-form-label nzFor="destinationInput">Destination</nz-form-label>
-            <nz-form-control>
-              <nz-select
-                id="destinationInput"
-                [(ngModel)]="destination"
-                name="destination"
-                nzPlaceHolder="Select destination">
-                <nz-option
-                  nzValue="dataset"
-                  nzLabel="Dataset"></nz-option>
-                <nz-option
-                  nzValue="local"
-                  nzLabel="Local"></nz-option>
-              </nz-select>
-            </nz-form-control>
-          </nz-form-item>
-        </nz-col>
-      </nz-row>
-    </form>
-  </div>
   <div
     class="input-wrapper"
-    *ngIf="destination === 'dataset'">
-    <input
-      [(ngModel)]="inputDatasetName"
-      (input)="onUserInputDatasetName($event)"
-      type="text"
-      nz-input
-      name="datasetName"
-      placeholder="Search for dataset by name..."
-      [nzAutocomplete]="auto" />
-    <nz-autocomplete #auto>
-      <nz-auto-option
-        *ngFor="let dataset of filteredUserAccessibleDatasets"
-        [nzLabel]="dataset.dataset.name">
-        <div class="auto-option-content">
-          <div 
class="dataset-id-container">{{dataset.dataset.did?.toString()}}</div>
+    *ngIf="hasPartialNonDownloadable && blockingDatasetLabels.length > 0">
+    <nz-alert
+      nzType="warning"
+      nzMessage="Some operators will be skipped"
+      [nzDescription]="blockingDatasetSummary || null"></nz-alert>
+  </div>
+  <ng-container *ngIf="!isExportRestricted; else restrictedExport">
+    <div class="input-wrapper">
+      <form nz-form>
+        <nz-row *ngIf="exportType !== 'data'">
+          <nz-col [nzSpan]="24">
+            <nz-form-item>
+              <nz-form-label nzFor="exportTypeInput">Export 
Type</nz-form-label>
+              <nz-form-control>
+                <nz-select
+                  id="exportTypeInput"
+                  [(ngModel)]="exportType"
+                  name="exportType"
+                  nzPlaceHolder="Select export type">
+                  <nz-option
+                    *ngIf="isTableOutput"
+                    nzValue="arrow"
+                    nzLabel="Binary Format (.arrow)"></nz-option>
+                  <nz-option
+                    *ngIf="isTableOutput && !containsBinaryData"
+                    nzValue="csv"
+                    nzLabel="Comma Separated Values (.csv)"></nz-option>
+                  <nz-option
+                    *ngIf="isVisualizationOutput"
+                    nzValue="html"
+                    nzLabel="Web Page (.html)"></nz-option>
+                  <nz-option
+                    nzValue="parquet"
+                    nzLabel="Parquet (.parquet)"></nz-option>
+                </nz-select>
+              </nz-form-control>
+            </nz-form-item>
+          </nz-col>
+        </nz-row>
+        <nz-row *ngIf="exportType === 'data'">
+          <nz-col [nzSpan]="24">
+            <nz-form-item>
+              <nz-form-label nzFor="filenameInput">Filename</nz-form-label>
+              <nz-form-control>
+                <input
+                  id="filenameInput"
+                  [(ngModel)]="inputFileName"
+                  name="filename"
+                  type="text"
+                  nz-input
+                  placeholder="Enter filename for binary data" />
+              </nz-form-control>
+            </nz-form-item>
+          </nz-col>
+        </nz-row>
+        <nz-row>
+          <nz-col [nzSpan]="24">
+            <nz-form-item>
+              <nz-form-label 
nzFor="destinationInput">Destination</nz-form-label>
+              <nz-form-control>
+                <nz-select
+                  id="destinationInput"
+                  [(ngModel)]="destination"
+                  name="destination"
+                  nzPlaceHolder="Select destination">
+                  <nz-option
+                    nzValue="dataset"
+                    nzLabel="Dataset"></nz-option>
+                  <nz-option
+                    nzValue="local"
+                    nzLabel="Local"></nz-option>
+                </nz-select>
+              </nz-form-control>
+            </nz-form-item>
+          </nz-col>
+        </nz-row>
+      </form>
+    </div>
+    <div
+      class="input-wrapper"
+      *ngIf="destination === 'dataset'">
+      <input
+        [(ngModel)]="inputDatasetName"
+        (input)="onUserInputDatasetName($event)"
+        type="text"
+        nz-input
+        name="datasetName"
+        placeholder="Search for dataset by name..."
+        [nzAutocomplete]="auto" />
+      <nz-autocomplete #auto>
+        <nz-auto-option
+          *ngFor="let dataset of filteredUserAccessibleDatasets"
+          [nzLabel]="dataset.dataset.name">
+          <div class="auto-option-content">
+            <div 
class="dataset-id-container">{{dataset.dataset.did?.toString()}}</div>
 
-          <span class="dataset-name">{{ dataset.dataset.name }}</span>
+            <span class="dataset-name">{{ dataset.dataset.name }}</span>
 
-          <button
-            nz-button
-            nzType="primary"
-            class="dataset-option-link-btn"
-            (click)="onClickExportResult('dataset', dataset)">
-            Save
-          </button>
-        </div>
-      </nz-auto-option>
-    </nz-autocomplete>
-    <nz-divider
-      nzText="or"
-      nzOrientation="center"></nz-divider>
+            <button
+              nz-button
+              nzType="primary"
+              class="dataset-option-link-btn"
+              (click)="onClickExportResult('dataset', dataset)">
+              Save
+            </button>
+          </div>
+        </nz-auto-option>
+      </nz-autocomplete>
+      <nz-divider
+        nzText="or"
+        nzOrientation="center"></nz-divider>
+      <button
+        nz-button
+        nzType="dashed"
+        nzBlock
+        (click)="onClickCreateNewDataset()">
+        <span
+          nz-icon
+          nzType="plus-circle"></span>
+        Create New Dataset
+      </button>
+    </div>
     <button
       nz-button
-      nzType="dashed"
-      nzBlock
-      (click)="onClickCreateNewDataset()">
-      <span
-        nz-icon
-        nzType="plus-circle"></span>
-      Create New Dataset
+      nzType="default"
+      *ngIf="destination === 'local'"
+      (click)="onClickExportResult( 'local')">
+      Export
     </button>
-  </div>
-  <button
-    nz-button
-    nzType="default"
-    *ngIf="destination === 'local'"
-    (click)="onClickExportResult( 'local')">
-    Export
-  </button>
+  </ng-container>
+  <ng-template #restrictedExport>
+    <div class="input-wrapper">
+      <nz-alert
+        nzType="error"
+        nzMessage="Export unavailable"
+        [nzDescription]="blockingDatasetSummary || null"></nz-alert>
+    </div>
+  </ng-template>
 </div>
diff --git 
a/core/gui/src/app/workspace/component/result-exportation/result-exportation.component.ts
 
b/core/gui/src/app/workspace/component/result-exportation/result-exportation.component.ts
index c053b1545f..4783bd93f2 100644
--- 
a/core/gui/src/app/workspace/component/result-exportation/result-exportation.component.ts
+++ 
b/core/gui/src/app/workspace/component/result-exportation/result-exportation.component.ts
@@ -19,7 +19,10 @@
 
 import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy";
 import { Component, inject, Input, OnInit } from "@angular/core";
-import { WorkflowResultExportService } from 
"../../service/workflow-result-export/workflow-result-export.service";
+import {
+  WorkflowResultExportService,
+  WorkflowResultDownloadability,
+} from "../../service/workflow-result-export/workflow-result-export.service";
 import { DashboardDataset } from 
"../../../dashboard/type/dashboard-dataset.interface";
 import { DatasetService } from 
"../../../dashboard/service/user/dataset/dataset.service";
 import { NZ_MODAL_DATA, NzModalRef, NzModalService } from 
"ng-zorro-antd/modal";
@@ -52,10 +55,65 @@ export class ResultExportationComponent implements OnInit {
   containsBinaryData: boolean = false;
   inputDatasetName = "";
   selectedComputingUnit: DashboardWorkflowComputingUnit | null = null;
+  downloadability?: WorkflowResultDownloadability;
 
   userAccessibleDatasets: DashboardDataset[] = [];
   filteredUserAccessibleDatasets: DashboardDataset[] = [];
 
+  /**
+   * Gets the operator IDs to check for restrictions based on the source 
trigger.
+   * Menu: all operators, Context menu: highlighted operators only
+   */
+  private getOperatorIdsToCheck(): readonly string[] {
+    if (this.sourceTriggered === "menu") {
+      return this.workflowActionService
+        .getTexeraGraph()
+        .getAllOperators()
+        .map(op => op.operatorID);
+    } else {
+      return 
this.workflowActionService.getJointGraphWrapper().getCurrentHighlightedOperatorIDs();
+    }
+  }
+
+  /**
+   * Computed property: operator IDs that can be exported
+   */
+  get exportableOperatorIds(): string[] {
+    if (!this.downloadability) return [];
+    return 
this.downloadability.getExportableOperatorIds(this.getOperatorIdsToCheck());
+  }
+
+  /**
+   * Computed property: operator IDs that are blocked from export
+   */
+  get blockedOperatorIds(): string[] {
+    if (!this.downloadability) return [];
+    return 
this.downloadability.getBlockedOperatorIds(this.getOperatorIdsToCheck());
+  }
+
+  /**
+   * Computed property: whether all selected operators are blocked
+   */
+  get isExportRestricted(): boolean {
+    const operatorIds = this.getOperatorIdsToCheck();
+    return this.exportableOperatorIds.length === 0 && operatorIds.length > 0;
+  }
+
+  /**
+   * Computed property: whether some (but not all) operators are blocked
+   */
+  get hasPartialNonDownloadable(): boolean {
+    return this.exportableOperatorIds.length > 0 && 
this.blockedOperatorIds.length > 0;
+  }
+
+  /**
+   * Computed property: dataset labels that are blocking export
+   */
+  get blockingDatasetLabels(): string[] {
+    if (!this.downloadability) return [];
+    return 
this.downloadability.getBlockingDatasets(this.getOperatorIdsToCheck());
+  }
+
   constructor(
     public workflowResultExportService: WorkflowResultExportService,
     private modalRef: NzModalRef,
@@ -74,7 +132,14 @@ export class ResultExportationComponent implements OnInit {
         this.userAccessibleDatasets = datasets.filter(dataset => 
dataset.accessPrivilege === "WRITE");
         this.filteredUserAccessibleDatasets = [...this.userAccessibleDatasets];
       });
-    this.updateOutputType();
+
+    this.workflowResultExportService
+      .computeRestrictionAnalysis()
+      .pipe(untilDestroyed(this))
+      .subscribe(downloadability => {
+        this.downloadability = downloadability;
+        this.updateOutputType();
+      });
 
     this.computingUnitStatusService
       .getSelectedComputingUnit()
@@ -85,19 +150,12 @@ export class ResultExportationComponent implements OnInit {
   }
 
   updateOutputType(): void {
-    // Determine if the caller of this component is menu or context menu
-    // if its menu then we need to export all operators else we need to export 
only highlighted operators
-
-    let operatorIds: readonly string[];
-    if (this.sourceTriggered === "menu") {
-      operatorIds = this.workflowActionService
-        .getTexeraGraph()
-        .getAllOperators()
-        .map(op => op.operatorID);
-    } else {
-      operatorIds = 
this.workflowActionService.getJointGraphWrapper().getCurrentHighlightedOperatorIDs();
+    if (!this.downloadability) {
+      return;
     }
 
+    const operatorIds = this.getOperatorIdsToCheck();
+
     if (operatorIds.length === 0) {
       // No operators highlighted
       this.isTableOutput = false;
@@ -106,13 +164,19 @@ export class ResultExportationComponent implements OnInit 
{
       return;
     }
 
-    // Assume they're all table or visualization
-    // until we find an operator that isn't
+    if (this.isExportRestricted) {
+      this.isTableOutput = false;
+      this.isVisualizationOutput = false;
+      this.containsBinaryData = false;
+      return;
+    }
+
+    // Assume they're all table or visualization until we find an operator 
that isn't
     let allTable = true;
     let allVisualization = true;
     let anyBinaryData = false;
 
-    for (const operatorId of operatorIds) {
+    for (const operatorId of this.exportableOperatorIds) {
       const outputTypes = 
this.workflowResultService.determineOutputTypes(operatorId);
       if (!outputTypes.hasAnyResult) {
         continue;
@@ -181,4 +245,14 @@ export class ResultExportationComponent implements OnInit {
       }
     });
   }
+
+  /**
+   * Getter that returns a comma-separated string of blocking dataset labels.
+   * Used in the template to display which datasets are preventing export.
+   *
+   * @returns String like "Dataset1 ([email protected]), Dataset2 
([email protected])"
+   */
+  get blockingDatasetSummary(): string {
+    return this.blockingDatasetLabels.join(", ");
+  }
 }
diff --git 
a/core/gui/src/app/workspace/service/workflow-result-export/workflow-result-export.service.spec.ts
 
b/core/gui/src/app/workspace/service/workflow-result-export/workflow-result-export.service.spec.ts
index 7e45c34de2..7dea1f097e 100644
--- 
a/core/gui/src/app/workspace/service/workflow-result-export/workflow-result-export.service.spec.ts
+++ 
b/core/gui/src/app/workspace/service/workflow-result-export/workflow-result-export.service.spec.ts
@@ -35,6 +35,7 @@ import { PaginatedResultEvent } from 
"../../types/workflow-websocket.interface";
 import { ExecutionState } from "../../types/execute-workflow.interface";
 import * as JSZip from "jszip";
 import { DownloadService } from 
"src/app/dashboard/service/user/download/download.service";
+import { DatasetService } from 
"../../../dashboard/service/user/dataset/dataset.service";
 import { commonTestProviders } from "../../../common/testing/test-utils";
 
 describe("WorkflowResultExportService", () => {
@@ -45,6 +46,7 @@ describe("WorkflowResultExportService", () => {
   let executeWorkflowServiceSpy: jasmine.SpyObj<ExecuteWorkflowService>;
   let workflowResultServiceSpy: jasmine.SpyObj<WorkflowResultService>;
   let downloadServiceSpy: jasmine.SpyObj<DownloadService>;
+  let datasetServiceSpy: jasmine.SpyObj<DatasetService>;
 
   let jointGraphWrapperSpy: jasmine.SpyObj<any>;
   let texeraGraphSpy: jasmine.SpyObj<any>;
@@ -60,8 +62,24 @@ describe("WorkflowResultExportService", () => {
     jointGraphWrapperSpy.getJointOperatorHighlightStream.and.returnValue(of());
     
jointGraphWrapperSpy.getJointOperatorUnhighlightStream.and.returnValue(of());
 
-    texeraGraphSpy = jasmine.createSpyObj("TexeraGraph", ["getAllOperators"]);
+    texeraGraphSpy = jasmine.createSpyObj("TexeraGraph", [
+      "getAllOperators",
+      "getOperatorAddStream",
+      "getOperatorDeleteStream",
+      "getOperatorPropertyChangeStream",
+      "getLinkAddStream",
+      "getLinkDeleteStream",
+      "getDisabledOperatorsChangedStream",
+      "getAllLinks",
+    ]);
     texeraGraphSpy.getAllOperators.and.returnValue([]);
+    texeraGraphSpy.getOperatorAddStream.and.returnValue(of());
+    texeraGraphSpy.getOperatorDeleteStream.and.returnValue(of());
+    texeraGraphSpy.getOperatorPropertyChangeStream.and.returnValue(of());
+    texeraGraphSpy.getLinkAddStream.and.returnValue(of());
+    texeraGraphSpy.getLinkDeleteStream.and.returnValue(of());
+    texeraGraphSpy.getDisabledOperatorsChangedStream.and.returnValue(of());
+    texeraGraphSpy.getAllLinks.and.returnValue([]);
 
     const wsSpy = jasmine.createSpyObj("WorkflowWebsocketService", 
["subscribeToEvent", "send"]);
     wsSpy.subscribeToEvent.and.returnValue(of()); // Return an empty observable
@@ -87,6 +105,9 @@ describe("WorkflowResultExportService", () => {
     const downloadSpy = jasmine.createSpyObj("DownloadService", 
["downloadOperatorsResult"]);
     downloadSpy.downloadOperatorsResult.and.returnValue(of(new Blob()));
 
+    const datasetSpy = jasmine.createSpyObj("DatasetService", 
["retrieveAccessibleDatasets"]);
+    datasetSpy.retrieveAccessibleDatasets.and.returnValue(of([]));
+
     TestBed.configureTestingModule({
       imports: [HttpClientTestingModule],
       providers: [
@@ -97,6 +118,7 @@ describe("WorkflowResultExportService", () => {
         { provide: ExecuteWorkflowService, useValue: ewSpy },
         { provide: WorkflowResultService, useValue: wrSpy },
         { provide: DownloadService, useValue: downloadSpy },
+        { provide: DatasetService, useValue: datasetSpy },
         ...commonTestProviders,
       ],
     });
@@ -109,6 +131,7 @@ describe("WorkflowResultExportService", () => {
     executeWorkflowServiceSpy = TestBed.inject(ExecuteWorkflowService) as 
jasmine.SpyObj<ExecuteWorkflowService>;
     workflowResultServiceSpy = TestBed.inject(WorkflowResultService) as 
jasmine.SpyObj<WorkflowResultService>;
     downloadServiceSpy = TestBed.inject(DownloadService) as 
jasmine.SpyObj<DownloadService>;
+    datasetServiceSpy = TestBed.inject(DatasetService) as 
jasmine.SpyObj<DatasetService>;
   });
 
   it("should be created", () => {
diff --git 
a/core/gui/src/app/workspace/service/workflow-result-export/workflow-result-export.service.ts
 
b/core/gui/src/app/workspace/service/workflow-result-export/workflow-result-export.service.ts
index d2a4b5ae4f..1abad167ec 100644
--- 
a/core/gui/src/app/workspace/service/workflow-result-export/workflow-result-export.service.ts
+++ 
b/core/gui/src/app/workspace/service/workflow-result-export/workflow-result-export.service.ts
@@ -26,7 +26,7 @@ import { PaginatedResultEvent, ResultExportResponse } from 
"../../types/workflow
 import { NotificationService } from 
"../../../common/service/notification/notification.service";
 import { ExecuteWorkflowService } from 
"../execute-workflow/execute-workflow.service";
 import { ExecutionState, isNotInExecution } from 
"../../types/execute-workflow.interface";
-import { filter } from "rxjs/operators";
+import { catchError, filter, map, take, tap } from "rxjs/operators";
 import { OperatorResultService, WorkflowResultService } from 
"../workflow-result/workflow-result.service";
 import { DownloadService } from 
"../../../dashboard/service/user/download/download.service";
 import { HttpResponse } from "@angular/common/http";
@@ -34,6 +34,64 @@ import { ExportWorkflowJsonResponse } from 
"../../../dashboard/service/user/down
 import { DashboardWorkflowComputingUnit } from 
"../../types/workflow-computing-unit";
 import { GuiConfigService } from "../../../common/service/gui-config.service";
 
+/**
+ * Result of workflow result downloadability analysis.
+ * Contains information about which operators are restricted from exporting
+ * due to non-downloadable dataset dependencies.
+ */
+export class WorkflowResultDownloadability {
+  /**
+   * Map of operator IDs to sets of blocking dataset labels.
+   * Key: Operator ID
+   * Value: Set of human-readable dataset labels (e.g., "dataset_name 
([email protected])")
+   *        that are blocking this operator from being exported
+   *
+   * An operator appears in this map if it directly uses or depends on 
(through data flow)
+   * one or more datasets that the current user is not allowed to download.
+   */
+  restrictedOperatorMap: Map<string, Set<string>>;
+
+  constructor(restrictedOperatorMap: Map<string, Set<string>>) {
+    this.restrictedOperatorMap = restrictedOperatorMap;
+  }
+
+  /**
+   * Filters operator IDs to return only those that are not restricted by 
dataset access controls.
+   *
+   * @param operatorIds Array of operator IDs to filter
+   * @returns Array of operator IDs that can be exported
+   */
+  getExportableOperatorIds(operatorIds: readonly string[]): string[] {
+    return operatorIds.filter(operatorId => 
!this.restrictedOperatorMap.has(operatorId));
+  }
+
+  /**
+   * Filters operator IDs to return only those that are restricted by dataset 
access controls.
+   *
+   * @param operatorIds Array of operator IDs to filter
+   * @returns Array of operator IDs that are blocked from export
+   */
+  getBlockedOperatorIds(operatorIds: readonly string[]): string[] {
+    return operatorIds.filter(operatorId => 
this.restrictedOperatorMap.has(operatorId));
+  }
+
+  /**
+   * Gets the list of dataset labels that are blocking export for the given 
operators.
+   * Used to display user-friendly error messages about which datasets are 
causing restrictions.
+   *
+   * @param operatorIds Array of operator IDs to check
+   * @returns Array of dataset labels (e.g., "Dataset1 ([email protected])")
+   */
+  getBlockingDatasets(operatorIds: readonly string[]): string[] {
+    const labels = new Set<string>();
+    operatorIds.forEach(operatorId => {
+      const datasets = this.restrictedOperatorMap.get(operatorId);
+      datasets?.forEach(label => labels.add(label));
+    });
+    return Array.from(labels);
+  }
+}
+
 @Injectable({
   providedIn: "root",
 })
@@ -60,36 +118,78 @@ export class WorkflowResultExportService {
       
this.workflowActionService.getJointGraphWrapper().getJointOperatorHighlightStream(),
       
this.workflowActionService.getJointGraphWrapper().getJointOperatorUnhighlightStream()
     ).subscribe(() => {
-      // check if there are any results to export on highlighted operators 
(either paginated or snapshot)
-      this.hasResultToExportOnHighlightedOperators =
-        
isNotInExecution(this.executeWorkflowService.getExecutionState().state) &&
-        this.workflowActionService
-          .getJointGraphWrapper()
-          .getCurrentHighlightedOperatorIDs()
-          .filter(
-            operatorId =>
-              this.workflowResultService.hasAnyResult(operatorId) ||
-              
this.workflowResultService.getResultService(operatorId)?.getCurrentResultSnapshot()
 !== undefined
-          ).length > 0;
-
-      // check if there are any results to export on all operators (either 
paginated or snapshot)
-      let staticHasResultToExportOnAllOperators =
-        
isNotInExecution(this.executeWorkflowService.getExecutionState().state) &&
-        this.workflowActionService
-          .getTexeraGraph()
-          .getAllOperators()
-          .map(operator => operator.operatorID)
-          .filter(
-            operatorId =>
-              this.workflowResultService.hasAnyResult(operatorId) ||
-              
this.workflowResultService.getResultService(operatorId)?.getCurrentResultSnapshot()
 !== undefined
-          ).length > 0;
-
-      // Notify subscribers of changes
-      
this.hasResultToExportOnAllOperators.next(staticHasResultToExportOnAllOperators);
+      this.updateExportAvailabilityFlags();
     });
   }
 
+  /**
+   * Computes restriction analysis by calling the backend API.
+   *
+   * The backend analyzes the workflow to identify operators that are 
restricted from export
+   * due to non-downloadable dataset dependencies. The restriction propagates 
through the
+   * workflow graph via data flow.
+   *
+   * @returns Observable that emits the restriction analysis result
+   */
+  public computeRestrictionAnalysis(): 
Observable<WorkflowResultDownloadability> {
+    const workflowId = this.workflowActionService.getWorkflow().wid;
+    if (!workflowId) {
+      return of(new WorkflowResultDownloadability(new Map<string, 
Set<string>>()));
+    }
+
+    return 
this.downloadService.getWorkflowResultDownloadability(workflowId).pipe(
+      map(backendResponse => {
+        // Convert backend format to Map<operatorId, Set<datasetLabel>>
+        const restrictedOperatorMap = new Map<string, Set<string>>();
+        Object.entries(backendResponse).forEach(([operatorId, datasetLabels]) 
=> {
+          restrictedOperatorMap.set(operatorId, new Set(datasetLabels));
+        });
+        return new WorkflowResultDownloadability(restrictedOperatorMap);
+      }),
+      catchError(() => {
+        return of(new WorkflowResultDownloadability(new Map<string, 
Set<string>>()));
+      })
+    );
+  }
+
+  /**
+   * Updates UI flags that control export button visibility and availability.
+   *
+   * Checks execution state and result availability to determine:
+   * - hasResultToExportOnHighlightedOperators: for context menu export button
+   * - hasResultToExportOnAllOperators: for top menu export button
+   *
+   * Export is only available when execution is idle and operators have 
results.
+   */
+  private updateExportAvailabilityFlags(): void {
+    const executionIdle = 
isNotInExecution(this.executeWorkflowService.getExecutionState().state);
+
+    const highlightedOperators = 
this.workflowActionService.getJointGraphWrapper().getCurrentHighlightedOperatorIDs();
+
+    const highlightedHasResult = highlightedOperators.some(
+      operatorId =>
+        this.workflowResultService.hasAnyResult(operatorId) ||
+        
this.workflowResultService.getResultService(operatorId)?.getCurrentResultSnapshot()
 !== undefined
+    );
+
+    this.hasResultToExportOnHighlightedOperators = executionIdle && 
highlightedHasResult;
+
+    const allOperatorIds = this.workflowActionService
+      .getTexeraGraph()
+      .getAllOperators()
+      .map(operator => operator.operatorID);
+
+    const hasAnyResult =
+      executionIdle &&
+      allOperatorIds.some(
+        operatorId =>
+          this.workflowResultService.hasAnyResult(operatorId) ||
+          
this.workflowResultService.getResultService(operatorId)?.getCurrentResultSnapshot()
 !== undefined
+      );
+
+    this.hasResultToExportOnAllOperators.next(hasAnyResult);
+  }
+
   /**
    * export the workflow execution result according the export type
    */
@@ -106,6 +206,51 @@ export class WorkflowResultExportService {
     destination: "dataset" | "local" = "dataset", // default to dataset
     unit: DashboardWorkflowComputingUnit | null // computing unit for cluster 
setting
   ): void {
+    this.computeRestrictionAnalysis()
+      .pipe(take(1))
+      .subscribe(restrictionResult =>
+        this.performExport(
+          exportType,
+          workflowName,
+          datasetIds,
+          rowIndex,
+          columnIndex,
+          filename,
+          exportAll,
+          destination,
+          unit,
+          restrictionResult
+        )
+      );
+  }
+
+  /**
+   * Performs the actual export operation with restriction validation.
+   *
+   * This method handles the core export logic:
+   * 1. Validates configuration and computing unit availability
+   * 2. Determines operator scope (all vs highlighted)
+   * 3. Applies restriction filtering with user feedback
+   * 4. Makes the export API call
+   * 5. Handles response and shows appropriate notifications
+   *
+   * Shows error messages if all operators are blocked, warning messages if 
some are blocked.
+   *
+   * @param downloadability Downloadability analysis result containing 
restriction information
+   */
+  private performExport(
+    exportType: string,
+    workflowName: string,
+    datasetIds: number[],
+    rowIndex: number,
+    columnIndex: number,
+    filename: string,
+    exportAll: boolean,
+    destination: "dataset" | "local",
+    unit: DashboardWorkflowComputingUnit | null,
+    downloadability: WorkflowResultDownloadability
+  ): void {
+    // Validates configuration and computing unit availability
     if (!this.config.env.exportExecutionResultEnabled) {
       return;
     }
@@ -120,7 +265,7 @@ export class WorkflowResultExportService {
       return;
     }
 
-    // gather operator IDs
+    // Determines operator scope
     const operatorIds = exportAll
       ? this.workflowActionService
           .getTexeraGraph()
@@ -128,17 +273,35 @@ export class WorkflowResultExportService {
           .map(operator => operator.operatorID)
       : 
[...this.workflowActionService.getJointGraphWrapper().getCurrentHighlightedOperatorIDs()];
 
-    const operatorArray = operatorIds.map(operatorId => {
-      return {
-        id: operatorId,
-        outputType: 
this.workflowResultService.determineOutputExtension(operatorId, exportType),
-      };
-    });
-
     if (operatorIds.length === 0) {
       return;
     }
 
+    // Applies restriction filtering with user feedback
+    const exportableOperatorIds = 
downloadability.getExportableOperatorIds(operatorIds);
+
+    if (exportableOperatorIds.length === 0) {
+      const datasets = downloadability.getBlockingDatasets(operatorIds);
+      const suffix = datasets.length > 0 ? `: ${datasets.join(", ")}` : "";
+      this.notificationService.error(
+        `Cannot export result: selection depends on dataset(s) that are not 
downloadable${suffix}`
+      );
+      return;
+    }
+
+    if (exportableOperatorIds.length < operatorIds.length) {
+      const datasets = downloadability.getBlockingDatasets(operatorIds);
+      const suffix = datasets.length > 0 ? ` (${datasets.join(", ")})` : "";
+      this.notificationService.warning(
+        `Some operators were skipped because their results depend on 
dataset(s) that are not downloadable${suffix}`
+      );
+    }
+
+    const operatorArray = exportableOperatorIds.map(operatorId => ({
+      id: operatorId,
+      outputType: 
this.workflowResultService.determineOutputExtension(operatorId, exportType),
+    }));
+
     // show loading
     this.notificationService.loading("Exporting...");
 
diff --git 
a/core/workflow-core/src/main/scala/edu/uci/ics/amber/core/storage/FileResolver.scala
 
b/core/workflow-core/src/main/scala/edu/uci/ics/amber/core/storage/FileResolver.scala
index 6bf138b4c0..cea7a89173 100644
--- 
a/core/workflow-core/src/main/scala/edu/uci/ics/amber/core/storage/FileResolver.scala
+++ 
b/core/workflow-core/src/main/scala/edu/uci/ics/amber/core/storage/FileResolver.scala
@@ -75,6 +75,31 @@ object FileResolver {
     filePath.toUri
   }
 
+  /**
+    * Parses a dataset file path and extracts its components.
+    * Expected format: /ownerEmail/datasetName/versionName/fileRelativePath
+    *
+    * @param fileName The file path to parse
+    * @return Some((ownerEmail, datasetName, versionName, fileRelativePath)) 
if valid, None otherwise
+    */
+  private def parseDatasetFilePath(
+      fileName: String
+  ): Option[(String, String, String, Array[String])] = {
+    val filePath = Paths.get(fileName)
+    val pathSegments = (0 until 
filePath.getNameCount).map(filePath.getName(_).toString).toArray
+
+    if (pathSegments.length < 4) {
+      return None
+    }
+
+    val ownerEmail = pathSegments(0)
+    val datasetName = pathSegments(1)
+    val versionName = pathSegments(2)
+    val fileRelativePathSegments = pathSegments.drop(3)
+
+    Some((ownerEmail, datasetName, versionName, fileRelativePathSegments))
+  }
+
   /**
     * Attempts to resolve a given fileName to a URI.
     *
@@ -88,14 +113,13 @@ object FileResolver {
     * @throws FileNotFoundException if the dataset file does not exist or 
cannot be created
     */
   private def datasetResolveFunc(fileName: String): URI = {
-    val filePath = Paths.get(fileName)
-    val pathSegments = (0 until 
filePath.getNameCount).map(filePath.getName(_).toString).toArray
+    val (ownerEmail, datasetName, versionName, fileRelativePathSegments) =
+      parseDatasetFilePath(fileName).getOrElse(
+        throw new FileNotFoundException(s"Dataset file $fileName not found.")
+      )
 
-    // extract info from the user-given fileName
-    val ownerEmail = pathSegments(0)
-    val datasetName = pathSegments(1)
-    val versionName = pathSegments(2)
-    val fileRelativePath = Paths.get(pathSegments.drop(3).head, 
pathSegments.drop(3).tail: _*)
+    val fileRelativePath =
+      Paths.get(fileRelativePathSegments.head, fileRelativePathSegments.tail: 
_*)
 
     // fetch the dataset and version from DB to get dataset ID and version hash
     val (dataset, datasetVersion) =
@@ -168,4 +192,20 @@ object FileResolver {
       case _: Exception => false // Invalid URI format
     }
   }
+
+  /**
+    * Parses a dataset file path to extract owner email and dataset name.
+    * Expected format: /ownerEmail/datasetName/versionName/fileRelativePath
+    *
+    * @param path The file path from operator properties
+    * @return Some((ownerEmail, datasetName)) if path is valid, None otherwise
+    */
+  def parseDatasetOwnerAndName(path: String): Option[(String, String)] = {
+    if (path == null) {
+      return None
+    }
+    parseDatasetFilePath(path).map {
+      case (ownerEmail, datasetName, _, _) => (ownerEmail, datasetName)
+    }
+  }
 }


Reply via email to