You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@hop.apache.org by mc...@apache.org on 2026/09/09 15:55:18 UTC
(hop) branch main updated: Add browser-native file operations to Hop Web (#8276)
This is an automated email from the ASF dual-hosted git repository.
mattcasters pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hop.git
The following commit(s) were added to refs/heads/main by this push:
new 7e6a26ec7d Add browser-native file operations to Hop Web (#8276)
7e6a26ec7d is described below
commit 7e6a26ec7da696ea6ba0c618ec83ac25b7e51c18
Author: Michael <58...@users.noreply.github.com>
AuthorDate: Wed Sep 9 10:55:12 2026 -0500
Add browser-native file operations to Hop Web (#8276)
* Add browser-native file operations to Hop Web
* Address review feedback on browser file transfers
* Skip last-used Kettle import source for File Browser ZIP uploads
File Browser ZIP import extracts into a hop-web-user-files session temp folder that is deleted when the dialog closes. Do not persist that path, ignore an already-stored temp last-used value, and remember a real folder only if the user browsed away from the upload.
---------
Co-authored-by: Michael <michaaels>
Co-authored-by: mattcasters <ma...@gmail.com>
---
assemblies/plugins/pom.xml | 12 +-
assemblies/web/pom.xml | 21 +-
assemblies/web/src/assembly/assembly.xml | 11 +
.../java/org/apache/hop/core/vfs/HopVfsTest.java | 26 +
.../modules/ROOT/pages/hop-gui/hop-web.adoc | 25 +
.../apache/hop/imports/gui/HopImportGuiPlugin.java | 27 +-
.../hop/imports/kettle/KettleImportDialog.java | 72 ++-
.../imports/gui/messages/messages_en_US.properties | 3 +
.../imports/gui/messages/messages_es_ES.properties | 6 +-
.../kettle/messages/messages_en_US.properties | 3 +
.../kettle/messages/messages_es_ES.properties | 5 +-
.../hop/imports/kettle/KettleImportDialogTest.java | 217 +++++++
.../apache/hop/marketplace/install/HopHome.java | 32 +
.../apache/hop/marketplace/optional-plugins.yaml | 6 -
.../hop/marketplace/install/HopHomeTest.java | 41 ++
.../apache/hop/projects/gui/ProjectsGuiPlugin.java | 197 +++---
rap/pom.xml | 5 +
.../apache/hop/ui/hopgui/HopWebUserFilePlugin.java | 666 +++++++++++++++++++++
.../org/apache/hop/ui/hopgui/UserFileTransfer.java | 358 +++++++++++
.../hop/ui/hopgui/HopWebUserFilePluginTest.java | 286 +++++++++
.../apache/hop/ui/hopgui/UserFileTransferTest.java | 649 ++++++++++++++++++++
.../org/apache/hop/ui/core/gui/GuiMenuWidgets.java | 157 +++--
.../main/java/org/apache/hop/ui/hopgui/HopGui.java | 77 +++
.../hop/ui/hopgui/HopWebUserFileMenuState.java | 55 ++
.../ui/hopgui/delegates/HopGuiFileDelegate.java | 51 +-
.../ui/hopgui/messages/messages_en_US.properties | 13 +
.../ui/hopgui/messages/messages_es_ES.properties | 13 +
.../src/main/resources/ui/images}/kettle-logo.svg | 36 +-
.../hop/ui/hopgui/HopGuiWebUserFileMenuTest.java | 75 +++
29 files changed, 2953 insertions(+), 192 deletions(-)
diff --git a/assemblies/plugins/pom.xml b/assemblies/plugins/pom.xml
index 398e0f2ed0..b9eb1e5152 100644
--- a/assemblies/plugins/pom.xml
+++ b/assemblies/plugins/pom.xml
@@ -642,6 +642,12 @@
<version>${project.version}</version>
<type>zip</type>
</dependency>
+ <dependency>
+ <groupId>org.apache.hop</groupId>
+ <artifactId>hop-misc-import</artifactId>
+ <version>${project.version}</version>
+ <type>zip</type>
+ </dependency>
<dependency>
<groupId>org.apache.hop</groupId>
<artifactId>hop-misc-mail</artifactId>
@@ -1665,12 +1671,6 @@
<version>${project.version}</version>
<type>zip</type>
</dependency>
- <dependency>
- <groupId>org.apache.hop</groupId>
- <artifactId>hop-misc-import</artifactId>
- <version>${project.version}</version>
- <type>zip</type>
- </dependency>
<dependency>
<groupId>org.apache.hop</groupId>
<artifactId>hop-misc-py4j</artifactId>
diff --git a/assemblies/web/pom.xml b/assemblies/web/pom.xml
index 94e69b8e02..afccf3c01d 100644
--- a/assemblies/web/pom.xml
+++ b/assemblies/web/pom.xml
@@ -29,10 +29,29 @@
<name>Hop Assemblies Web</name>
<properties>
- <rap.version>4.4.0</rap.version>
+ <commons.fileupload2.version>2.0.0-M5</commons.fileupload2.version>
+ <commons.io.version>2.22.0</commons.io.version>
+ <rap.version>4.7.0</rap.version>
</properties>
<dependencies>
+ <dependency>
+ <groupId>commons-io</groupId>
+ <artifactId>commons-io</artifactId>
+ <version>${commons.io.version}</version>
+ </dependency>
+ <!-- RAP file upload uses Commons FileUpload 2 at runtime. The web assembly
+ intentionally excludes transitive dependencies, so declare them here. -->
+ <dependency>
+ <groupId>org.apache.commons</groupId>
+ <artifactId>commons-fileupload2-core</artifactId>
+ <version>${commons.fileupload2.version}</version>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.commons</groupId>
+ <artifactId>commons-fileupload2-jakarta-servlet6</artifactId>
+ <version>${commons.fileupload2.version}</version>
+ </dependency>
<dependency>
<groupId>org.apache.hop</groupId>
<artifactId>hop-assemblies-static</artifactId>
diff --git a/assemblies/web/src/assembly/assembly.xml b/assemblies/web/src/assembly/assembly.xml
index b28d1e24f5..0894a7f1e3 100644
--- a/assemblies/web/src/assembly/assembly.xml
+++ b/assemblies/web/src/assembly/assembly.xml
@@ -75,6 +75,17 @@
<useTransitiveDependencies>false</useTransitiveDependencies>
<useProjectArtifact>false</useProjectArtifact>
</dependencySet>
+ <!-- Commons FileUpload is a runtime dependency of RAP's file upload service. -->
+ <dependencySet>
+ <includes>
+ <include>org.apache.commons:commons-fileupload2-core</include>
+ <include>org.apache.commons:commons-fileupload2-jakarta-servlet6</include>
+ <include>commons-io:commons-io</include>
+ </includes>
+ <outputDirectory>WEB-INF/lib</outputDirectory>
+ <useTransitiveDependencies>false</useTransitiveDependencies>
+ <useProjectArtifact>false</useProjectArtifact>
+ </dependencySet>
<dependencySet>
<includes>
<include>org.apache.hop:hop-ui-rap:jar</include>
diff --git a/core/src/test/java/org/apache/hop/core/vfs/HopVfsTest.java b/core/src/test/java/org/apache/hop/core/vfs/HopVfsTest.java
index 79d5a59cde..c287d4e98a 100644
--- a/core/src/test/java/org/apache/hop/core/vfs/HopVfsTest.java
+++ b/core/src/test/java/org/apache/hop/core/vfs/HopVfsTest.java
@@ -29,6 +29,7 @@ import org.apache.commons.vfs2.FileObject;
import org.apache.hop.core.variables.Variables;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
/** Unit test for {@link HopVfs} */
class HopVfsTest {
@@ -213,4 +214,29 @@ class HopVfsTest {
HopVfs.getFileObject(userHome + "/test-file-hop.txt").getName().getURI(),
childObj.getName().getURI());
}
+
+ @Test
+ void testGetFileObjectForNonExistingLocalFileUri(@TempDir Path tempDir) throws Exception {
+ Path configFile = tempDir.resolve("folder with spaces #1").resolve("hop-config.json.new");
+
+ // Literal local filenames must be escaped by the caller. Path.toUri also produces the absolute
+ // file:///C:/... form required by VFS on Windows, without a process-wide path rewrite.
+ try (FileObject fileObject = HopVfs.getFileObject(configFile.toUri().toString())) {
+ assertEquals(configFile.toAbsolutePath().toString(), HopVfs.getFilename(fileObject));
+ }
+ }
+
+ @Test
+ void testSchemeLessPathRetainsPercentEncodedTraversalSemantics(@TempDir Path tempDir)
+ throws Exception {
+ Path candidate = tempDir.resolve("%2e%2e%2fsecret");
+ Path expected = tempDir.getParent().resolve("secret");
+
+ // Explorer's containment check depends on VFS decoding escapes before normalizing the path.
+ // Encoding every scheme-less path as a URI would make this look like a direct child instead.
+ try (FileObject candidateObject = HopVfs.getFileObject(candidate.toString());
+ FileObject expectedObject = HopVfs.getFileObject(expected.toString())) {
+ assertEquals(expectedObject.getName(), candidateObject.getName());
+ }
+ }
}
diff --git a/docs/hop-user-manual/modules/ROOT/pages/hop-gui/hop-web.adoc b/docs/hop-user-manual/modules/ROOT/pages/hop-gui/hop-web.adoc
index b103bb6459..5ded8b34d6 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/hop-gui/hop-web.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/hop-gui/hop-web.adoc
@@ -50,6 +50,31 @@ You'll feel right at home!
image:hop-web.png[Hop Web,width="90%"]
+== Working with server files and browser files
+
+The *File* menu opens and saves files on the Hop server or its configured virtual file systems.
+The toolbar Save button and keyboard Save shortcut use this same server-side behavior.
+*Close* and *Close All* are available in this menu.
+
+The *File Browser* menu transfers files between Hop Web and your computer:
+
+* *Open* uploads a `.hpl` pipeline or `.hwf` workflow and opens an unsaved copy.
+ Use *File → Save* to choose a permanent server location for that copy.
+ Uploaded temporary paths are not added to recent files or restored when the session ends.
+* *Save* downloads the active pipeline or workflow using its suggested filename, even when it has no changes.
+* *Save As* asks for a download filename first. The browser controls where the download is stored on your computer.
+* *Export project ZIP* downloads the active project.
+* *Import Kettle/PDI ZIP* uploads a ZIP and opens the existing Kettle/PDI import dialog.
+ The extracted folder is temporary and is not stored as the last import source.
+ The standard distribution includes the import plugin; custom distributions must include `hop-misc-import`.
+
+*Export SVG* appears in both menus only while a pipeline or workflow is active.
+In *File Browser*, it downloads the SVG to your computer.
+
+A completed browser download clears the unsaved indicator of an untitled document only if its content has not changed during the transfer.
+Downloading a copy of an existing server file leaves any pending server changes marked as unsaved.
+Save or download your work before logging off or allowing the web session to expire.
+
== Starting Hop Web with a project and environment
Hop Web accepts the same variables as the default Apache Hop container image to allow Hop Web users to specify their project and environment when starting a Hop Web container:
diff --git a/plugins/misc/import/src/main/java/org/apache/hop/imports/gui/HopImportGuiPlugin.java b/plugins/misc/import/src/main/java/org/apache/hop/imports/gui/HopImportGuiPlugin.java
index 3538c44e1a..206574b95a 100644
--- a/plugins/misc/import/src/main/java/org/apache/hop/imports/gui/HopImportGuiPlugin.java
+++ b/plugins/misc/import/src/main/java/org/apache/hop/imports/gui/HopImportGuiPlugin.java
@@ -17,18 +17,25 @@
package org.apache.hop.imports.gui;
+import org.apache.hop.core.exception.HopException;
import org.apache.hop.core.gui.plugin.GuiPlugin;
import org.apache.hop.core.gui.plugin.key.GuiKeyboardShortcut;
import org.apache.hop.core.gui.plugin.key.GuiOsxKeyboardShortcut;
import org.apache.hop.core.gui.plugin.menu.GuiMenuElement;
+import org.apache.hop.core.security.Permission;
+import org.apache.hop.i18n.BaseMessages;
import org.apache.hop.imports.kettle.KettleImport;
import org.apache.hop.imports.kettle.KettleImportDialog;
import org.apache.hop.ui.core.dialog.ErrorDialog;
+import org.apache.hop.ui.core.security.HopSecurityUi;
import org.apache.hop.ui.hopgui.HopGui;
+import org.apache.hop.ui.util.EnvironmentUtils;
@GuiPlugin(name = "Import", description = "Import Kettle projects")
public class HopImportGuiPlugin {
+ private static final Class<?> PKG = HopImportGuiPlugin.class;
+
public static final String ID_MAIN_MENU_FILE_IMPORT = "10060-menu-tools-import";
public static HopImportGuiPlugin instance;
@@ -44,23 +51,39 @@ public class HopImportGuiPlugin {
root = HopGui.ID_MAIN_MENU,
id = ID_MAIN_MENU_FILE_IMPORT,
label = "i18n::HopGuiImport.Menu.Item",
- image = "kettle-logo.svg",
+ image = "ui/images/kettle-logo.svg",
parentId = HopGui.ID_MAIN_MENU_FILE,
separator = true)
@GuiKeyboardShortcut(control = true, key = 'i', global = true)
@GuiOsxKeyboardShortcut(command = true, key = 'i', global = true)
public void menuToolsImport() {
+ menuToolsImport(null);
+ }
+
+ /** Opens the Kettle/PDI import dialog with an optional source folder selected up front. */
+ public void menuToolsImport(String sourceFolder) {
+ if (!HopSecurityUi.check(Permission.FILE_CREATE)
+ || !HopSecurityUi.check(Permission.METADATA_WRITE)) {
+ return;
+ }
HopGui hopGui = HopGui.getInstance();
try {
// Import using this Kettle import plugin...
//
KettleImport kettleImport = new KettleImport();
kettleImport.init(hopGui.getVariables(), hopGui.getLog());
+ kettleImport.setInputFolderName(sourceFolder);
KettleImportDialog dialog =
new KettleImportDialog(hopGui.getShell(), hopGui.getVariables(), kettleImport);
dialog.open();
} catch (Exception e) {
- new ErrorDialog(hopGui.getShell(), "Error", "Error importing from Kettle", e);
+ String title = BaseMessages.getString(PKG, "HopGuiImport.Error.Title");
+ boolean web = EnvironmentUtils.getInstance().isWeb();
+ String message =
+ BaseMessages.getString(
+ PKG, web ? "HopGuiImport.Error.Web.Message" : "HopGuiImport.Error.Message");
+ hopGui.getLog().logError(message, e);
+ new ErrorDialog(hopGui.getShell(), title, message, web ? new HopException(message) : e);
}
}
}
diff --git a/plugins/misc/import/src/main/java/org/apache/hop/imports/kettle/KettleImportDialog.java b/plugins/misc/import/src/main/java/org/apache/hop/imports/kettle/KettleImportDialog.java
index df16d3df9c..bfea79558d 100644
--- a/plugins/misc/import/src/main/java/org/apache/hop/imports/kettle/KettleImportDialog.java
+++ b/plugins/misc/import/src/main/java/org/apache/hop/imports/kettle/KettleImportDialog.java
@@ -19,6 +19,7 @@ package org.apache.hop.imports.kettle;
import java.lang.reflect.InvocationTargetException;
import java.util.List;
+import org.apache.commons.lang3.StringUtils;
import org.apache.hop.core.Const;
import org.apache.hop.core.exception.HopException;
import org.apache.hop.core.extension.ExtensionPointHandler;
@@ -42,6 +43,7 @@ import org.apache.hop.ui.core.widget.TextVar;
import org.apache.hop.ui.hopgui.HopGui;
import org.apache.hop.ui.hopgui.shared.AuditManagerGuiUtil;
import org.apache.hop.ui.pipeline.transform.BaseTransformDialog;
+import org.apache.hop.ui.util.EnvironmentUtils;
import org.apache.hop.workflow.config.WorkflowRunConfiguration;
import org.eclipse.swt.SWT;
import org.eclipse.swt.layout.FormAttachment;
@@ -79,12 +81,19 @@ public class KettleImportDialog extends Dialog {
public static final String CONST_KETTLE_IMPORT_DIALOG_BUTTON_BROWSE =
"KettleImportDialog.Button.Browse";
+ /**
+ * Directory prefix used by Hop Web File Browser uploads. Keep in sync with {@code
+ * HopWebUserFilePlugin} session temp directories.
+ */
+ static final String WEB_USER_FILE_TEMP_DIRECTORY_PREFIX = "hop-web-user-files-";
+
private final IVariables variables;
private Shell shell;
private final PropsUi props;
private final KettleImport kettleImport;
+ private final String configuredSourceFolder;
private final List<String> projectNames;
private TextVar wImportFrom;
@@ -111,6 +120,7 @@ public class KettleImportDialog extends Dialog {
this.variables = variables;
this.kettleImport = kettleImport;
+ this.configuredSourceFolder = kettleImport.getInputFolderName();
try {
projectNames =
@@ -516,9 +526,9 @@ public class KettleImportDialog extends Dialog {
// See if we need to remember previous settings...
//
wImportFrom.setText(
- Const.NVL(
- AuditManagerGuiUtil.getLastUsedValue(LAST_USED_IMPORT_SOURCE_FOLDER),
- Const.NVL(kettleImport.getInputFolderName(), "")));
+ initialSourceFolder(
+ kettleImport.getInputFolderName(),
+ AuditManagerGuiUtil.getLastUsedValue(LAST_USED_IMPORT_SOURCE_FOLDER)));
wImportInExisting.setSelection(
!CONST_FALSE.equalsIgnoreCase(
AuditManagerGuiUtil.getLastUsedValue(LAST_USED_IMPORT_INTO_PROJECT)));
@@ -561,7 +571,10 @@ public class KettleImportDialog extends Dialog {
public void dispose() {
props.setScreen(new WindowProperty(shell));
- AuditManagerGuiUtil.addLastUsedValue(LAST_USED_IMPORT_SOURCE_FOLDER, wImportFrom.getText());
+ String sourceFolder = wImportFrom.getText();
+ if (shouldRememberSourceFolder(configuredSourceFolder, sourceFolder)) {
+ AuditManagerGuiUtil.addLastUsedValue(LAST_USED_IMPORT_SOURCE_FOLDER, sourceFolder);
+ }
AuditManagerGuiUtil.addLastUsedValue(
LAST_USED_IMPORT_INTO_PROJECT, wImportInExisting.getSelection() ? "true" : CONST_FALSE);
AuditManagerGuiUtil.addLastUsedValue(LAST_USED_IMPORT_TARGET_PROJECT, wImportProject.getText());
@@ -583,6 +596,48 @@ public class KettleImportDialog extends Dialog {
shell.dispose();
}
+ static String initialSourceFolder(String configuredSourceFolder, String lastUsedSourceFolder) {
+ if (StringUtils.isNotBlank(configuredSourceFolder)) {
+ return configuredSourceFolder;
+ }
+ if (StringUtils.isBlank(lastUsedSourceFolder)
+ || isEphemeralWebUploadFolder(lastUsedSourceFolder)) {
+ return "";
+ }
+ return lastUsedSourceFolder;
+ }
+
+ /**
+ * File Browser ZIP uploads extract into a session temp folder that is deleted when the dialog
+ * closes. Do not persist that path, and ignore it if it was already stored as last-used.
+ */
+ static boolean shouldRememberSourceFolder(
+ String configuredSourceFolder, String currentSourceFolder) {
+ if (isEphemeralWebUploadFolder(currentSourceFolder)) {
+ return false;
+ }
+ if (StringUtils.isBlank(configuredSourceFolder)) {
+ return true;
+ }
+ return StringUtils.isNotBlank(currentSourceFolder)
+ && !sameSourceFolder(configuredSourceFolder, currentSourceFolder);
+ }
+
+ static boolean isEphemeralWebUploadFolder(String folder) {
+ if (StringUtils.isBlank(folder)) {
+ return false;
+ }
+ String normalized = folder.replace('\\', '/');
+ return normalized.contains("/" + WEB_USER_FILE_TEMP_DIRECTORY_PREFIX)
+ || normalized.startsWith(WEB_USER_FILE_TEMP_DIRECTORY_PREFIX);
+ }
+
+ private static boolean sameSourceFolder(String left, String right) {
+ return StringUtils.equals(
+ StringUtils.removeEnd(left.replace('\\', '/'), "/"),
+ StringUtils.removeEnd(right.replace('\\', '/'), "/"));
+ }
+
private void browseHomeFolder(Event event) {
BaseDialog.presentDirectoryDialog(shell, wImportFrom, variables);
}
@@ -725,7 +780,14 @@ public class KettleImportDialog extends Dialog {
box.open();
}
} catch (Exception e) {
- new ErrorDialog(shell, "Error", "Error importing", e);
+ String title = BaseMessages.getString(PKG, "KettleImportDialog.Error.Title");
+ boolean web = EnvironmentUtils.getInstance().isWeb();
+ String message =
+ BaseMessages.getString(
+ PKG,
+ web ? "KettleImportDialog.Error.Web.Message" : "KettleImportDialog.Error.Message");
+ LogChannel.UI.logError(message, e);
+ new ErrorDialog(shell, title, message, web ? new HopException(message) : e);
}
}
diff --git a/plugins/misc/import/src/main/resources/org/apache/hop/imports/gui/messages/messages_en_US.properties b/plugins/misc/import/src/main/resources/org/apache/hop/imports/gui/messages/messages_en_US.properties
index cc1dd6f8c3..e13a374e39 100644
--- a/plugins/misc/import/src/main/resources/org/apache/hop/imports/gui/messages/messages_en_US.properties
+++ b/plugins/misc/import/src/main/resources/org/apache/hop/imports/gui/messages/messages_en_US.properties
@@ -16,3 +16,6 @@
#
HopGuiImport.Menu.Item=Import from Kettle/PDI
+HopGuiImport.Error.Title=Kettle/PDI import
+HopGuiImport.Error.Message=Unable to open the Kettle/PDI import.
+HopGuiImport.Error.Web.Message=Unable to open the Kettle/PDI import. See the server log for details.
diff --git a/plugins/misc/import/src/main/resources/org/apache/hop/imports/gui/messages/messages_es_ES.properties b/plugins/misc/import/src/main/resources/org/apache/hop/imports/gui/messages/messages_es_ES.properties
index 1692cdb472..7f5ee60f93 100644
--- a/plugins/misc/import/src/main/resources/org/apache/hop/imports/gui/messages/messages_es_ES.properties
+++ b/plugins/misc/import/src/main/resources/org/apache/hop/imports/gui/messages/messages_es_ES.properties
@@ -1,5 +1,4 @@
#
-#
# 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.
@@ -17,3 +16,8 @@
#
#
+HopGuiImport.Menu.Item=Importar desde Kettle/PDI
+HopGuiImport.Error.Title=Importación de Kettle/PDI
+HopGuiImport.Error.Message=No se pudo abrir la importación de Kettle/PDI.
+HopGuiImport.Error.Web.Message=No se pudo abrir la importación de Kettle/PDI. Consulte el registro del servidor para obtener más detalles.
+
diff --git a/plugins/misc/import/src/main/resources/org/apache/hop/imports/kettle/messages/messages_en_US.properties b/plugins/misc/import/src/main/resources/org/apache/hop/imports/kettle/messages/messages_en_US.properties
index 896994a49a..de7f71ad2d 100644
--- a/plugins/misc/import/src/main/resources/org/apache/hop/imports/kettle/messages/messages_en_US.properties
+++ b/plugins/misc/import/src/main/resources/org/apache/hop/imports/kettle/messages/messages_en_US.properties
@@ -40,3 +40,6 @@ KettleImportDialog.Pipeline.RunConfiguration.Label=Pipeline default run configur
KettleImportDialog.RunConfiguration.Tooltip=Sets the default run configuration in case it was not specified
KettleImportDialog.Shell.Name=Import code to Hop
KettleImportDialog.Workflow.RunConfiguration.Label=Workflow default run configuration
+KettleImportDialog.Error.Title=Kettle/PDI import
+KettleImportDialog.Error.Message=The Kettle/PDI import failed.
+KettleImportDialog.Error.Web.Message=The Kettle/PDI import failed. See the server log for details.
diff --git a/plugins/misc/import/src/main/resources/org/apache/hop/imports/kettle/messages/messages_es_ES.properties b/plugins/misc/import/src/main/resources/org/apache/hop/imports/kettle/messages/messages_es_ES.properties
index 1692cdb472..1d1ebfd562 100644
--- a/plugins/misc/import/src/main/resources/org/apache/hop/imports/kettle/messages/messages_es_ES.properties
+++ b/plugins/misc/import/src/main/resources/org/apache/hop/imports/kettle/messages/messages_es_ES.properties
@@ -1,5 +1,4 @@
#
-#
# 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.
@@ -17,3 +16,7 @@
#
#
+KettleImportDialog.Error.Title=Importación de Kettle/PDI
+KettleImportDialog.Error.Message=La importación de Kettle/PDI falló.
+KettleImportDialog.Error.Web.Message=La importación de Kettle/PDI falló. Consulte el registro del servidor para obtener más detalles.
+
diff --git a/plugins/misc/import/src/test/java/org/apache/hop/imports/kettle/KettleImportDialogTest.java b/plugins/misc/import/src/test/java/org/apache/hop/imports/kettle/KettleImportDialogTest.java
new file mode 100644
index 0000000000..e989c84117
--- /dev/null
+++ b/plugins/misc/import/src/test/java/org/apache/hop/imports/kettle/KettleImportDialogTest.java
@@ -0,0 +1,217 @@
+/*
+ * 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.hop.imports.kettle;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.same;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockConstruction;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.io.IOException;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicReference;
+import org.apache.hop.core.logging.HopLogStore;
+import org.apache.hop.core.logging.ILogChannel;
+import org.apache.hop.core.security.Permission;
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.imports.gui.HopImportGuiPlugin;
+import org.apache.hop.ui.core.dialog.ErrorDialog;
+import org.apache.hop.ui.core.security.HopSecurityUi;
+import org.apache.hop.ui.hopgui.HopGui;
+import org.apache.hop.ui.util.EnvironmentUtils;
+import org.eclipse.swt.widgets.Button;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.mockito.MockedConstruction;
+import org.mockito.MockedStatic;
+
+class KettleImportDialogTest {
+
+ @BeforeAll
+ static void initializeLogging() {
+ HopLogStore.init();
+ }
+
+ @Test
+ void browserImportEntryPointIsPubliclyAvailable() throws Exception {
+ assertNotNull(HopImportGuiPlugin.class.getMethod("menuToolsImport", String.class));
+ }
+
+ @Test
+ void configuredBrowserUploadFolderOverridesLastUsedFolder() {
+ assertEquals(
+ "uploaded-project",
+ KettleImportDialog.initialSourceFolder("uploaded-project", "previous-project"));
+ }
+
+ @Test
+ void fallsBackToLastUsedFolderForTheTraditionalMenu() {
+ assertEquals(
+ "previous-project", KettleImportDialog.initialSourceFolder(null, "previous-project"));
+ assertEquals("", KettleImportDialog.initialSourceFolder(null, null));
+ }
+
+ @Test
+ void ignoresADeletedFileBrowserUploadAsLastUsedSource() {
+ assertEquals(
+ "",
+ KettleImportDialog.initialSourceFolder(
+ null, "/tmp/hop-web-user-files-abc/kettle-import-1"));
+ assertEquals(
+ "",
+ KettleImportDialog.initialSourceFolder(
+ null, "C:\\Temp\\hop-web-user-files-abc\\kettle-import-1"));
+ assertEquals(
+ "uploaded-project",
+ KettleImportDialog.initialSourceFolder(
+ "uploaded-project", "/tmp/hop-web-user-files-abc/kettle-import-1"));
+ }
+
+ @Test
+ void remembersTraditionalSourceFoldersButNotFileBrowserUploads() {
+ assertTrue(KettleImportDialog.shouldRememberSourceFolder(null, "/home/kettle"));
+ assertTrue(KettleImportDialog.shouldRememberSourceFolder("", "/home/kettle"));
+ assertFalse(
+ KettleImportDialog.shouldRememberSourceFolder(
+ "/tmp/hop-web-user-files-abc/kettle-import-1",
+ "/tmp/hop-web-user-files-abc/kettle-import-1"));
+ assertFalse(
+ KettleImportDialog.shouldRememberSourceFolder(
+ null, "/tmp/hop-web-user-files-abc/kettle-import-1"));
+ assertTrue(
+ KettleImportDialog.shouldRememberSourceFolder(
+ "/tmp/hop-web-user-files-abc/kettle-import-1", "/srv/kettle"));
+ assertFalse(
+ KettleImportDialog.shouldRememberSourceFolder(
+ "/tmp/hop-web-user-files-abc/kettle-import-1/",
+ "/tmp/hop-web-user-files-abc/kettle-import-1"));
+ }
+
+ @Test
+ void detectsHopWebSessionTempUploadFolders() {
+ assertTrue(
+ KettleImportDialog.isEphemeralWebUploadFolder(
+ "/tmp/hop-web-user-files-xyz/kettle-import-1"));
+ assertTrue(
+ KettleImportDialog.isEphemeralWebUploadFolder(
+ "C:\\Users\\hop\\AppData\\Local\\Temp\\hop-web-user-files-xyz\\project"));
+ assertFalse(KettleImportDialog.isEphemeralWebUploadFolder("/home/kettle/project"));
+ assertFalse(KettleImportDialog.isEphemeralWebUploadFolder(null));
+ assertFalse(KettleImportDialog.isEphemeralWebUploadFolder(""));
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = {false, true})
+ void openingImportPreservesDesktopDetailsAndHidesWebServerPaths(boolean web) {
+ HopGui hopGui = mock(HopGui.class);
+ ILogChannel log = mock(ILogChannel.class);
+ EnvironmentUtils environment = mock(EnvironmentUtils.class);
+ RuntimeException failure = importFailure();
+ when(hopGui.getVariables()).thenThrow(failure);
+ when(hopGui.getLog()).thenReturn(log);
+ when(environment.isWeb()).thenReturn(web);
+ AtomicReference<List<?>> dialogArguments = new AtomicReference<>();
+
+ try (MockedStatic<HopGui> hopGuiStatic = mockStatic(HopGui.class);
+ MockedStatic<HopSecurityUi> security = mockStatic(HopSecurityUi.class);
+ MockedStatic<EnvironmentUtils> environmentStatic = mockStatic(EnvironmentUtils.class);
+ MockedConstruction<ErrorDialog> dialogs =
+ mockConstruction(
+ ErrorDialog.class, (dialog, context) -> dialogArguments.set(context.arguments()))) {
+ hopGuiStatic.when(HopGui::getInstance).thenReturn(hopGui);
+ security.when(() -> HopSecurityUi.check(Permission.FILE_CREATE)).thenReturn(true);
+ security.when(() -> HopSecurityUi.check(Permission.METADATA_WRITE)).thenReturn(true);
+ environmentStatic.when(EnvironmentUtils::getInstance).thenReturn(environment);
+
+ new HopImportGuiPlugin().menuToolsImport("uploaded-project");
+
+ assertEquals(1, dialogs.constructed().size());
+ assertImportError(
+ web, failure, dialogArguments.get(), HopImportGuiPlugin.class, "HopGuiImport");
+ verify(log).logError(anyString(), same(failure));
+ }
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = {false, true})
+ void importingPreservesDesktopDetailsAndHidesWebServerPaths(boolean web) throws Exception {
+ // Exercise the import failure path without opening an SWT display or native dialog.
+ KettleImportDialog importDialog = mock(KettleImportDialog.class);
+ Button importInExisting = mock(Button.class);
+ EnvironmentUtils environment = mock(EnvironmentUtils.class);
+ RuntimeException failure = importFailure();
+ when(importInExisting.getSelection()).thenThrow(failure);
+ when(environment.isWeb()).thenReturn(web);
+ Field importInExistingField = KettleImportDialog.class.getDeclaredField("wImportInExisting");
+ importInExistingField.setAccessible(true);
+ importInExistingField.set(importDialog, importInExisting);
+ Method doImport = KettleImportDialog.class.getDeclaredMethod("doImport");
+ doImport.setAccessible(true);
+ AtomicReference<List<?>> dialogArguments = new AtomicReference<>();
+
+ try (MockedStatic<EnvironmentUtils> environmentStatic = mockStatic(EnvironmentUtils.class);
+ MockedConstruction<ErrorDialog> dialogs =
+ mockConstruction(
+ ErrorDialog.class, (dialog, context) -> dialogArguments.set(context.arguments()))) {
+ environmentStatic.when(EnvironmentUtils::getInstance).thenReturn(environment);
+
+ doImport.invoke(importDialog);
+
+ assertEquals(1, dialogs.constructed().size());
+ assertImportError(
+ web, failure, dialogArguments.get(), KettleImportDialog.class, "KettleImportDialog");
+ }
+ }
+
+ private static RuntimeException importFailure() {
+ return new IllegalStateException(
+ "Unable to read /srv/private/import/kettle.properties",
+ new IOException("/srv/private/config/passwords.properties"));
+ }
+
+ private static void assertImportError(
+ boolean web, Exception failure, List<?> arguments, Class<?> pkg, String messagePrefix) {
+ String message = (String) arguments.get(2);
+ Exception displayedFailure = (Exception) arguments.get(3);
+ assertFalse(message.contains("/srv/private"));
+ if (web) {
+ assertEquals(BaseMessages.getString(pkg, messagePrefix + ".Error.Web.Message"), message);
+ assertEquals(message, displayedFailure.getMessage().trim());
+ assertNull(displayedFailure.getCause());
+ assertEquals(0, displayedFailure.getSuppressed().length);
+ } else {
+ assertEquals(BaseMessages.getString(pkg, messagePrefix + ".Error.Message"), message);
+ assertFalse(message.contains("server log"));
+ assertSame(failure, displayedFailure);
+ assertSame(failure.getCause(), displayedFailure.getCause());
+ }
+ }
+}
diff --git a/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/install/HopHome.java b/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/install/HopHome.java
index 9cb5591f8f..2c3e08b99d 100644
--- a/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/install/HopHome.java
+++ b/plugins/misc/marketplace/src/main/java/org/apache/hop/marketplace/install/HopHome.java
@@ -35,6 +35,9 @@ import org.apache.hop.core.exception.HopException;
*/
public final class HopHome {
+ private static final String HOP_CONFIG_FOLDER = "HOP_CONFIG_FOLDER";
+ private static final String HOP_PLUGIN_BASE_FOLDERS = "HOP_PLUGIN_BASE_FOLDERS";
+
private HopHome() {}
public static Path resolve() throws HopException {
@@ -61,9 +64,38 @@ public final class HopHome {
if (parent != null) {
paths.add(parent);
}
+
+ // Web deployments can keep the writable plugins and configuration outside the binaries.
+ // Resolve those configured locations without changing the launcher behavior above.
+ addConfiguredParent(paths, System.getProperty(HOP_CONFIG_FOLDER), "config");
+ addConfiguredParent(paths, System.getenv(HOP_CONFIG_FOLDER), "config");
+ addConfiguredPluginParents(paths, System.getProperty(HOP_PLUGIN_BASE_FOLDERS));
+ addConfiguredPluginParents(paths, System.getenv(HOP_PLUGIN_BASE_FOLDERS));
return paths;
}
+ private static void addConfiguredPluginParents(Set<Path> paths, String configured) {
+ if (configured == null || configured.isBlank()) {
+ return;
+ }
+ for (String folder : configured.split(",")) {
+ addConfiguredParent(paths, folder, "plugins");
+ }
+ }
+
+ private static void addConfiguredParent(Set<Path> paths, String configured, String childName) {
+ if (configured == null || configured.isBlank()) {
+ return;
+ }
+ Path path = Paths.get(configured.trim()).toAbsolutePath().normalize();
+ if (path.getFileName() != null && childName.equalsIgnoreCase(path.getFileName().toString())) {
+ path = path.getParent();
+ }
+ if (path != null) {
+ paths.add(path);
+ }
+ }
+
/** True if path is a directory that looks like a Hop install (has {@code plugins/}). */
static boolean isHopHome(Path path) {
return path != null && Files.isDirectory(path) && Files.isDirectory(path.resolve("plugins"));
diff --git a/plugins/misc/marketplace/src/main/resources/org/apache/hop/marketplace/optional-plugins.yaml b/plugins/misc/marketplace/src/main/resources/org/apache/hop/marketplace/optional-plugins.yaml
index 1c25abf046..a620eb5219 100644
--- a/plugins/misc/marketplace/src/main/resources/org/apache/hop/marketplace/optional-plugins.yaml
+++ b/plugins/misc/marketplace/src/main/resources/org/apache/hop/marketplace/optional-plugins.yaml
@@ -238,12 +238,6 @@ plugins:
description: Generate Hop documentation
installPath: plugins/misc/documentation
modulePath: plugins/misc/documentation
- - artifactId: hop-misc-import
- name: Import
- category: Miscellaneous
- description: Import Kettle / Pentaho Data Integration metadata
- installPath: plugins/misc/import
- modulePath: plugins/misc/import
- artifactId: hop-misc-lint
name: Lint
category: Miscellaneous
diff --git a/plugins/misc/marketplace/src/test/java/org/apache/hop/marketplace/install/HopHomeTest.java b/plugins/misc/marketplace/src/test/java/org/apache/hop/marketplace/install/HopHomeTest.java
index fae4b4a184..4caf4c9ee9 100644
--- a/plugins/misc/marketplace/src/test/java/org/apache/hop/marketplace/install/HopHomeTest.java
+++ b/plugins/misc/marketplace/src/test/java/org/apache/hop/marketplace/install/HopHomeTest.java
@@ -66,4 +66,45 @@ class HopHomeTest {
System.setProperty("user.dir", previous);
}
}
+
+ @Test
+ void resolveUsesConfiguredPluginFolderWhenUserDirIsNotHopHome() throws Exception {
+ Path hop = tempDir.resolve("hop-data");
+ Files.createDirectories(hop.resolve("plugins"));
+ String previousUserDir = System.getProperty("user.dir");
+ String previousPluginFolders = System.getProperty("HOP_PLUGIN_BASE_FOLDERS");
+ try {
+ System.setProperty("user.dir", tempDir.resolve("not-hop").toString());
+ System.setProperty("HOP_PLUGIN_BASE_FOLDERS", hop.resolve("plugins").toString());
+ assertEquals(hop.toAbsolutePath().normalize(), HopHome.resolve());
+ } finally {
+ restoreProperty("user.dir", previousUserDir);
+ restoreProperty("HOP_PLUGIN_BASE_FOLDERS", previousPluginFolders);
+ }
+ }
+
+ @Test
+ void resolveUsesParentOfConfiguredConfigFolder() throws Exception {
+ Path hop = tempDir.resolve("hop-web");
+ Files.createDirectories(hop.resolve("plugins"));
+ Files.createDirectories(hop.resolve("config"));
+ String previousUserDir = System.getProperty("user.dir");
+ String previousConfigFolder = System.getProperty("HOP_CONFIG_FOLDER");
+ try {
+ System.setProperty("user.dir", tempDir.resolve("not-hop").toString());
+ System.setProperty("HOP_CONFIG_FOLDER", hop.resolve("config").toString());
+ assertEquals(hop.toAbsolutePath().normalize(), HopHome.resolve());
+ } finally {
+ restoreProperty("user.dir", previousUserDir);
+ restoreProperty("HOP_CONFIG_FOLDER", previousConfigFolder);
+ }
+ }
+
+ private static void restoreProperty(String name, String value) {
+ if (value == null) {
+ System.clearProperty(name);
+ } else {
+ System.setProperty(name, value);
+ }
+ }
}
diff --git a/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/ProjectsGuiPlugin.java b/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/ProjectsGuiPlugin.java
index 77ecf6d69a..da331aa884 100644
--- a/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/ProjectsGuiPlugin.java
+++ b/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/ProjectsGuiPlugin.java
@@ -1706,6 +1706,14 @@ public class ProjectsGuiPlugin {
if (zipFilename == null) {
return;
}
+ exportProject(zipFilename, true);
+ }
+
+ public void exportProject(String zipFilename, boolean showConfirmation) {
+ HopGui hopGui = HopGui.getInstance();
+ Shell shell = hopGui.getShell();
+ IVariables variables = hopGui.getVariables();
+
String projectName =
HopGui.getInstance().getStatusToolbarWidgets().getToolbarItemText(ID_TOOLBAR_ITEM_PROJECT);
if (StringUtils.isEmpty(projectName)) {
@@ -1713,7 +1721,7 @@ public class ProjectsGuiPlugin {
}
ProjectsConfig config = ProjectsConfigSingleton.getConfig();
ProjectConfig projectConfig = config.findProjectConfig(projectName);
- String projectHome = projectConfig.getProjectHome();
+ String projectHome = variables.resolve(projectConfig.getProjectHome());
AtomicBoolean includeVariables = new AtomicBoolean(true);
AtomicBoolean includeMetadata = new AtomicBoolean(true);
@@ -1856,94 +1864,95 @@ public class ProjectsGuiPlugin {
boolean asIs = exportAsIs.get();
FileObject zipFile = HopVfs.getFileObject(zipFilename);
- OutputStream outputStream = HopVfs.getOutputStream(zipFile, false);
- ZipOutputStream zos = new ZipOutputStream(outputStream);
- FileObject projectDirectory = HopVfs.getFileObject(projectHome);
- // As-is: strip project home so entries land at the zip root (compatible with
- // zip:file://…!/ read-only project open). Normal: strip parent so entries are under
- // {projectName}/…
- String stripBase =
- asIs
- ? projectDirectory.getName().getURI()
- : projectDirectory.getParent().getName().getURI();
-
- // Includes selected files and dependencies
- if (!tree.getFileObjects().isEmpty()) {
- for (FileObject fileObject : tree.getFileObjects()) {
- // To prevent the zip file from including itself
- if (zipFile.equals(fileObject)) {
- continue;
+ try (OutputStream outputStream = HopVfs.getOutputStream(zipFile, false);
+ ZipOutputStream zos = new ZipOutputStream(outputStream)) {
+ FileObject projectDirectory = HopVfs.getFileObject(projectHome);
+ // As-is: strip project home so entries land at the zip root (compatible with
+ // zip:file://…!/ read-only project open). Normal: strip parent so entries are under
+ // {projectName}/…
+ String stripBase =
+ asIs
+ ? projectDirectory.getName().getURI()
+ : projectDirectory.getParent().getName().getURI();
+
+ // Includes selected files and dependencies
+ if (!tree.getFileObjects().isEmpty()) {
+ for (FileObject fileObject : tree.getFileObjects()) {
+ // To prevent the zip file from including itself
+ if (zipFile.equals(fileObject)) {
+ continue;
+ }
+ monitor.subTask(fileObject.getName().getURI());
+ zipFile(fileObject, fileObject.getName().getURI(), zos, stripBase);
}
- monitor.subTask(fileObject.getName().getURI());
- zipFile(fileObject, fileObject.getName().getURI(), zos, stripBase);
}
- }
- // Sidecars only for the classic (non as-is) export format
- if (!asIs) {
- HashMap<String, String> variablesMap = new HashMap<>();
-
- for (String name : variables.getVariableNames()) {
- if (!name.contains("java.")
- && !name.contains("user.")
- && !name.contains("sun.")
- && !name.contains("os.")
- && !name.contains("file.")
- && !name.contains("jdk.")
- && !name.contains("http.")
- && !name.contains("path.")
- && !name.contains("ftp.")
- && !name.contains("line.")
- && !name.contains("awt.")
- && !name.equals("HOP_METADATA_FOLDER")
- && !name.contains("HOP_ENVIRONMENT_NAME")
- && !name.contains("HOP_AUDIT_FOLDER")
- && !name.contains("HOP_CONFIG_FOLDER")
- && !name.contains("PROJECT_HOME")
- && !name.contains("PARENT_PROJECT_NAME")
- && !name.contains("HOP_PROJECTS")
- && !name.contains("HOP_PLATFORM_OS")
- && !name.contains("HOP_PROJECT_NAME")
- && !name.contains("HOP_SERVER_URL")) {
- String value = variables.getVariable(name);
- variablesMap.put(name, value);
+ // Sidecars only for the classic (non as-is) export format
+ if (!asIs) {
+ HashMap<String, String> variablesMap = new HashMap<>();
+
+ for (String name : variables.getVariableNames()) {
+ if (!name.contains("java.")
+ && !name.contains("user.")
+ && !name.contains("sun.")
+ && !name.contains("os.")
+ && !name.contains("file.")
+ && !name.contains("jdk.")
+ && !name.contains("http.")
+ && !name.contains("path.")
+ && !name.contains("ftp.")
+ && !name.contains("line.")
+ && !name.contains("awt.")
+ && !name.equals("HOP_METADATA_FOLDER")
+ && !name.contains("HOP_ENVIRONMENT_NAME")
+ && !name.contains("HOP_AUDIT_FOLDER")
+ && !name.contains("HOP_CONFIG_FOLDER")
+ && !name.contains("PROJECT_HOME")
+ && !name.contains("PARENT_PROJECT_NAME")
+ && !name.contains("HOP_PROJECTS")
+ && !name.contains("HOP_PLATFORM_OS")
+ && !name.contains("HOP_PROJECT_NAME")
+ && !name.contains("HOP_SERVER_URL")) {
+ String value = variables.getVariable(name);
+ variablesMap.put(name, value);
+ }
}
- }
- ObjectMapper objectMapper = new ObjectMapper();
- String variablesJson = objectMapper.writeValueAsString(variablesMap);
-
- SerializableMetadataProvider metadataProvider =
- new SerializableMetadataProvider(hopGui.getMetadataProvider());
- String metadataJson = metadataProvider.toJson();
-
- // Includes config file
- zipFile(
- HopVfs.getFileObject(
- Const.HOP_CONFIG_FOLDER + Const.FILE_SEPARATOR + Const.HOP_CONFIG),
- projectDirectory.getName().getBaseName()
- + Const.FILE_SEPARATOR
- + Const.HOP_CONFIG,
- zos,
- stripBase);
-
- // Includes variables
- if (includeVariables.get()) {
- zipString(
- variablesJson,
- "variables.json",
+ ObjectMapper objectMapper = new ObjectMapper();
+ String variablesJson = objectMapper.writeValueAsString(variablesMap);
+
+ SerializableMetadataProvider metadataProvider =
+ new SerializableMetadataProvider(hopGui.getMetadataProvider());
+ String metadataJson = metadataProvider.toJson();
+
+ // Includes config file
+ zipFile(
+ HopVfs.getFileObject(
+ Const.HOP_CONFIG_FOLDER + Const.FILE_SEPARATOR + Const.HOP_CONFIG),
+ projectDirectory.getName().getBaseName()
+ + Const.FILE_SEPARATOR
+ + Const.HOP_CONFIG,
zos,
- projectDirectory.getName().getBaseName());
- }
+ stripBase);
+
+ // Includes variables
+ if (includeVariables.get()) {
+ zipString(
+ variablesJson,
+ "variables.json",
+ zos,
+ projectDirectory.getName().getBaseName());
+ }
- // Includes metadata
- if (includeMetadata.get()) {
- zipString(
- metadataJson, "metadata.json", zos, projectDirectory.getName().getBaseName());
+ // Includes metadata
+ if (includeMetadata.get()) {
+ zipString(
+ metadataJson,
+ "metadata.json",
+ zos,
+ projectDirectory.getName().getBaseName());
+ }
}
}
-
- zos.close();
- outputStream.close();
} catch (Exception e) {
throw new InvocationTargetException(e, "Error zipping project: " + e.getMessage());
} finally {
@@ -1954,21 +1963,27 @@ public class ProjectsGuiPlugin {
ProgressMonitorDialog pmd = new ProgressMonitorDialog(shell);
pmd.run(false, op);
- GuiResource.getInstance().toClipboard(zipFilename);
+ if (showConfirmation) {
+ GuiResource.getInstance().toClipboard(zipFilename);
- MessageBox box = new MessageBox(shell, SWT.CLOSE | SWT.ICON_INFORMATION);
- box.setText(BaseMessages.getString(PKG, "ProjectGuiPlugin.ZipDirectory.Dialog.Header"));
- box.setMessage(
- BaseMessages.getString(PKG, "ProjectGuiPlugin.ZipDirectory.Dialog.Message1", zipFilename)
- + Const.CR
- + BaseMessages.getString(PKG, "ProjectGuiPlugin.ZipDirectory.Dialog.Message2"));
- box.open();
+ MessageBox box = new MessageBox(shell, SWT.CLOSE | SWT.ICON_INFORMATION);
+ box.setText(BaseMessages.getString(PKG, "ProjectGuiPlugin.ZipDirectory.Dialog.Header"));
+ box.setMessage(
+ BaseMessages.getString(
+ PKG, "ProjectGuiPlugin.ZipDirectory.Dialog.Message1", zipFilename)
+ + Const.CR
+ + BaseMessages.getString(PKG, "ProjectGuiPlugin.ZipDirectory.Dialog.Message2"));
+ box.open();
+ }
} catch (Exception e) {
+ String errorMessage =
+ BaseMessages.getString(PKG, "ProjectGuiPlugin.ZipDirectory.Error.Dialog.Message");
+ hopGui.getLog().logError(errorMessage, e);
new ErrorDialog(
HopGui.getInstance().getShell(),
BaseMessages.getString(PKG, "ProjectGuiPlugin.ZipDirectory.Error.Dialog.Header"),
- BaseMessages.getString(PKG, "ProjectGuiPlugin.ZipDirectory.Error.Dialog.Message"),
- e);
+ errorMessage,
+ showConfirmation ? e : new HopException(errorMessage));
}
}
diff --git a/rap/pom.xml b/rap/pom.xml
index 2bf92e85cf..050384d094 100644
--- a/rap/pom.xml
+++ b/rap/pom.xml
@@ -35,6 +35,11 @@
</properties>
<dependencies>
+ <dependency>
+ <groupId>org.eclipse.rap</groupId>
+ <artifactId>org.eclipse.rap.filedialog</artifactId>
+ <version>${rap.version}</version>
+ </dependency>
<dependency>
<groupId>org.eclipse.rap</groupId>
<artifactId>org.eclipse.rap.rwt</artifactId>
diff --git a/rap/src/main/java/org/apache/hop/ui/hopgui/HopWebUserFilePlugin.java b/rap/src/main/java/org/apache/hop/ui/hopgui/HopWebUserFilePlugin.java
new file mode 100644
index 0000000000..877543bc1d
--- /dev/null
+++ b/rap/src/main/java/org/apache/hop/ui/hopgui/HopWebUserFilePlugin.java
@@ -0,0 +1,666 @@
+/*
+ * 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.hop.ui.hopgui;
+
+import java.io.FilterInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.lang.reflect.InvocationTargetException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Arrays;
+import java.util.Comparator;
+import java.util.HashSet;
+import java.util.IdentityHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+import java.util.stream.Stream;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipInputStream;
+import org.apache.commons.io.FilenameUtils;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hop.base.AbstractMeta;
+import org.apache.hop.core.Const;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.extension.ExtensionPointHandler;
+import org.apache.hop.core.extension.HopExtensionPoint;
+import org.apache.hop.core.gui.plugin.GuiPlugin;
+import org.apache.hop.core.gui.plugin.GuiRegistry;
+import org.apache.hop.core.gui.plugin.menu.GuiMenuElement;
+import org.apache.hop.core.gui.plugin.menu.GuiMenuItem;
+import org.apache.hop.core.security.Permission;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.core.xml.XmlHandler;
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.metadata.api.IHopMetadataProvider;
+import org.apache.hop.pipeline.PipelineMeta;
+import org.apache.hop.pipeline.PipelineSvgPainter;
+import org.apache.hop.ui.core.dialog.EnterStringDialog;
+import org.apache.hop.ui.core.dialog.ErrorDialog;
+import org.apache.hop.ui.core.security.HopSecurityUi;
+import org.apache.hop.ui.hopgui.file.IHopFileTypeHandler;
+import org.apache.hop.ui.hopgui.file.pipeline.HopGuiPipelineGraph;
+import org.apache.hop.ui.hopgui.file.workflow.HopGuiWorkflowGraph;
+import org.apache.hop.workflow.WorkflowMeta;
+import org.apache.hop.workflow.WorkflowSvgPainter;
+import org.eclipse.rap.rwt.RWT;
+import org.eclipse.rap.rwt.service.UISession;
+import org.w3c.dom.Node;
+
+/** Hop Web menu actions for opening and saving files on the user's computer. */
+@GuiPlugin(name = "Hop Web user files")
+public class HopWebUserFilePlugin {
+
+ private static final Class<?> PKG = HopWebUserFilePlugin.class;
+
+ public static final String ID_MAIN_MENU_FILE_USER = "15000-menu-file-user";
+
+ private static final String PROJECT_EXPORT_MENU_ID = "10055-menu-file-export-to-svg";
+ private static final String KETTLE_IMPORT_MENU_ID = "10060-menu-tools-import";
+ private static final String SESSION_TEMP_DIRECTORY =
+ HopWebUserFilePlugin.class.getName() + ".tempDirectory";
+ static final String SESSION_TEMP_DIRECTORY_PREFIX = "hop-web-user-files-";
+ private static final String HOP_FILE_EXTENSIONS = ".hpl,.hwf";
+ private static final long MAX_HOP_FILE_SIZE = 25L * 1024 * 1024;
+ private static final long MAX_COMPRESSED_ZIP_SIZE = 100L * 1024 * 1024;
+ private static final long MAX_UNCOMPRESSED_ZIP_SIZE = 250L * 1024 * 1024;
+ private static final long MAX_UNCOMPRESSED_ZIP_ENTRY_SIZE = 64L * 1024 * 1024;
+ private static final long MIN_COMPRESSION_RATIO_SIZE = 1024L * 1024;
+ private static final int MAX_COMPRESSION_RATIO = 100;
+ private static final int MAX_ZIP_ENTRIES = 10_000;
+ private static final int MAX_ZIP_ENTRY_DEPTH = 32;
+ private static final int MAX_ZIP_ENTRY_NAME_LENGTH = 4096;
+
+ private final Map<IHopFileTypeHandler, String> userFileNames = new IdentityHashMap<>();
+ private UserFileTransfer transfer;
+ private boolean sessionCleanupRegistered;
+
+ @GuiMenuElement(
+ root = HopGui.ID_MAIN_MENU,
+ id = ID_MAIN_MENU_FILE_USER,
+ label = "i18n::HopGui.Menu.File.User",
+ parentId = HopGui.ID_MAIN_MENU)
+ public void menuFileUser() {
+ // Category only.
+ }
+
+ @GuiMenuElement(
+ root = HopGui.ID_MAIN_MENU,
+ id = HopGui.ID_MAIN_MENU_FILE_USER_NEW,
+ label = "i18n::HopGui.Menu.File.New",
+ image = "ui/images/add.svg",
+ parentId = ID_MAIN_MENU_FILE_USER)
+ public void newFile() {
+ HopGui.getInstance().menuFileNew();
+ }
+
+ @GuiMenuElement(
+ root = HopGui.ID_MAIN_MENU,
+ id = HopGui.ID_MAIN_MENU_FILE_USER_OPEN,
+ label = "i18n::HopGui.Menu.File.Open",
+ image = "ui/images/open.svg",
+ parentId = ID_MAIN_MENU_FILE_USER)
+ public void openFile() {
+ if (!HopSecurityUi.check(Permission.FILE_VIEW)) {
+ return;
+ }
+ try {
+ transfer()
+ .open(
+ HOP_FILE_EXTENSIONS,
+ MAX_HOP_FILE_SIZE,
+ new UserFileTransfer.UploadListener() {
+ @Override
+ public void uploaded(String filename, Path uploadedFile) {
+ openUploadedFile(filename, uploadedFile);
+ }
+
+ @Override
+ public void error(Exception exception) {
+ showError("HopGui.FileBrowser.Error.Upload", exception);
+ }
+ });
+ } catch (Exception e) {
+ showError("HopGui.FileBrowser.Error.Upload", e);
+ }
+ }
+
+ @GuiMenuElement(
+ root = HopGui.ID_MAIN_MENU,
+ id = HopGui.ID_MAIN_MENU_FILE_USER_SAVE,
+ label = "i18n::HopGui.Menu.File.Save",
+ image = "ui/images/save.svg",
+ parentId = ID_MAIN_MENU_FILE_USER)
+ public void saveFile() {
+ downloadActiveFile(false);
+ }
+
+ @GuiMenuElement(
+ root = HopGui.ID_MAIN_MENU,
+ id = HopGui.ID_MAIN_MENU_FILE_USER_SAVE_AS,
+ label = "i18n::HopGui.Menu.File.SaveAs",
+ image = "ui/images/save-as.svg",
+ parentId = ID_MAIN_MENU_FILE_USER)
+ public void saveFileAs() {
+ downloadActiveFile(true);
+ }
+
+ @GuiMenuElement(
+ root = HopGui.ID_MAIN_MENU,
+ id = HopGui.ID_MAIN_MENU_FILE_USER_EXPORT_TO_SVG,
+ label = "i18n::HopGui.Menu.File.ExportToSVG",
+ image = "ui/images/image.svg",
+ parentId = ID_MAIN_MENU_FILE_USER)
+ public void exportToSvg() {
+ if (!HopSecurityUi.check(Permission.FILE_EXPORT)) {
+ return;
+ }
+ try {
+ HopGuiPipelineGraph pipelineGraph = HopGui.getActivePipelineGraph();
+ if (pipelineGraph != null) {
+ String name = safeFilename(pipelineGraph.getPipelineMeta().getName(), ".svg");
+ String svg =
+ PipelineSvgPainter.generatePipelineSvg(
+ pipelineGraph.getPipelineMeta(), 1.0f, pipelineGraph.getVariables());
+ transfer().download(name, "image/svg+xml", svg.getBytes(StandardCharsets.UTF_8));
+ return;
+ }
+
+ HopGuiWorkflowGraph workflowGraph = HopGui.getActiveWorkflowGraph();
+ if (workflowGraph != null) {
+ String name = safeFilename(workflowGraph.getWorkflowMeta().getName(), ".svg");
+ String svg =
+ WorkflowSvgPainter.generateWorkflowSvg(
+ workflowGraph.getWorkflowMeta(), 1.0f, workflowGraph.getVariables());
+ transfer().download(name, "image/svg+xml", svg.getBytes(StandardCharsets.UTF_8));
+ return;
+ }
+ // The menu item is hidden until a pipeline or workflow is active. Keep this guard quiet as
+ // well in case a stale keyboard shortcut or menu event reaches the action.
+ return;
+ } catch (Exception e) {
+ showError("HopGui.FileBrowser.Error.ExportSvg", e);
+ }
+ }
+
+ @GuiMenuElement(
+ root = HopGui.ID_MAIN_MENU,
+ id = HopGui.ID_MAIN_MENU_FILE_USER_EXPORT_PROJECT,
+ label = "i18n::HopGui.Menu.File.ExportProjectZip",
+ image = "ui/images/zipfile.svg",
+ parentId = ID_MAIN_MENU_FILE_USER)
+ public void exportProjectZip() {
+ if (!HopSecurityUi.check(Permission.FILE_EXPORT)) {
+ return;
+ }
+ Path zipFile = null;
+ try {
+ zipFile = Files.createTempFile(getSessionTempDirectory(), "hop-project-", ".zip");
+ Files.deleteIfExists(zipFile);
+ invokeGuiPlugin(
+ PROJECT_EXPORT_MENU_ID,
+ "exportProject",
+ new Class<?>[] {String.class, boolean.class},
+ zipFile.toString(),
+ false);
+ if (!Files.exists(zipFile)) {
+ return;
+ }
+
+ String projectName =
+ HopGui.getInstance().getVariables().getVariable("HOP_PROJECT_NAME", "hop-project");
+ transfer().download(safeFilename(projectName, ".zip"), "application/zip", zipFile);
+ } catch (Exception e) {
+ showError("HopGui.FileBrowser.Error.ExportProject", e);
+ } finally {
+ if (zipFile != null) {
+ try {
+ Files.deleteIfExists(zipFile);
+ } catch (IOException ignored) {
+ // Best effort after the browser download has been prepared.
+ }
+ }
+ }
+ }
+
+ @GuiMenuElement(
+ root = HopGui.ID_MAIN_MENU,
+ id = HopGui.ID_MAIN_MENU_FILE_USER_IMPORT_KETTLE,
+ label = "i18n::HopGui.Menu.File.ImportKettleZip",
+ image = "ui/images/kettle-logo.svg",
+ parentId = ID_MAIN_MENU_FILE_USER)
+ public void importFromKettleZip() {
+ if (!HopSecurityUi.check(Permission.FILE_CREATE)
+ || !HopSecurityUi.check(Permission.METADATA_WRITE)) {
+ return;
+ }
+ try {
+ transfer()
+ .open(
+ ".zip",
+ MAX_COMPRESSED_ZIP_SIZE,
+ new UserFileTransfer.UploadListener() {
+ @Override
+ public void uploaded(String filename, Path uploadedFile) {
+ openKettleImport(filename, uploadedFile);
+ }
+
+ @Override
+ public void error(Exception exception) {
+ showError("HopGui.FileBrowser.Error.ImportKettle", exception);
+ }
+ });
+ } catch (Exception e) {
+ showError("HopGui.FileBrowser.Error.ImportKettle", e);
+ }
+ }
+
+ private UserFileTransfer transfer() throws IOException {
+ if (transfer == null || transfer.isDisposed()) {
+ transfer = new UserFileTransfer(HopGui.getInstance().getShell(), getSessionTempDirectory());
+ if (!sessionCleanupRegistered) {
+ RWT.getUISession().addUISessionListener(event -> userFileNames.clear());
+ sessionCleanupRegistered = true;
+ }
+ }
+ return transfer;
+ }
+
+ private void openUploadedFile(String filename, Path uploadedFile) {
+ try {
+ if (!HopSecurityUi.check(Permission.FILE_VIEW)) {
+ return;
+ }
+ String safeName = safeFilename(filename, "");
+ HopGui hopGui = HopGui.getInstance();
+ AbstractMeta model =
+ readUploadedFile(
+ safeName, uploadedFile, hopGui.getMetadataProvider(), hopGui.getVariables());
+ // The editor must never see the upload path: tab state, recent files and file watchers
+ // otherwise retain a session temporary file as if it were a saved server document.
+ IHopFileTypeHandler handler =
+ model instanceof PipelineMeta pipeline
+ ? HopGui.getExplorerPerspective().addPipeline(pipeline)
+ : HopGui.getExplorerPerspective().addWorkflow((WorkflowMeta) model);
+ userFileNames.put(handler, safeName);
+ handler.updateGui();
+ HopGui.getExplorerPerspective().activate();
+ ExtensionPointHandler.callExtensionPoint(
+ hopGui.getLog(),
+ hopGui.getVariables(),
+ model instanceof PipelineMeta
+ ? HopExtensionPoint.PipelineAfterOpen.id
+ : HopExtensionPoint.WorkflowAfterOpen.id,
+ model);
+ } catch (Exception e) {
+ showError("HopGui.FileBrowser.Error.Open", e);
+ }
+ }
+
+ static AbstractMeta readUploadedFile(
+ String filename,
+ Path uploadedFile,
+ IHopMetadataProvider metadataProvider,
+ IVariables variables)
+ throws IOException, HopException {
+ String extension = FilenameUtils.getExtension(filename);
+ boolean pipeline = "hpl".equalsIgnoreCase(extension);
+ if (!pipeline && !"hwf".equalsIgnoreCase(extension)) {
+ throw new HopException("Only Hop pipeline and workflow files can be opened here.");
+ }
+ try (InputStream input = Files.newInputStream(uploadedFile)) {
+ Node root = XmlHandler.loadXmlFile(input, null, false, false).getDocumentElement();
+ String expectedTag = pipeline ? PipelineMeta.XML_TAG : WorkflowMeta.XML_TAG;
+ if (root == null || !expectedTag.equals(root.getNodeName())) {
+ throw new HopException("The uploaded file does not contain the selected Hop file type.");
+ }
+ AbstractMeta model;
+ if (pipeline) {
+ PipelineMeta pipelineMeta = new PipelineMeta();
+ pipelineMeta.loadXml(root, null, metadataProvider, variables);
+ model = pipelineMeta;
+ } else {
+ model = new WorkflowMeta(root, metadataProvider, variables);
+ }
+ if (StringUtils.isBlank(model.getName())) {
+ model.setName(FilenameUtils.getBaseName(filename));
+ }
+ model.setFilename(null);
+ // Like a new document, the uploaded copy has no permanent server location yet.
+ model.setChanged();
+ return model;
+ }
+ }
+
+ private void openKettleImport(String filename, Path uploadedFile) {
+ Path extractionDirectory = null;
+ try {
+ if (!HopSecurityUi.check(Permission.FILE_CREATE)
+ || !HopSecurityUi.check(Permission.METADATA_WRITE)) {
+ return;
+ }
+ if (!"zip".equalsIgnoreCase(FilenameUtils.getExtension(filename))) {
+ throw new HopException("Select a ZIP file containing the Kettle/PDI project.");
+ }
+
+ extractionDirectory =
+ Files.createDirectory(
+ getSessionTempDirectory().resolve("kettle-import-" + UUID.randomUUID()));
+ Path sourceFolder = extractZip(uploadedFile, extractionDirectory);
+ invokeGuiPlugin(
+ KETTLE_IMPORT_MENU_ID,
+ "menuToolsImport",
+ new Class<?>[] {String.class},
+ sourceFolder.toString());
+ } catch (Exception e) {
+ showError("HopGui.FileBrowser.Error.ImportKettle", e);
+ } finally {
+ if (extractionDirectory != null) {
+ deleteTree(extractionDirectory);
+ }
+ }
+ }
+
+ private void downloadActiveFile(boolean saveAs) {
+ if (!HopSecurityUi.check(Permission.FILE_SAVE)) {
+ return;
+ }
+ try {
+ IHopFileTypeHandler handler = HopGui.getInstance().getActiveFileTypeHandler();
+ if (handler == null) {
+ showError(
+ "HopGui.FileBrowser.Error.NoActiveFile",
+ new HopException("No active pipeline or workflow"));
+ return;
+ }
+
+ byte[] content = serialize(handler);
+ String extension = handler.getFileType().getDefaultFileExtension();
+ String name = suggestedFilename(handler, extension);
+ if (saveAs) {
+ name =
+ new EnterStringDialog(
+ HopGui.getInstance().getActiveShell(),
+ name,
+ BaseMessages.getString(PKG, "HopGui.FileBrowser.SaveAs.Title"),
+ BaseMessages.getString(PKG, "HopGui.FileBrowser.SaveAs.Label"))
+ .open();
+ if (StringUtils.isBlank(name)) {
+ return;
+ }
+ }
+
+ String downloadName = safeFilename(name, extension);
+ transfer()
+ .download(
+ downloadName,
+ "application/xml",
+ content,
+ () -> {
+ // A tab can be closed or edited while its HTTP download is in flight.
+ if (HopGui.getExplorerPerspective().getItems().stream()
+ .noneMatch(item -> item.getTypeHandler() == handler)) {
+ return;
+ }
+ try {
+ if (saveAs) {
+ userFileNames.put(handler, downloadName);
+ }
+ markDownloaded(handler, content);
+ } catch (HopException e) {
+ showError("HopGui.FileBrowser.Error.Save", e);
+ }
+ });
+ } catch (Exception e) {
+ showError("HopGui.FileBrowser.Error.Save", e);
+ }
+ }
+
+ static void markDownloaded(IHopFileTypeHandler handler, byte[] downloadedContent)
+ throws HopException {
+ // A browser copy must not mark an existing server file as saved. For an untitled document,
+ // acknowledge only the exact revision sent, leaving any edits made during the transfer dirty.
+ if (StringUtils.isBlank(handler.getFilename())
+ && Arrays.equals(downloadedContent, serialize(handler))) {
+ if (handler.getSubject() instanceof AbstractMeta model) {
+ model.clearChanged();
+ handler.updateGui();
+ }
+ }
+ }
+
+ private String suggestedFilename(IHopFileTypeHandler handler, String extension) {
+ String name =
+ userFileNames.getOrDefault(
+ handler,
+ StringUtils.isNotBlank(handler.getFilename())
+ ? FilenameUtils.getName(handler.getFilename().replace('\\', '/'))
+ : handler.getName());
+ return safeFilename(name, extension);
+ }
+
+ static byte[] serialize(IHopFileTypeHandler handler) throws HopException {
+ String xml;
+ if (handler.getSubject() instanceof PipelineMeta pipelineMeta) {
+ xml = pipelineMeta.getXml(handler.getVariables());
+ } else if (handler.getSubject() instanceof WorkflowMeta workflowMeta) {
+ xml = workflowMeta.getXml(handler.getVariables());
+ } else {
+ throw new HopException("Only pipelines and workflows can currently be saved as user files.");
+ }
+ return (XmlHandler.getXmlHeader(Const.UTF_8) + xml).getBytes(StandardCharsets.UTF_8);
+ }
+
+ static String safeFilename(String name, String extension) {
+ String safeName = FilenameUtils.getName(StringUtils.defaultIfBlank(name, "hop-file"));
+ safeName = safeName.replaceAll("[\\p{Cntrl}\\\\/:*?\"<>|]", "_");
+ if (StringUtils.isNotBlank(extension)
+ && !safeName.toLowerCase().endsWith(extension.toLowerCase())) {
+ safeName += extension;
+ }
+ return safeName;
+ }
+
+ static Path extractZip(Path zipFile, Path directory) throws IOException, HopException {
+ long compressedSize = Files.size(zipFile);
+ if (compressedSize <= 0 || compressedSize > MAX_COMPRESSED_ZIP_SIZE) {
+ throw new HopException("The ZIP file is empty or exceeds the compressed size limit.");
+ }
+
+ Path extractionRoot = directory.toAbsolutePath().normalize();
+ int entries = 0;
+ int fileEntries = 0;
+ long totalSize = 0;
+ byte[] buffer = new byte[8192];
+ Set<Path> extractedPaths = new HashSet<>();
+
+ try (CountingInputStream compressed = new CountingInputStream(Files.newInputStream(zipFile));
+ ZipInputStream zip = new ZipInputStream(compressed)) {
+ ZipEntry entry;
+ while ((entry = zip.getNextEntry()) != null) {
+ if (++entries > MAX_ZIP_ENTRIES) {
+ throw new HopException("The ZIP file contains too many entries.");
+ }
+
+ String entryName = entry.getName().replace('\\', '/');
+ if (entryName.isBlank()
+ || entryName.length() > MAX_ZIP_ENTRY_NAME_LENGTH
+ || entryName.startsWith("/")
+ || entryName.matches("^[A-Za-z]:.*")
+ || entryName.indexOf('\0') >= 0) {
+ throw new HopException("The ZIP file contains an invalid entry path.");
+ }
+ Path relativeEntry = Path.of(entryName).normalize();
+ Path target = extractionRoot.resolve(relativeEntry).normalize();
+ if (relativeEntry.getNameCount() > MAX_ZIP_ENTRY_DEPTH
+ || !target.startsWith(extractionRoot)
+ || !extractedPaths.add(target)) {
+ throw new HopException("The ZIP file contains an invalid or duplicate entry path.");
+ }
+
+ if (entry.isDirectory()) {
+ Files.createDirectories(target);
+ } else {
+ fileEntries++;
+ Files.createDirectories(target.getParent());
+ try (OutputStream output = Files.newOutputStream(target)) {
+ long entrySize = 0;
+ int length;
+ while ((length = zip.read(buffer)) != -1) {
+ if (length == 0) {
+ continue;
+ }
+ entrySize += length;
+ totalSize += length;
+ if (entrySize > MAX_UNCOMPRESSED_ZIP_ENTRY_SIZE) {
+ throw new HopException("A ZIP entry exceeds the uncompressed size limit.");
+ }
+ if (totalSize > MAX_UNCOMPRESSED_ZIP_SIZE) {
+ throw new HopException("The uncompressed ZIP file is larger than 250 MiB.");
+ }
+ if (totalSize > MIN_COMPRESSION_RATIO_SIZE
+ && compressed.getByteCount() > 0
+ && totalSize / compressed.getByteCount() > MAX_COMPRESSION_RATIO) {
+ throw new HopException("The ZIP file exceeds the allowed compression ratio.");
+ }
+ output.write(buffer, 0, length);
+ }
+ }
+ }
+ zip.closeEntry();
+ }
+ }
+
+ if (entries == 0 || fileEntries == 0) {
+ throw new HopException("The selected ZIP file is empty.");
+ }
+
+ try (Stream<Path> children = Files.list(extractionRoot)) {
+ List<Path> paths = children.toList();
+ if (paths.size() == 1 && Files.isDirectory(paths.get(0))) {
+ return paths.get(0);
+ }
+ }
+ return extractionRoot;
+ }
+
+ private static Object invokeGuiPlugin(
+ String menuId, String methodName, Class<?>[] parameterTypes, Object... arguments)
+ throws Exception {
+ GuiRegistry guiRegistry = GuiRegistry.getInstance();
+ GuiMenuItem menuItem = guiRegistry.findGuiMenuItem(HopGui.ID_MAIN_MENU, menuId);
+ if (menuItem == null) {
+ throw new HopException("The required Hop plugin is not installed.");
+ }
+
+ HopGui hopGui = HopGui.getInstance();
+ String listenerClassName = menuItem.getListenerClassName();
+ String instanceId = hopGui.getMainMenuWidgets().getInstanceId();
+ Object plugin = guiRegistry.findGuiPluginObject(hopGui.getId(), listenerClassName, instanceId);
+ Class<?> pluginClass = menuItem.getClassLoader().loadClass(listenerClassName);
+ if (plugin == null) {
+ plugin = pluginClass.getConstructor().newInstance();
+ guiRegistry.registerGuiPluginObject(hopGui.getId(), listenerClassName, instanceId, plugin);
+ }
+ try {
+ return pluginClass.getMethod(methodName, parameterTypes).invoke(plugin, arguments);
+ } catch (InvocationTargetException e) {
+ if (e.getCause() instanceof Exception cause) {
+ throw cause;
+ }
+ throw e;
+ }
+ }
+
+ private static Path getSessionTempDirectory() throws IOException {
+ UISession session = RWT.getUISession();
+ Path directory = (Path) session.getAttribute(SESSION_TEMP_DIRECTORY);
+ if (directory != null) {
+ return directory;
+ }
+
+ directory = Files.createTempDirectory(SESSION_TEMP_DIRECTORY_PREFIX);
+ session.setAttribute(SESSION_TEMP_DIRECTORY, directory);
+ Path sessionDirectory = directory;
+ session.addUISessionListener(event -> deleteSessionTempDirectory(sessionDirectory));
+ return directory;
+ }
+
+ private static void deleteSessionTempDirectory(Path directory) {
+ deleteTree(directory);
+ }
+
+ private static void deleteTree(Path directory) {
+ try (Stream<Path> files = Files.walk(directory)) {
+ files
+ .sorted(Comparator.reverseOrder())
+ .forEach(
+ file -> {
+ try {
+ Files.deleteIfExists(file);
+ } catch (IOException ignored) {
+ // Best effort during session teardown.
+ }
+ });
+ } catch (IOException ignored) {
+ // Best effort during session teardown.
+ }
+ }
+
+ private static void showError(String messageKey, Exception cause) {
+ String title = BaseMessages.getString(PKG, "HopGui.FileBrowser.Error.Title");
+ String message = BaseMessages.getString(PKG, messageKey);
+ HopGui.getInstance().getLog().logError(message, cause);
+ new ErrorDialog(
+ HopGui.getInstance().getActiveShell(), title, message, new HopException(message));
+ }
+
+ private static final class CountingInputStream extends FilterInputStream {
+ private long byteCount;
+
+ private CountingInputStream(InputStream input) {
+ super(input);
+ }
+
+ @Override
+ public int read() throws IOException {
+ int value = super.read();
+ if (value >= 0) {
+ byteCount++;
+ }
+ return value;
+ }
+
+ @Override
+ public int read(byte[] buffer, int offset, int length) throws IOException {
+ int count = super.read(buffer, offset, length);
+ if (count > 0) {
+ byteCount += count;
+ }
+ return count;
+ }
+
+ private long getByteCount() {
+ return byteCount;
+ }
+ }
+}
diff --git a/rap/src/main/java/org/apache/hop/ui/hopgui/UserFileTransfer.java b/rap/src/main/java/org/apache/hop/ui/hopgui/UserFileTransfer.java
new file mode 100644
index 0000000000..f02a935034
--- /dev/null
+++ b/rap/src/main/java/org/apache/hop/ui/hopgui/UserFileTransfer.java
@@ -0,0 +1,358 @@
+/*
+ * 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.hop.ui.hopgui;
+
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import jakarta.servlet.http.HttpSession;
+import java.io.IOException;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.LinkOption;
+import java.nio.file.Path;
+import java.time.Duration;
+import java.util.Comparator;
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.function.LongSupplier;
+import java.util.stream.Stream;
+import org.eclipse.rap.rwt.RWT;
+import org.eclipse.rap.rwt.client.service.UrlLauncher;
+import org.eclipse.rap.rwt.service.ServiceHandler;
+import org.eclipse.rap.rwt.service.ServiceManager;
+import org.eclipse.rap.rwt.service.UISession;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.SWTException;
+import org.eclipse.swt.widgets.Display;
+import org.eclipse.swt.widgets.FileDialog;
+import org.eclipse.swt.widgets.Shell;
+
+/** Browser-backed file selection and download support for Hop Web. */
+final class UserFileTransfer {
+
+ interface UploadListener {
+ void uploaded(String filename, Path uploadedFile) throws Exception;
+
+ void error(Exception exception);
+ }
+
+ static final long DEFAULT_UPLOAD_TIME_LIMIT = Duration.ofMinutes(5).toMillis();
+ static final long DOWNLOAD_TTL_NANOS = Duration.ofMinutes(10).toNanos();
+ static final int MAX_PENDING_DOWNLOADS = 16;
+
+ private static final String DOWNLOAD_SERVICE_PREFIX = UserFileTransfer.class.getName() + ".";
+
+ private final Shell shell;
+ private final Display display;
+ private final ServiceManager serviceManager;
+ private final UISession uiSession;
+ private final String uiSessionId;
+ private final String httpSessionId;
+ private final String downloadServiceId = DOWNLOAD_SERVICE_PREFIX + UUID.randomUUID();
+ private final Path transferDirectory;
+ private final Path uploadDirectory;
+ private final Path downloadDirectory;
+ private final Map<String, Download> downloads = new ConcurrentHashMap<>();
+ private final AtomicBoolean disposed = new AtomicBoolean();
+ private final LongSupplier nanoTime;
+
+ UserFileTransfer(Shell shell, Path sessionTempDirectory) throws IOException {
+ this(shell, sessionTempDirectory, System::nanoTime);
+ }
+
+ UserFileTransfer(Shell shell, Path sessionTempDirectory, LongSupplier nanoTime)
+ throws IOException {
+ this.shell = shell;
+ this.display = shell.getDisplay();
+ this.nanoTime = nanoTime;
+ serviceManager = RWT.getServiceManager();
+ uiSession = RWT.getUISession();
+ uiSessionId = uiSession.getId();
+ httpSessionId = uiSession.getHttpSession().getId();
+ transferDirectory =
+ Files.createDirectory(sessionTempDirectory.resolve("transfer-" + UUID.randomUUID()));
+ uploadDirectory = Files.createDirectory(transferDirectory.resolve("uploads"));
+ downloadDirectory = Files.createDirectory(transferDirectory.resolve("downloads"));
+ serviceManager.registerServiceHandler(downloadServiceId, new DownloadServiceHandler());
+ uiSession.addUISessionListener(event -> dispose());
+ }
+
+ void open(String acceptedExtensions, long uploadSizeLimit, UploadListener listener) {
+ if (isDisposed()) {
+ listener.error(new IOException("The browser file transfer session is closed."));
+ return;
+ }
+
+ Path requestDirectory = null;
+ try {
+ requestDirectory =
+ Files.createDirectory(uploadDirectory.resolve(UUID.randomUUID().toString()));
+ FileDialog dialog = new FileDialog(shell, SWT.OPEN);
+ dialog.setUploadDirectory(requestDirectory.toFile());
+ dialog.setUploadSizeLimit(uploadSizeLimit);
+ dialog.setUploadTimeLimit(DEFAULT_UPLOAD_TIME_LIMIT);
+ if (acceptedExtensions != null && !acceptedExtensions.isBlank()) {
+ dialog.setFilterExtensions(acceptedExtensions.split(","));
+ }
+
+ String uploadedPath = dialog.open();
+ if (uploadedPath == null || uploadedPath.isBlank()) {
+ if (!dialog.getExceptions().isEmpty()) {
+ throw new IOException(
+ "The browser could not upload the selected file.", dialog.getExceptions().get(0));
+ }
+ return;
+ }
+
+ Path uploadedFile = validateUploadedFile(requestDirectory, uploadedPath, uploadSizeLimit);
+ listener.uploaded(safeUploadFilename(dialog.getFileName()), uploadedFile);
+ } catch (Exception e) {
+ listener.error(e);
+ } finally {
+ deleteTree(requestDirectory);
+ }
+ }
+
+ void download(String filename, String contentType, byte[] content) throws IOException {
+ download(filename, contentType, content, null);
+ }
+
+ void download(String filename, String contentType, byte[] content, Runnable onSuccess)
+ throws IOException {
+ Path file = Files.createTempFile(downloadDirectory, "download-", ".tmp");
+ try {
+ Files.write(file, content);
+ registerDownload(filename, contentType, file, onSuccess);
+ } catch (IOException | RuntimeException e) {
+ Files.deleteIfExists(file);
+ throw e;
+ }
+ }
+
+ void download(String filename, String contentType, Path source) throws IOException {
+ Path file = Files.createTempFile(downloadDirectory, "download-", ".tmp");
+ try {
+ Files.copy(source, file, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
+ registerDownload(filename, contentType, file, null);
+ } catch (IOException | RuntimeException e) {
+ Files.deleteIfExists(file);
+ throw e;
+ }
+ }
+
+ private synchronized void registerDownload(
+ String filename, String contentType, Path file, Runnable onSuccess) throws IOException {
+ if (isDisposed()) {
+ throw new IOException("The browser file transfer session is closed.");
+ }
+ removeExpiredDownloads();
+ evictOldestDownloadIfNecessary();
+
+ String token = UUID.randomUUID().toString();
+ Download download =
+ new Download(
+ safeHeaderFilename(filename),
+ contentType,
+ file,
+ nanoTime.getAsLong() + DOWNLOAD_TTL_NANOS,
+ onSuccess);
+ downloads.put(token, download);
+ try {
+ String url =
+ serviceManager.getServiceHandlerUrl(downloadServiceId)
+ + "&token="
+ + URLEncoder.encode(token, StandardCharsets.UTF_8);
+ RWT.getClient().getService(UrlLauncher.class).openURL(url);
+ } catch (RuntimeException e) {
+ downloads.remove(token, download);
+ throw e;
+ }
+ }
+
+ boolean isDisposed() {
+ return disposed.get();
+ }
+
+ synchronized void dispose() {
+ if (disposed.compareAndSet(false, true)) {
+ try {
+ serviceManager.unregisterServiceHandler(downloadServiceId);
+ } finally {
+ downloads.values().forEach(download -> deleteFile(download.file()));
+ downloads.clear();
+ deleteTree(transferDirectory);
+ }
+ }
+ }
+
+ private void removeExpiredDownloads() {
+ long now = nanoTime.getAsLong();
+ downloads.forEach(
+ (token, download) -> {
+ if (download.expiresAtNanos() - now <= 0 && downloads.remove(token, download)) {
+ deleteFile(download.file());
+ }
+ });
+ }
+
+ private void evictOldestDownloadIfNecessary() {
+ if (downloads.size() < MAX_PENDING_DOWNLOADS) {
+ return;
+ }
+ downloads.entrySet().stream()
+ .min(Comparator.comparingLong(entry -> entry.getValue().expiresAtNanos()))
+ .ifPresent(
+ entry -> {
+ if (downloads.remove(entry.getKey(), entry.getValue())) {
+ deleteFile(entry.getValue().file());
+ }
+ });
+ }
+
+ private final class DownloadServiceHandler implements ServiceHandler {
+ @Override
+ public void service(HttpServletRequest request, HttpServletResponse response)
+ throws IOException {
+ if (isDisposed() || !isRequestFromOwningSession(request)) {
+ response.sendError(HttpServletResponse.SC_NOT_FOUND);
+ return;
+ }
+
+ removeExpiredDownloads();
+ String token = request.getParameter("token");
+ if (token == null || token.isBlank()) {
+ response.sendError(HttpServletResponse.SC_NOT_FOUND);
+ return;
+ }
+
+ Download download = downloads.remove(token);
+ if (download == null) {
+ response.sendError(HttpServletResponse.SC_NOT_FOUND);
+ return;
+ }
+
+ try {
+ response.setContentType(download.contentType());
+ response.setContentLengthLong(Files.size(download.file()));
+ response.setHeader("Cache-Control", "private, no-store");
+ response.setHeader("Pragma", "no-cache");
+ response.setHeader("X-Content-Type-Options", "nosniff");
+ response.setHeader("Content-Disposition", contentDisposition(download.filename()));
+ Files.copy(download.file(), response.getOutputStream());
+ response.flushBuffer();
+ notifyDownloadSucceeded(download.onSuccess());
+ } finally {
+ deleteFile(download.file());
+ if (isDisposed()) {
+ // A concurrent session teardown may have encountered this file while it was still open.
+ deleteTree(transferDirectory);
+ }
+ }
+ }
+ }
+
+ private void notifyDownloadSucceeded(Runnable onSuccess) {
+ if (onSuccess == null || isDisposed() || display.isDisposed()) {
+ return;
+ }
+ try {
+ display.asyncExec(
+ () -> {
+ if (!isDisposed() && !shell.isDisposed()) {
+ onSuccess.run();
+ }
+ });
+ } catch (SWTException e) {
+ if (e.code != SWT.ERROR_DEVICE_DISPOSED) {
+ throw e;
+ }
+ }
+ }
+
+ static Path validateUploadedFile(Path requestDirectory, String uploadedPath, long sizeLimit)
+ throws IOException {
+ Path directory = requestDirectory.toAbsolutePath().normalize();
+ Path uploadedFile = Path.of(uploadedPath).toAbsolutePath().normalize();
+ if (!uploadedFile.startsWith(directory)
+ || !directory.equals(uploadedFile.getParent())
+ || !Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)
+ || !Files.isRegularFile(uploadedFile, LinkOption.NOFOLLOW_LINKS)
+ || Files.size(uploadedFile) > sizeLimit) {
+ throw new IOException("The uploaded file is invalid or exceeds the configured size limit.");
+ }
+ return uploadedFile;
+ }
+
+ static String safeUploadFilename(String filename) {
+ if (filename == null) {
+ return "hop-file";
+ }
+ String basename = filename.replace('\\', '/');
+ basename = basename.substring(basename.lastIndexOf('/') + 1);
+ return safeHeaderFilename(basename);
+ }
+
+ private boolean isRequestFromOwningSession(HttpServletRequest request) {
+ HttpSession requestHttpSession = request.getSession(false);
+ if (requestHttpSession == null || !httpSessionId.equals(requestHttpSession.getId())) {
+ return false;
+ }
+ try {
+ return uiSessionId.equals(RWT.getUISession().getId());
+ } catch (IllegalStateException e) {
+ return false;
+ }
+ }
+
+ static String safeHeaderFilename(String filename) {
+ String safe = filename == null ? "hop-file" : filename;
+ safe = safe.replaceAll("[\\x00-\\x1f\\x7f\\\\/\"]", "_");
+ return safe.isBlank() ? "hop-file" : safe;
+ }
+
+ static String contentDisposition(String filename) {
+ String ascii = filename.replaceAll("[^\\x20-\\x7E]", "_");
+ String encoded = URLEncoder.encode(filename, StandardCharsets.UTF_8).replace("+", "%20");
+ return "attachment; filename=\"" + ascii + "\"; filename*=UTF-8''" + encoded;
+ }
+
+ private static void deleteFile(Path file) {
+ try {
+ Files.deleteIfExists(file);
+ } catch (IOException ignored) {
+ // Best effort cleanup; the enclosing session directory is removed on session teardown.
+ }
+ }
+
+ private static void deleteTree(Path directory) {
+ if (directory == null || !Files.exists(directory)) {
+ return;
+ }
+ try (Stream<Path> files = Files.walk(directory)) {
+ files.sorted(Comparator.reverseOrder()).forEach(UserFileTransfer::deleteFile);
+ } catch (IOException ignored) {
+ // Best effort cleanup during request or session teardown.
+ }
+ }
+
+ private record Download(
+ String filename, String contentType, Path file, long expiresAtNanos, Runnable onSuccess) {}
+}
diff --git a/rap/src/test/java/org/apache/hop/ui/hopgui/HopWebUserFilePluginTest.java b/rap/src/test/java/org/apache/hop/ui/hopgui/HopWebUserFilePluginTest.java
new file mode 100644
index 0000000000..52bc10fbd0
--- /dev/null
+++ b/rap/src/test/java/org/apache/hop/ui/hopgui/HopWebUserFilePluginTest.java
@@ -0,0 +1,286 @@
+/*
+ * 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.hop.ui.hopgui;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.io.ByteArrayOutputStream;
+import java.lang.reflect.Method;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.UUID;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipOutputStream;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.gui.plugin.menu.GuiMenuElement;
+import org.apache.hop.core.logging.HopLogStore;
+import org.apache.hop.core.variables.Variables;
+import org.apache.hop.metadata.serializer.memory.MemoryMetadataProvider;
+import org.apache.hop.pipeline.PipelineMeta;
+import org.apache.hop.ui.hopgui.file.IHopFileTypeHandler;
+import org.apache.hop.workflow.WorkflowMeta;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+class HopWebUserFilePluginTest {
+
+ @TempDir Path tempDirectory;
+
+ @BeforeAll
+ static void initializeLogging() {
+ HopLogStore.init();
+ }
+
+ @Test
+ void uploadedPipelineIsUnsavedAndIndependentOfTemporaryFile() throws Exception {
+ Path upload = tempDirectory.resolve("pipeline with spaces #1.hpl");
+ Files.writeString(upload, "<pipeline><info><name>Uploaded pipeline</name></info></pipeline>");
+
+ PipelineMeta model =
+ (PipelineMeta)
+ HopWebUserFilePlugin.readUploadedFile(
+ upload.getFileName().toString(),
+ upload,
+ new MemoryMetadataProvider(),
+ new Variables());
+ Files.delete(upload);
+
+ assertNull(model.getFilename());
+ assertEquals("Uploaded pipeline", model.getName());
+ assertTrue(model.hasChanged());
+ assertTrue(model.getXml(new Variables()).contains("Uploaded pipeline"));
+ }
+
+ @Test
+ void uploadedWorkflowIsUnsavedAndIndependentOfTemporaryFile() throws Exception {
+ Path upload = tempDirectory.resolve("workflow.hwf");
+ Files.writeString(upload, "<workflow><name>Uploaded workflow</name></workflow>");
+
+ WorkflowMeta model =
+ (WorkflowMeta)
+ HopWebUserFilePlugin.readUploadedFile(
+ "workflow.hwf", upload, new MemoryMetadataProvider(), new Variables());
+ Files.delete(upload);
+
+ assertNull(model.getFilename());
+ assertEquals("Uploaded workflow", model.getName());
+ assertTrue(model.hasChanged());
+ assertTrue(model.getXml(new Variables()).contains("Uploaded workflow"));
+ }
+
+ @Test
+ void rejectsWrongXmlTypeAndMalformedUpload() throws Exception {
+ Path upload = tempDirectory.resolve("invalid.hpl");
+ Files.writeString(upload, "<workflow><name>Wrong type</name></workflow>");
+ assertThrows(
+ HopException.class,
+ () ->
+ HopWebUserFilePlugin.readUploadedFile(
+ "invalid.hpl", upload, new MemoryMetadataProvider(), new Variables()));
+
+ Files.writeString(upload, "<pipeline>");
+ assertThrows(
+ HopException.class,
+ () ->
+ HopWebUserFilePlugin.readUploadedFile(
+ "invalid.hpl", upload, new MemoryMetadataProvider(), new Variables()));
+ }
+
+ @Test
+ void successfulDownloadClearsOnlyTheUploadedRevision() throws Exception {
+ PipelineMeta model = new PipelineMeta();
+ model.setName("Downloaded pipeline");
+ model.setChanged();
+ IHopFileTypeHandler handler = handler(model);
+
+ HopWebUserFilePlugin.markDownloaded(handler, HopWebUserFilePlugin.serialize(handler));
+
+ assertFalse(model.hasChanged());
+ assertNull(model.getFilename());
+ verify(handler).updateGui();
+ }
+
+ @Test
+ void successfulWorkflowDownloadClearsItsChangedFlag() throws Exception {
+ WorkflowMeta model = new WorkflowMeta();
+ model.setName("Downloaded workflow");
+ model.setChanged();
+ IHopFileTypeHandler handler = mock(IHopFileTypeHandler.class);
+ when(handler.getSubject()).thenReturn(model);
+ when(handler.getVariables()).thenReturn(new Variables());
+
+ HopWebUserFilePlugin.markDownloaded(handler, HopWebUserFilePlugin.serialize(handler));
+
+ assertFalse(model.hasChanged());
+ verify(handler).updateGui();
+ }
+
+ @Test
+ void downloadDoesNotDiscardEditsMadeWhileTransferWasPending() throws Exception {
+ PipelineMeta model = new PipelineMeta();
+ model.setName("Before download");
+ IHopFileTypeHandler handler = handler(model);
+ byte[] snapshot = HopWebUserFilePlugin.serialize(handler);
+ model.setName("Edited during download");
+ model.setChanged();
+
+ HopWebUserFilePlugin.markDownloaded(handler, snapshot);
+
+ assertTrue(model.hasChanged());
+ verify(handler, never()).updateGui();
+ }
+
+ @Test
+ void downloadingCopyDoesNotMarkServerFileAsSaved() throws Exception {
+ PipelineMeta model = new PipelineMeta();
+ model.setName("Server pipeline");
+ model.setChanged();
+ IHopFileTypeHandler handler = handler(model);
+ when(handler.getFilename()).thenReturn("project/server.hpl");
+
+ HopWebUserFilePlugin.markDownloaded(handler, HopWebUserFilePlugin.serialize(handler));
+
+ assertTrue(model.hasChanged());
+ verify(handler, never()).updateGui();
+ }
+
+ private static IHopFileTypeHandler handler(PipelineMeta model) {
+ IHopFileTypeHandler handler = mock(IHopFileTypeHandler.class);
+ when(handler.getSubject()).thenReturn(model);
+ when(handler.getVariables()).thenReturn(new Variables());
+ return handler;
+ }
+
+ @Test
+ void browserActionsAreRegisteredInsideTheFileUserMenu() throws Exception {
+ assertEquals(
+ HopGui.ID_MAIN_MENU,
+ HopWebUserFilePlugin.class
+ .getDeclaredMethod("menuFileUser")
+ .getAnnotation(GuiMenuElement.class)
+ .parentId());
+ assertUserFileMenuAction("newFile", "15010-menu-file-user-new");
+ assertUserFileMenuAction("openFile", "15020-menu-file-user-open");
+ assertUserFileMenuAction("saveFile", HopGui.ID_MAIN_MENU_FILE_USER_SAVE);
+ assertUserFileMenuAction("saveFileAs", HopGui.ID_MAIN_MENU_FILE_USER_SAVE_AS);
+ assertUserFileMenuAction("exportToSvg", HopGui.ID_MAIN_MENU_FILE_USER_EXPORT_TO_SVG);
+ assertUserFileMenuAction("exportProjectZip", HopGui.ID_MAIN_MENU_FILE_USER_EXPORT_PROJECT);
+ assertUserFileMenuAction("importFromKettleZip", HopGui.ID_MAIN_MENU_FILE_USER_IMPORT_KETTLE);
+ assertEquals(
+ "ui/images/kettle-logo.svg",
+ HopWebUserFilePlugin.class
+ .getDeclaredMethod("importFromKettleZip")
+ .getAnnotation(GuiMenuElement.class)
+ .image());
+ }
+
+ @Test
+ void extractsSingleProjectFolder() throws Exception {
+ Path zip = zip("my-project/example.ktr", "kettle");
+ Path extractionDirectory = Files.createDirectory(tempDirectory.resolve("extract-project"));
+
+ Path source = HopWebUserFilePlugin.extractZip(zip, extractionDirectory);
+
+ assertEquals(extractionDirectory.resolve("my-project"), source);
+ assertEquals("kettle", Files.readString(source.resolve("example.ktr"), StandardCharsets.UTF_8));
+ }
+
+ @Test
+ void rejectsPathsOutsideExtractionDirectory() throws Exception {
+ Path zip = zip("../outside.ktr", "unsafe");
+ Path extractionDirectory = Files.createDirectory(tempDirectory.resolve("extract-traversal"));
+
+ HopException exception =
+ assertThrows(
+ HopException.class, () -> HopWebUserFilePlugin.extractZip(zip, extractionDirectory));
+ assertFalse(Files.exists(tempDirectory.resolve("outside.ktr")));
+ assertFalse(exception.getMessage().contains("outside.ktr"));
+ }
+
+ @Test
+ void rejectsWindowsStylePathTraversal() throws Exception {
+ Path zip = zip("..\\outside.ktr", "unsafe");
+ Path extractionDirectory = Files.createDirectory(tempDirectory.resolve("extract-windows"));
+
+ assertThrows(
+ HopException.class, () -> HopWebUserFilePlugin.extractZip(zip, extractionDirectory));
+ assertFalse(Files.exists(tempDirectory.resolve("outside.ktr")));
+ }
+
+ @Test
+ void rejectsAbsoluteAndDriveQualifiedPaths() throws Exception {
+ Path unixZip = zip("/outside.ktr", "unsafe");
+ Path driveZip = zip("C:/outside.ktr", "unsafe");
+ Path unixDirectory = Files.createDirectory(tempDirectory.resolve("extract-absolute"));
+ Path driveDirectory = Files.createDirectory(tempDirectory.resolve("extract-drive"));
+
+ assertThrows(HopException.class, () -> HopWebUserFilePlugin.extractZip(unixZip, unixDirectory));
+ assertThrows(
+ HopException.class, () -> HopWebUserFilePlugin.extractZip(driveZip, driveDirectory));
+ }
+
+ @Test
+ void rejectsHighlyCompressedZipBomb() throws Exception {
+ Path zip = zip("project/repeated.ktr", "0".repeat(2 * 1024 * 1024));
+ Path extractionDirectory = Files.createDirectory(tempDirectory.resolve("extract-ratio"));
+
+ assertThrows(
+ HopException.class, () -> HopWebUserFilePlugin.extractZip(zip, extractionDirectory));
+ }
+
+ @Test
+ void sanitizesDownloadHeaders() {
+ String filename = UserFileTransfer.safeHeaderFilename("report\r\n\".svg");
+
+ assertFalse(filename.contains("\r"));
+ assertFalse(filename.contains("\n"));
+ assertFalse(filename.contains("\""));
+ assertTrue(UserFileTransfer.contentDisposition(filename).startsWith("attachment;"));
+ }
+
+ private Path zip(String name, String content) throws Exception {
+ ByteArrayOutputStream output = new ByteArrayOutputStream();
+ try (ZipOutputStream zip = new ZipOutputStream(output)) {
+ zip.putNextEntry(new ZipEntry(name));
+ zip.write(content.getBytes(StandardCharsets.UTF_8));
+ zip.closeEntry();
+ }
+ Path zipFile = tempDirectory.resolve(UUID.randomUUID() + ".zip");
+ Files.write(zipFile, output.toByteArray());
+ return zipFile;
+ }
+
+ private static void assertUserFileMenuAction(String methodName, String id) throws Exception {
+ Method method = HopWebUserFilePlugin.class.getDeclaredMethod(methodName);
+ GuiMenuElement menu = method.getAnnotation(GuiMenuElement.class);
+ assertNotNull(menu);
+ assertEquals(id, menu.id());
+ assertEquals(HopWebUserFilePlugin.ID_MAIN_MENU_FILE_USER, menu.parentId());
+ }
+}
diff --git a/rap/src/test/java/org/apache/hop/ui/hopgui/UserFileTransferTest.java b/rap/src/test/java/org/apache/hop/ui/hopgui/UserFileTransferTest.java
new file mode 100644
index 0000000000..e95f4b3de2
--- /dev/null
+++ b/rap/src/test/java/org/apache/hop/ui/hopgui/UserFileTransferTest.java
@@ -0,0 +1,649 @@
+/*
+ * 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.hop.ui.hopgui;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockConstruction;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import jakarta.servlet.ServletOutputStream;
+import jakarta.servlet.WriteListener;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import jakarta.servlet.http.HttpSession;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Queue;
+import java.util.concurrent.atomic.AtomicLong;
+import org.eclipse.rap.rwt.RWT;
+import org.eclipse.rap.rwt.client.Client;
+import org.eclipse.rap.rwt.client.service.UrlLauncher;
+import org.eclipse.rap.rwt.service.ServiceHandler;
+import org.eclipse.rap.rwt.service.ServiceManager;
+import org.eclipse.rap.rwt.service.UISession;
+import org.eclipse.rap.rwt.service.UISessionEvent;
+import org.eclipse.rap.rwt.service.UISessionListener;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.SWTException;
+import org.eclipse.swt.widgets.Display;
+import org.eclipse.swt.widgets.FileDialog;
+import org.eclipse.swt.widgets.Shell;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.mockito.ArgumentCaptor;
+import org.mockito.MockedConstruction;
+import org.mockito.MockedStatic;
+
+class UserFileTransferTest {
+
+ private static final byte[] CONTENT = "<pipeline/>".getBytes(StandardCharsets.UTF_8);
+
+ @TempDir Path tempDirectory;
+
+ private final AtomicLong nanoTime = new AtomicLong();
+ private final List<String> urls = new ArrayList<>();
+ private final Queue<Runnable> uiCallbacks = new ArrayDeque<>();
+
+ private MockedStatic<RWT> rwt;
+ private UserFileTransfer transfer;
+ private Shell shell;
+ private Display display;
+ private ServiceManager serviceManager;
+ private UISession uiSession;
+ private HttpSession httpSession;
+ private UrlLauncher launcher;
+ private ServiceHandler handler;
+ private String serviceId;
+
+ @BeforeEach
+ void setUp() throws Exception {
+ shell = mock(Shell.class);
+ display = mock(Display.class);
+ serviceManager = mock(ServiceManager.class);
+ uiSession = mock(UISession.class);
+ httpSession = mock(HttpSession.class);
+ Client client = mock(Client.class);
+ launcher = mock(UrlLauncher.class);
+ when(shell.getDisplay()).thenReturn(display);
+ when(uiSession.getId()).thenReturn("owning-ui-session");
+ when(uiSession.getHttpSession()).thenReturn(httpSession);
+ when(httpSession.getId()).thenReturn("owning-http-session");
+ when(client.getService(UrlLauncher.class)).thenReturn(launcher);
+ when(serviceManager.getServiceHandlerUrl(anyString())).thenReturn("/hop?service=download");
+ doAnswer(
+ invocation -> {
+ urls.add(invocation.getArgument(0));
+ return null;
+ })
+ .when(launcher)
+ .openURL(anyString());
+ doAnswer(
+ invocation -> {
+ uiCallbacks.add(invocation.getArgument(0));
+ return null;
+ })
+ .when(display)
+ .asyncExec(any(Runnable.class));
+
+ rwt = mockStatic(RWT.class);
+ rwt.when(RWT::getServiceManager).thenReturn(serviceManager);
+ rwt.when(RWT::getUISession).thenReturn(uiSession);
+ rwt.when(RWT::getClient).thenReturn(client);
+ transfer = new UserFileTransfer(shell, tempDirectory, nanoTime::get);
+ ArgumentCaptor<ServiceHandler> handlerCaptor = ArgumentCaptor.forClass(ServiceHandler.class);
+ ArgumentCaptor<String> idCaptor = ArgumentCaptor.forClass(String.class);
+ verify(serviceManager).registerServiceHandler(idCaptor.capture(), handlerCaptor.capture());
+ handler = handlerCaptor.getValue();
+ serviceId = idCaptor.getValue();
+ }
+
+ @AfterEach
+ void tearDown() {
+ try {
+ if (transfer != null && !transfer.isDisposed()) {
+ transfer.dispose();
+ }
+ } finally {
+ if (rwt != null) {
+ rwt.close();
+ }
+ }
+ }
+
+ @Test
+ void servesContentWithSafeHeadersAndConsumesTheTokenOnce() throws Exception {
+ String token = download("r\u00e9sum\u00e9\r\n\".hpl", null);
+ HttpServletResponse response = mock(HttpServletResponse.class);
+ TestOutputStream output = new TestOutputStream();
+ when(response.getOutputStream()).thenReturn(output);
+
+ handler.service(request(token), response);
+
+ assertArrayEquals(CONTENT, output.bytes.toByteArray());
+ verify(response).setContentType("application/xml");
+ verify(response).setContentLengthLong(CONTENT.length);
+ verify(response).setHeader("Cache-Control", "private, no-store");
+ verify(response).setHeader("Pragma", "no-cache");
+ verify(response).setHeader("X-Content-Type-Options", "nosniff");
+ verify(response)
+ .setHeader(
+ "Content-Disposition",
+ "attachment; filename=\"r_sum____.hpl\"; filename*=UTF-8''r%C3%A9sum%C3%A9___.hpl");
+ verify(response).flushBuffer();
+ assertEquals(0, pendingFileCount());
+ assertNotFound(token);
+ }
+
+ @Test
+ void rejectsAnotherHttpSessionWithoutConsumingTheToken() throws Exception {
+ String token = download("pipeline.hpl", null);
+ HttpSession foreign = mock(HttpSession.class);
+ when(foreign.getId()).thenReturn("foreign-http-session");
+ HttpServletRequest request = request(token);
+ when(request.getSession(false)).thenReturn(foreign);
+ HttpServletResponse response = mock(HttpServletResponse.class);
+
+ handler.service(request, response);
+
+ verify(response).sendError(HttpServletResponse.SC_NOT_FOUND);
+ assertEquals(1, pendingFileCount());
+ assertDownloads(token);
+ }
+
+ @Test
+ void rejectsAnotherUiSessionWithinTheSameHttpSession() throws Exception {
+ String token = download("pipeline.hpl", null);
+ UISession foreign = mock(UISession.class);
+ when(foreign.getId()).thenReturn("foreign-ui-session");
+ rwt.when(RWT::getUISession).thenReturn(foreign);
+
+ assertNotFound(token);
+
+ assertEquals(1, pendingFileCount());
+ rwt.when(RWT::getUISession).thenReturn(uiSession);
+ assertDownloads(token);
+ }
+
+ @Test
+ void rejectsRequestsWithoutAnHttpSession() throws Exception {
+ String token = download("pipeline.hpl", null);
+ HttpServletRequest request = request(token);
+ when(request.getSession(false)).thenReturn(null);
+ HttpServletResponse response = mock(HttpServletResponse.class);
+
+ handler.service(request, response);
+
+ verify(response).sendError(HttpServletResponse.SC_NOT_FOUND);
+ verify(request, never()).getSession(true);
+ assertEquals(1, pendingFileCount());
+ }
+
+ @Test
+ void rejectsRequestsWithoutAnActiveUiSession() throws Exception {
+ String token = download("pipeline.hpl", null);
+ rwt.when(RWT::getUISession).thenThrow(new IllegalStateException("Session is gone"));
+
+ assertNotFound(token);
+
+ assertEquals(1, pendingFileCount());
+ }
+
+ @Test
+ void rejectsMissingBlankAndUnknownTokens() throws Exception {
+ download("pipeline.hpl", null);
+
+ assertNotFound(null);
+ assertNotFound(" ");
+ assertNotFound("unknown-token");
+
+ assertEquals(1, pendingFileCount());
+ }
+
+ @Test
+ void expiresAndDeletesPendingDownloadsAtTheTtlBoundary() throws Exception {
+ Runnable callback = mock(Runnable.class);
+ String token = download("pipeline.hpl", callback);
+ nanoTime.set(UserFileTransfer.DOWNLOAD_TTL_NANOS);
+
+ assertNotFound(token);
+
+ assertEquals(0, pendingFileCount());
+ verify(callback, never()).run();
+ assertTrue(uiCallbacks.isEmpty());
+ }
+
+ @Test
+ void registeringADownloadAlsoRemovesExpiredFiles() throws Exception {
+ download("expired.hpl", null);
+ nanoTime.set(UserFileTransfer.DOWNLOAD_TTL_NANOS);
+
+ String current = download("current.hpl", null);
+
+ assertEquals(1, pendingFileCount());
+ assertDownloads(current);
+ }
+
+ @Test
+ void evictsTheOldestDownloadAboveThePendingLimit() throws Exception {
+ List<String> tokens = new ArrayList<>();
+ for (int index = 0; index <= UserFileTransfer.MAX_PENDING_DOWNLOADS; index++) {
+ nanoTime.incrementAndGet();
+ tokens.add(download("pipeline-" + index + ".hpl", null));
+ }
+
+ assertEquals(UserFileTransfer.MAX_PENDING_DOWNLOADS, pendingFileCount());
+ assertNotFound(tokens.getFirst());
+ for (String token : tokens.subList(1, tokens.size())) {
+ assertDownloads(token);
+ }
+ assertEquals(0, pendingFileCount());
+ }
+
+ @Test
+ void notifiesSuccessOnlyOnTheUiThreadAfterTheResponseHasFlushed() throws Exception {
+ Runnable callback = mock(Runnable.class);
+ String token = download("pipeline.hpl", callback);
+ verify(callback, never()).run();
+ assertTrue(uiCallbacks.isEmpty());
+
+ HttpServletResponse response = mock(HttpServletResponse.class);
+ when(response.getOutputStream()).thenReturn(new TestOutputStream());
+ doAnswer(
+ invocation -> {
+ assertTrue(uiCallbacks.isEmpty());
+ return null;
+ })
+ .when(response)
+ .flushBuffer();
+ handler.service(request(token), response);
+
+ verify(callback, never()).run();
+ assertEquals(1, uiCallbacks.size());
+ uiCallbacks.remove().run();
+ verify(callback).run();
+ }
+
+ @Test
+ void failedStreamingDeletesTheFileWithoutNotifyingSuccess() throws Exception {
+ Runnable callback = mock(Runnable.class);
+ String token = download("pipeline.hpl", callback);
+ HttpServletResponse response = mock(HttpServletResponse.class);
+ TestOutputStream output = new TestOutputStream();
+ output.failWrite = true;
+ when(response.getOutputStream()).thenReturn(output);
+
+ assertThrows(IOException.class, () -> handler.service(request(token), response));
+
+ assertEquals(0, pendingFileCount());
+ assertNotFound(token);
+ verify(response, never()).flushBuffer();
+ verify(callback, never()).run();
+ assertTrue(uiCallbacks.isEmpty());
+ }
+
+ @Test
+ void failedFlushDoesNotNotifySuccess() throws Exception {
+ Runnable callback = mock(Runnable.class);
+ String token = download("pipeline.hpl", callback);
+ HttpServletResponse response = mock(HttpServletResponse.class);
+ when(response.getOutputStream()).thenReturn(new TestOutputStream());
+ doThrow(new IOException("Connection closed")).when(response).flushBuffer();
+
+ assertThrows(IOException.class, () -> handler.service(request(token), response));
+
+ assertEquals(0, pendingFileCount());
+ verify(callback, never()).run();
+ assertTrue(uiCallbacks.isEmpty());
+ }
+
+ @Test
+ void discardsQueuedCallbacksAfterSessionDisposal() throws Exception {
+ Runnable callback = mock(Runnable.class);
+ assertDownloads(download("pipeline.hpl", callback));
+
+ transfer.dispose();
+ uiCallbacks.remove().run();
+
+ verify(callback, never()).run();
+ }
+
+ @Test
+ void discardsQueuedCallbacksWhenTheShellCloses() throws Exception {
+ Runnable callback = mock(Runnable.class);
+ assertDownloads(download("pipeline.hpl", callback));
+
+ when(shell.isDisposed()).thenReturn(true);
+ uiCallbacks.remove().run();
+
+ verify(callback, never()).run();
+ }
+
+ @Test
+ void toleratesDisplayDisposalWhileSchedulingSuccess() throws Exception {
+ Runnable callback = mock(Runnable.class);
+ doThrow(new SWTException(SWT.ERROR_DEVICE_DISPOSED))
+ .when(display)
+ .asyncExec(any(Runnable.class));
+
+ assertDownloads(download("pipeline.hpl", callback));
+
+ verify(callback, never()).run();
+ assertEquals(0, pendingFileCount());
+ }
+
+ @Test
+ void cleansUpWhenTheBrowserCannotLaunchTheDownload() throws Exception {
+ doThrow(new IllegalStateException("Client is unavailable")).when(launcher).openURL(anyString());
+
+ assertThrows(
+ IllegalStateException.class,
+ () -> transfer.download("pipeline.hpl", "application/xml", CONTENT));
+
+ assertEquals(0, pendingFileCount());
+ }
+
+ @Test
+ void cleansUpWhenCreatingTheDownloadUrlFails() throws Exception {
+ when(serviceManager.getServiceHandlerUrl(anyString()))
+ .thenThrow(new IllegalStateException("Application context is closed"));
+
+ assertThrows(
+ IllegalStateException.class,
+ () -> transfer.download("pipeline.hpl", "application/xml", CONTENT));
+
+ assertEquals(0, pendingFileCount());
+ assertTrue(urls.isEmpty());
+ }
+
+ @Test
+ void sessionDisposalDuringStreamingSuppressesSuccessAndCleansFiles() throws Exception {
+ Runnable callback = mock(Runnable.class);
+ String token = download("pipeline.hpl", callback);
+ HttpServletResponse response = mock(HttpServletResponse.class);
+ when(response.getOutputStream()).thenReturn(new TestOutputStream());
+ doAnswer(
+ invocation -> {
+ transfer.dispose();
+ return null;
+ })
+ .when(response)
+ .flushBuffer();
+
+ handler.service(request(token), response);
+
+ verify(callback, never()).run();
+ assertTrue(uiCallbacks.isEmpty());
+ try (var paths = Files.list(tempDirectory)) {
+ assertEquals(0, paths.count());
+ }
+ }
+
+ @Test
+ void cleansUpWhenCopyingADownloadSourceFails() throws Exception {
+ assertThrows(
+ IOException.class,
+ () ->
+ transfer.download("pipeline.hpl", "application/xml", tempDirectory.resolve("missing")));
+
+ assertEquals(0, pendingFileCount());
+ assertTrue(urls.isEmpty());
+ }
+
+ @Test
+ void sessionDestructionUnregistersTheServiceAndRemovesAllFiles() throws Exception {
+ String token = download("pipeline.hpl", null);
+ ArgumentCaptor<UISessionListener> listener = ArgumentCaptor.forClass(UISessionListener.class);
+ verify(uiSession).addUISessionListener(listener.capture());
+
+ listener.getValue().beforeDestroy(new UISessionEvent(uiSession));
+ transfer.dispose();
+
+ assertTrue(transfer.isDisposed());
+ verify(serviceManager, times(1)).unregisterServiceHandler(serviceId);
+ try (var paths = Files.list(tempDirectory)) {
+ assertEquals(0, paths.count());
+ }
+ assertNotFound(token);
+ assertThrows(
+ IOException.class, () -> transfer.download("pipeline.hpl", "application/xml", CONTENT));
+ }
+
+ @Test
+ void stillCleansFilesIfUnregisteringTheServiceFails() throws Exception {
+ download("pipeline.hpl", null);
+ doThrow(new IllegalStateException("Application context is closed"))
+ .when(serviceManager)
+ .unregisterServiceHandler(serviceId);
+
+ assertThrows(IllegalStateException.class, transfer::dispose);
+
+ assertTrue(transfer.isDisposed());
+ try (var paths = Files.list(tempDirectory)) {
+ assertEquals(0, paths.count());
+ }
+ }
+
+ @Test
+ void acceptsOnlyARegularDirectChildWithinTheSizeLimit() throws Exception {
+ Path directory = Files.createDirectory(tempDirectory.resolve("request"));
+ Path file = Files.write(directory.resolve("pipeline.hpl"), CONTENT);
+
+ assertEquals(
+ file.toAbsolutePath().normalize(),
+ UserFileTransfer.validateUploadedFile(directory, file.toString(), CONTENT.length));
+ assertThrows(
+ IOException.class,
+ () ->
+ UserFileTransfer.validateUploadedFile(directory, file.toString(), CONTENT.length - 1));
+ assertThrows(
+ IOException.class,
+ () ->
+ UserFileTransfer.validateUploadedFile(directory, directory.toString(), CONTENT.length));
+ }
+
+ @Test
+ void rejectsOutsideNestedAndTraversalUploadPathsWithoutDeletingThem() throws Exception {
+ Path directory = Files.createDirectory(tempDirectory.resolve("request"));
+ Path outside = Files.write(tempDirectory.resolve("outside.hpl"), CONTENT);
+ Path nestedDirectory = Files.createDirectory(directory.resolve("nested"));
+ Path nested = Files.write(nestedDirectory.resolve("pipeline.hpl"), CONTENT);
+ Path siblingDirectory = Files.createDirectory(tempDirectory.resolve("request-sibling"));
+ Path sibling = Files.write(siblingDirectory.resolve("pipeline.hpl"), CONTENT);
+ for (Path file : List.of(outside, nested, sibling, directory.resolve("../outside.hpl"))) {
+ assertThrows(
+ IOException.class,
+ () -> UserFileTransfer.validateUploadedFile(directory, file.toString(), CONTENT.length));
+ }
+
+ assertArrayEquals(CONTENT, Files.readAllBytes(outside));
+ assertArrayEquals(CONTENT, Files.readAllBytes(nested));
+ assertArrayEquals(CONTENT, Files.readAllBytes(sibling));
+ }
+
+ @Test
+ void rejectsSymbolicLinkUploads() throws Exception {
+ Path directory = Files.createDirectory(tempDirectory.resolve("request"));
+ Path outside = Files.write(tempDirectory.resolve("outside.hpl"), CONTENT);
+ Path link = directory.resolve("pipeline.hpl");
+ try {
+ Files.createSymbolicLink(link, outside);
+ } catch (IOException | UnsupportedOperationException | SecurityException e) {
+ assumeTrue(false, "Symbolic link creation is unavailable: " + e.getClass().getSimpleName());
+ }
+
+ assertThrows(
+ IOException.class,
+ () -> UserFileTransfer.validateUploadedFile(directory, link.toString(), CONTENT.length));
+ assertArrayEquals(CONTENT, Files.readAllBytes(outside));
+ }
+
+ @Test
+ void usesASanitizedBasenameForUploadedFiles() {
+ assertEquals("pipeline.hpl", UserFileTransfer.safeUploadFilename("C:\\fakepath\\pipeline.hpl"));
+ assertEquals("pipeline.hpl", UserFileTransfer.safeUploadFilename("../../pipeline.hpl"));
+ assertEquals("pipeline__.hpl", UserFileTransfer.safeUploadFilename("pipeline\r\n.hpl"));
+ assertEquals("hop-file", UserFileTransfer.safeUploadFilename(null));
+ assertEquals("hop-file", UserFileTransfer.safeUploadFilename("/"));
+ }
+
+ @Test
+ void rejectsAndCleansAnOutsideUploadWithoutDeletingTheForeignFile() throws Exception {
+ Path outside = Files.write(tempDirectory.resolve("outside.hpl"), CONTENT);
+ UserFileTransfer.UploadListener listener = mock(UserFileTransfer.UploadListener.class);
+ try (MockedConstruction<FileDialog> dialogs =
+ mockConstruction(
+ FileDialog.class,
+ (dialog, context) -> when(dialog.open()).thenReturn(outside.toString()))) {
+ transfer.open("*.hpl", CONTENT.length, listener);
+
+ verify(listener).error(any(IOException.class));
+ verify(listener, never()).uploaded(anyString(), any(Path.class));
+ ArgumentCaptor<java.io.File> directory = ArgumentCaptor.forClass(java.io.File.class);
+ verify(dialogs.constructed().getFirst()).setUploadDirectory(directory.capture());
+ assertFalse(Files.exists(directory.getValue().toPath()));
+ }
+ assertArrayEquals(CONTENT, Files.readAllBytes(outside));
+ }
+
+ @Test
+ void cancellingAnUploadCleansItsRequestDirectory() throws Exception {
+ UserFileTransfer.UploadListener listener = mock(UserFileTransfer.UploadListener.class);
+ try (MockedConstruction<FileDialog> dialogs = mockConstruction(FileDialog.class)) {
+ transfer.open("*.hpl", CONTENT.length, listener);
+
+ verify(listener, never()).error(any(Exception.class));
+ verify(listener, never()).uploaded(anyString(), any(Path.class));
+ ArgumentCaptor<java.io.File> directory = ArgumentCaptor.forClass(java.io.File.class);
+ verify(dialogs.constructed().getFirst()).setUploadDirectory(directory.capture());
+ assertFalse(Files.exists(directory.getValue().toPath()));
+ }
+ }
+
+ @Test
+ void cleansTheUploadedFileEvenWhenTheListenerFails() throws Exception {
+ UserFileTransfer.UploadListener listener = mock(UserFileTransfer.UploadListener.class);
+ doThrow(new IOException("Invalid pipeline"))
+ .when(listener)
+ .uploaded(anyString(), any(Path.class));
+ List<Path> uploadDirectories = new ArrayList<>();
+ try (MockedConstruction<FileDialog> dialogs =
+ mockConstruction(
+ FileDialog.class,
+ (dialog, context) -> {
+ doAnswer(
+ invocation -> {
+ uploadDirectories.add(((java.io.File) invocation.getArgument(0)).toPath());
+ return null;
+ })
+ .when(dialog)
+ .setUploadDirectory(any(java.io.File.class));
+ when(dialog.open())
+ .thenAnswer(
+ invocation ->
+ Files.write(uploadDirectories.getFirst().resolve("pipeline.hpl"), CONTENT)
+ .toString());
+ when(dialog.getFileName()).thenReturn("C:\\fakepath\\pipeline.hpl");
+ })) {
+ transfer.open("*.hpl", CONTENT.length, listener);
+
+ assertEquals(1, dialogs.constructed().size());
+ verify(listener)
+ .uploaded("pipeline.hpl", uploadDirectories.getFirst().resolve("pipeline.hpl"));
+ verify(listener).error(any(IOException.class));
+ }
+ assertFalse(Files.exists(uploadDirectories.getFirst()));
+ }
+
+ private String download(String filename, Runnable onSuccess) throws IOException {
+ transfer.download(filename, "application/xml", CONTENT, onSuccess);
+ String url = urls.getLast();
+ return url.substring(url.indexOf("&token=") + "&token=".length());
+ }
+
+ private HttpServletRequest request(String token) {
+ HttpServletRequest request = mock(HttpServletRequest.class);
+ when(request.getSession(false)).thenReturn(httpSession);
+ when(request.getParameter("token")).thenReturn(token);
+ return request;
+ }
+
+ private void assertNotFound(String token) throws Exception {
+ HttpServletResponse response = mock(HttpServletResponse.class);
+ handler.service(request(token), response);
+ verify(response).sendError(HttpServletResponse.SC_NOT_FOUND);
+ verify(response, never()).getOutputStream();
+ }
+
+ private void assertDownloads(String token) throws Exception {
+ HttpServletResponse response = mock(HttpServletResponse.class);
+ TestOutputStream output = new TestOutputStream();
+ when(response.getOutputStream()).thenReturn(output);
+ handler.service(request(token), response);
+ assertArrayEquals(CONTENT, output.bytes.toByteArray());
+ verify(response).flushBuffer();
+ }
+
+ private long pendingFileCount() throws IOException {
+ try (var files = Files.walk(tempDirectory)) {
+ return files.filter(Files::isRegularFile).count();
+ }
+ }
+
+ private static class TestOutputStream extends ServletOutputStream {
+ private final ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+ private boolean failWrite;
+
+ @Override
+ public void write(int value) throws IOException {
+ if (failWrite) {
+ throw new IOException("Connection closed");
+ }
+ bytes.write(value);
+ }
+
+ @Override
+ public boolean isReady() {
+ return true;
+ }
+
+ @Override
+ public void setWriteListener(WriteListener listener) {}
+ }
+}
diff --git a/ui/src/main/java/org/apache/hop/ui/core/gui/GuiMenuWidgets.java b/ui/src/main/java/org/apache/hop/ui/core/gui/GuiMenuWidgets.java
index 22c531aebf..c7392c7cc6 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/gui/GuiMenuWidgets.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/gui/GuiMenuWidgets.java
@@ -48,12 +48,18 @@ import org.eclipse.swt.widgets.Shell;
public class GuiMenuWidgets extends BaseGuiWidgets {
private Map<String, MenuItem> menuItemMap;
+ private Map<String, MenuItem> menuSeparatorMap;
+ private Map<String, Menu> menuParentMap;
+ private Map<String, GuiMenuItem> menuDefinitionMap;
private Map<String, KeyboardShortcut> shortcutMap;
private Map<String, Boolean> menuEnabledMap;
public GuiMenuWidgets() {
super(UUID.randomUUID().toString());
this.menuItemMap = new HashMap<>();
+ this.menuSeparatorMap = new HashMap<>();
+ this.menuParentMap = new HashMap<>();
+ this.menuDefinitionMap = new HashMap<>();
this.shortcutMap = new HashMap<>();
this.menuEnabledMap = new HashMap<>();
}
@@ -151,40 +157,7 @@ public class GuiMenuWidgets extends BaseGuiWidgets {
GuiRegistry.getInstance().findChildGuiMenuItems(root, guiMenuItem.getId());
if (children.isEmpty()) {
-
- if (guiMenuItem.isAddingSeparator()) {
- new MenuItem(parentMenu, SWT.SEPARATOR);
- }
-
- menuItem = new MenuItem(parentMenu, SWT.PUSH);
- initMenuItem(menuItem, guiMenuItem);
-
- // Call the method to which the GuiWidgetElement annotation belongs.
- //
- menuItem.addListener(
- SWT.Selection,
- e -> {
- try {
- executeMenuItem(guiMenuItem, instanceId);
- } catch (Exception ex) {
- Throwable cause = ex.getCause() != null ? ex.getCause() : ex;
- String msg = cause.getMessage();
- if (msg == null || msg.isEmpty()) {
- msg = cause.getClass().getSimpleName();
- }
- LogChannel.UI.logError(
- "Unable to call method "
- + guiMenuItem.getListenerMethod()
- + " in singleton "
- + guiMenuItem.getListenerClassName()
- + " : "
- + msg,
- ex);
- }
- });
-
- menuItemMap.put(guiMenuItem.getId(), menuItem);
- menuEnabledMap.put(guiMenuItem.getId(), true);
+ addLeafMenuWidget(parentMenu, guiMenuItem, parentMenu.getItemCount());
} else {
// We have a bunch of children, so we want to create a new drop-down menu in the parent menu
@@ -212,6 +185,48 @@ public class GuiMenuWidgets extends BaseGuiWidgets {
}
}
+ private void addLeafMenuWidget(
+ Menu parentMenu, GuiMenuItem guiMenuItem, int requestedInsertionIndex) {
+ int insertionIndex = Math.max(0, Math.min(requestedInsertionIndex, parentMenu.getItemCount()));
+ if (guiMenuItem.isAddingSeparator()) {
+ MenuItem separator = new MenuItem(parentMenu, SWT.SEPARATOR, insertionIndex++);
+ menuSeparatorMap.put(guiMenuItem.getId(), separator);
+ }
+
+ MenuItem menuItem = new MenuItem(parentMenu, SWT.PUSH, insertionIndex);
+ initMenuItem(menuItem, guiMenuItem);
+ menuItem.setEnabled(menuEnabledMap.getOrDefault(guiMenuItem.getId(), true));
+
+ // Call the method to which the GuiWidgetElement annotation belongs.
+ //
+ menuItem.addListener(
+ SWT.Selection,
+ e -> {
+ try {
+ executeMenuItem(guiMenuItem, instanceId);
+ } catch (Exception ex) {
+ Throwable cause = ex.getCause() != null ? ex.getCause() : ex;
+ String msg = cause.getMessage();
+ if (msg == null || msg.isEmpty()) {
+ msg = cause.getClass().getSimpleName();
+ }
+ LogChannel.UI.logError(
+ "Unable to call method "
+ + guiMenuItem.getListenerMethod()
+ + " in singleton "
+ + guiMenuItem.getListenerClassName()
+ + " : "
+ + msg,
+ ex);
+ }
+ });
+
+ menuItemMap.put(guiMenuItem.getId(), menuItem);
+ menuParentMap.put(guiMenuItem.getId(), parentMenu);
+ menuDefinitionMap.put(guiMenuItem.getId(), guiMenuItem);
+ menuEnabledMap.putIfAbsent(guiMenuItem.getId(), true);
+ }
+
public static void executeMenuItem(GuiMenuItem guiMenuItem, String instanceId) throws Exception {
Object parentObject =
findGuiPluginInstance(
@@ -298,28 +313,72 @@ public class GuiMenuWidgets extends BaseGuiWidgets {
return menuItemMap.get(id);
}
+ /** Find the menu item with the given ID and remove it together with its owned separator. */
+ public void removeMenuItem(String id) {
+ menuEnabledMap.put(id, false);
+ disposeMenuItem(id);
+ }
+
+ private void disposeMenuItem(String id) {
+ MenuItem menuItem = menuItemMap.remove(id);
+ MenuItem separator = menuSeparatorMap.remove(id);
+ if (separator != null && !separator.isDisposed()) {
+ separator.dispose();
+ }
+ if (menuItem != null && !menuItem.isDisposed()) {
+ menuItem.dispose();
+ }
+ }
+
/**
- * Find the menu item with the given ID and remove it from the menu, together with the separator
- * that was created in front of it. Use this for menu items which simply don't apply to the
- * environment we're running in.
+ * Show or hide a leaf menu item. RAP's SWT MenuItem does not expose setVisible, so hidden items
+ * are disposed and recreated from their registered definition when they become available again.
*
- * @param id The ID to look for
+ * @param id the menu item ID
+ * @param visible whether the item should be present in the menu
*/
- public void removeMenuItem(String id) {
- MenuItem menuItem = menuItemMap.remove(id);
- menuEnabledMap.remove(id);
- if (menuItem == null || menuItem.isDisposed()) {
+ public void setMenuItemVisible(String id, boolean visible) {
+ MenuItem menuItem = menuItemMap.get(id);
+ if (!visible) {
+ disposeMenuItem(id);
+ return;
+ }
+ if (menuItem != null && !menuItem.isDisposed()) {
+ return;
+ }
+
+ GuiMenuItem definition = menuDefinitionMap.get(id);
+ Menu parentMenu = menuParentMap.get(id);
+ if (definition == null || parentMenu == null || parentMenu.isDisposed()) {
return;
}
- Menu parentMenu = menuItem.getParent();
- int index = parentMenu.indexOf(menuItem);
- if (index > 0) {
- MenuItem previousItem = parentMenu.getItem(index - 1);
- if ((previousItem.getStyle() & SWT.SEPARATOR) != 0) {
- previousItem.dispose();
+ addLeafMenuWidget(parentMenu, definition, findInsertionIndex(parentMenu, definition));
+ }
+
+ private int findInsertionIndex(Menu parentMenu, GuiMenuItem definition) {
+ GuiMenuItem nextDefinition = null;
+ MenuItem nextMenuItem = null;
+ for (Map.Entry<String, MenuItem> entry : menuItemMap.entrySet()) {
+ MenuItem candidate = entry.getValue();
+ GuiMenuItem candidateDefinition = menuDefinitionMap.get(entry.getKey());
+ if (candidate == null
+ || candidate.isDisposed()
+ || candidate.getParent() != parentMenu
+ || candidateDefinition == null
+ || candidateDefinition.compareTo(definition) <= 0) {
+ continue;
}
+ if (nextDefinition == null || candidateDefinition.compareTo(nextDefinition) < 0) {
+ nextDefinition = candidateDefinition;
+ nextMenuItem = candidate;
+ }
+ }
+ if (nextMenuItem == null) {
+ return parentMenu.getItemCount();
}
- menuItem.dispose();
+ MenuItem separator = menuSeparatorMap.get(nextDefinition.getId());
+ return parentMenu.indexOf(
+ separator != null && !separator.isDisposed() ? separator : nextMenuItem);
}
public KeyboardShortcut findKeyboardShortcut(String id) {
diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/HopGui.java b/ui/src/main/java/org/apache/hop/ui/hopgui/HopGui.java
index 3605a396c2..b19c9b3f45 100644
--- a/ui/src/main/java/org/apache/hop/ui/hopgui/HopGui.java
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/HopGui.java
@@ -95,6 +95,7 @@ import org.apache.hop.partition.PartitionSchema;
import org.apache.hop.server.HopServerMeta;
import org.apache.hop.ui.core.ConstUi;
import org.apache.hop.ui.core.PropsUi;
+import org.apache.hop.ui.core.bus.HopGuiEvents;
import org.apache.hop.ui.core.bus.HopGuiEventsHandler;
import org.apache.hop.ui.core.dialog.ErrorDialog;
import org.apache.hop.ui.core.dialog.HopDescribedVariablesDialog;
@@ -210,6 +211,20 @@ public class HopGui
public static final String ID_MAIN_MENU_FILE_LOG_OFF = "10850-menu-file-log-off";
public static final String ID_MAIN_MENU_FILE_EXIT = "10900-menu-file-exit";
+ public static final String ID_MAIN_MENU_FILE_USER_NEW = "15010-menu-file-user-new";
+ public static final String ID_MAIN_MENU_FILE_USER_OPEN = "15020-menu-file-user-open";
+ public static final String ID_MAIN_MENU_FILE_USER_SAVE = "15030-menu-file-user-save";
+ public static final String ID_MAIN_MENU_FILE_USER_SAVE_AS = "15040-menu-file-user-save-as";
+ public static final String ID_MAIN_MENU_FILE_USER_EXPORT_TO_SVG =
+ "15050-menu-file-user-export-svg";
+ public static final String ID_MAIN_MENU_FILE_USER_EXPORT_PROJECT =
+ "15060-menu-file-user-export-project";
+ public static final String ID_MAIN_MENU_FILE_USER_IMPORT_KETTLE =
+ "15070-menu-file-user-import-kettle-zip";
+
+ private static final String ID_MAIN_MENU_PROJECT_EXPORT_PLUGIN = "10055-menu-file-export-to-svg";
+ private static final String ID_MAIN_MENU_KETTLE_IMPORT_PLUGIN = "10060-menu-tools-import";
+
public static final String ID_MAIN_MENU_EDIT_PARENT_ID = "20000-menu-edit";
public static final String ID_MAIN_MENU_EDIT_UNDO = "20010-menu-edit-undo";
public static final String ID_MAIN_MENU_EDIT_REDO = "20020-menu-edit-redo";
@@ -1256,8 +1271,15 @@ public class HopGui
mainMenu = new Menu(shell, SWT.BAR);
mainMenuWidgets.createMenuWidgets(ID_MAIN_MENU, shell, mainMenu);
+ updateWebUserFileMenuItems();
mainMenuWidgets.ensureShortcutPluginInstancesRegistered();
+ eventsHandler.addEventListener(
+ getClass().getName() + "WebProjectMenuItems",
+ event -> updateWebUserFileMenuItems(),
+ HopGuiEvents.ProjectActivated.name(),
+ HopGuiEvents.ProjectDeactivated.name());
+
if (EnvironmentUtils.getInstance().isWeb()) {
mainMenuWidgets.enableMenuItem(HopGui.ID_MAIN_MENU_FILE_EXIT, false);
} else if (areSessionControlsVisible()) {
@@ -2331,6 +2353,7 @@ public class HopGui
fileType, handler, ID_MAIN_MENU_FILE_SAVE, IHopFileType.CAPABILITY_SAVE, changed);
mainMenuWidgets.enableMenuItem(
fileType, handler, ID_MAIN_MENU_FILE_SAVE_AS, IHopFileType.CAPABILITY_SAVE_AS);
+
mainMenuWidgets.enableMenuItem(
fileType, handler, ID_MAIN_MENU_FILE_EXPORT_TO_SVG, IHopFileType.CAPABILITY_EXPORT_TO_SVG);
mainMenuWidgets.enableMenuItem(
@@ -2390,6 +2413,60 @@ public class HopGui
HopSecurity.allows(Permission.FILE_CREATE) || HopSecurity.allows(Permission.METADATA_WRITE);
mainMenuWidgets.enableMenuItem(ID_MAIN_MENU_FILE_NEW, canCreate);
mainToolbarWidgets.enableToolbarItem(ID_MAIN_TOOLBAR_NEW, canCreate);
+
+ updateWebUserFileMenuItems();
+ }
+
+ private void updateWebUserFileMenuItems() {
+ if (!EnvironmentUtils.getInstance().isWeb()) {
+ return;
+ }
+
+ boolean projectExportAvailable =
+ GuiRegistry.getInstance().findGuiMenuItem(ID_MAIN_MENU, ID_MAIN_MENU_PROJECT_EXPORT_PLUGIN)
+ != null;
+ boolean kettleImportAvailable =
+ GuiRegistry.getInstance().findGuiMenuItem(ID_MAIN_MENU, ID_MAIN_MENU_KETTLE_IMPORT_PLUGIN)
+ != null;
+
+ boolean showProjectExport =
+ HopWebUserFileMenuState.shouldShowProjectExport(
+ variables.resolve(Const.VAR_PROJECT_HOME),
+ projectExportAvailable,
+ HopSecurity.allows(Permission.FILE_EXPORT));
+ boolean showKettleImport =
+ HopWebUserFileMenuState.shouldShowKettleImport(
+ kettleImportAvailable,
+ HopSecurity.allows(Permission.FILE_CREATE),
+ HopSecurity.allows(Permission.METADATA_WRITE));
+ boolean showSvgExport =
+ HopWebUserFileMenuState.shouldShowSvgExport(
+ getActivePipelineGraph() != null,
+ getActiveWorkflowGraph() != null,
+ HopSecurity.allows(Permission.FILE_EXPORT));
+
+ boolean canDownload =
+ HopWebUserFileMenuState.canDownload(
+ getActiveFileTypeHandler(), HopSecurity.allows(Permission.FILE_SAVE));
+ mainMenuWidgets.enableMenuItem(ID_MAIN_MENU_FILE_USER_SAVE, canDownload);
+ mainMenuWidgets.enableMenuItem(ID_MAIN_MENU_FILE_USER_SAVE_AS, canDownload);
+
+ // Keep the enabled-state map in sync for keyboard/menu dispatch and hide unavailable actions.
+ // File Server keeps the same actions, but shares the web-only visibility rules for SVG.
+ mainMenuWidgets.setMenuItemVisible(ID_MAIN_MENU_FILE_EXPORT_TO_SVG, showSvgExport);
+ mainMenuWidgets.enableMenuItem(ID_MAIN_MENU_FILE_EXPORT_TO_SVG, showSvgExport);
+ mainMenuWidgets.setMenuItemVisible(ID_MAIN_MENU_FILE_USER_EXPORT_TO_SVG, showSvgExport);
+ mainMenuWidgets.enableMenuItem(ID_MAIN_MENU_FILE_USER_EXPORT_TO_SVG, showSvgExport);
+ mainMenuWidgets.enableMenuItem(
+ ID_MAIN_MENU_FILE_USER_NEW,
+ HopSecurity.allows(Permission.FILE_CREATE)
+ || HopSecurity.allows(Permission.METADATA_WRITE));
+ mainMenuWidgets.enableMenuItem(
+ ID_MAIN_MENU_FILE_USER_OPEN, HopSecurity.allows(Permission.FILE_VIEW));
+ mainMenuWidgets.enableMenuItem(ID_MAIN_MENU_FILE_USER_EXPORT_PROJECT, showProjectExport);
+ mainMenuWidgets.enableMenuItem(ID_MAIN_MENU_FILE_USER_IMPORT_KETTLE, showKettleImport);
+ mainMenuWidgets.setMenuItemVisible(ID_MAIN_MENU_FILE_USER_EXPORT_PROJECT, showProjectExport);
+ mainMenuWidgets.setMenuItemVisible(ID_MAIN_MENU_FILE_USER_IMPORT_KETTLE, showKettleImport);
}
public IHopFileTypeHandler getActiveFileTypeHandler() {
diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/HopWebUserFileMenuState.java b/ui/src/main/java/org/apache/hop/ui/hopgui/HopWebUserFileMenuState.java
new file mode 100644
index 0000000000..31fe51d64a
--- /dev/null
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/HopWebUserFileMenuState.java
@@ -0,0 +1,55 @@
+/*
+ * 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.hop.ui.hopgui;
+
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hop.core.Const;
+import org.apache.hop.pipeline.PipelineMeta;
+import org.apache.hop.ui.hopgui.file.IHopFileTypeHandler;
+import org.apache.hop.workflow.WorkflowMeta;
+
+final class HopWebUserFileMenuState {
+
+ private HopWebUserFileMenuState() {}
+
+ static boolean canDownload(IHopFileTypeHandler handler, boolean saveAllowed) {
+ return saveAllowed
+ && handler != null
+ && (handler.getSubject() instanceof PipelineMeta
+ || handler.getSubject() instanceof WorkflowMeta);
+ }
+
+ static boolean shouldShowProjectExport(
+ String projectHome, boolean pluginAvailable, boolean exportAllowed) {
+ return StringUtils.isNotBlank(projectHome)
+ && !Const.VAR_PROJECT_HOME.equals(projectHome)
+ && pluginAvailable
+ && exportAllowed;
+ }
+
+ static boolean shouldShowKettleImport(
+ boolean pluginAvailable, boolean fileCreateAllowed, boolean metadataWriteAllowed) {
+ return pluginAvailable && fileCreateAllowed && metadataWriteAllowed;
+ }
+
+ static boolean shouldShowSvgExport(
+ boolean pipelineOpen, boolean workflowOpen, boolean exportAllowed) {
+ return (pipelineOpen || workflowOpen) && exportAllowed;
+ }
+}
diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/delegates/HopGuiFileDelegate.java b/ui/src/main/java/org/apache/hop/ui/hopgui/delegates/HopGuiFileDelegate.java
index 0fc981b990..61d0d6a4ed 100644
--- a/ui/src/main/java/org/apache/hop/ui/hopgui/delegates/HopGuiFileDelegate.java
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/delegates/HopGuiFileDelegate.java
@@ -55,6 +55,7 @@ import org.apache.hop.ui.hopgui.HopGui;
import org.apache.hop.ui.hopgui.file.HopFileTypeRegistry;
import org.apache.hop.ui.hopgui.file.IHopFileType;
import org.apache.hop.ui.hopgui.file.IHopFileTypeHandler;
+import org.apache.hop.ui.hopgui.file.empty.EmptyFileType;
import org.apache.hop.ui.hopgui.file.pipeline.HopGuiPipelineGraph;
import org.apache.hop.ui.hopgui.file.workflow.HopGuiWorkflowGraph;
import org.apache.hop.ui.hopgui.perspective.IHopPerspective;
@@ -309,30 +310,40 @@ public class HopGuiFileDelegate {
public void closeAllFiles() {
this.isClosing = true;
- for (IHopPerspective perspective : hopGui.getPerspectiveManager().getPerspectives()) {
- List<TabItemHandler> tabItemHandlers = perspective.getItems();
- if (tabItemHandlers != null) {
- // Copy the list to avoid changing the list we're editing (closing items)
- //
- List<TabItemHandler> handlers = new ArrayList<>(tabItemHandlers);
- for (TabItemHandler tabItemHandler : handlers) {
- IHopFileTypeHandler typeHandler = tabItemHandler.getTypeHandler();
- typeHandler.close();
+ try {
+ for (IHopPerspective perspective : hopGui.getPerspectiveManager().getPerspectives()) {
+ List<TabItemHandler> tabItemHandlers = perspective.getItems();
+ if (tabItemHandlers != null) {
+ // Copy the list to avoid changing the list we're editing (closing items)
+ //
+ List<TabItemHandler> handlers = new ArrayList<>(tabItemHandlers);
+ for (TabItemHandler tabItemHandler : handlers) {
+ IHopFileTypeHandler typeHandler = tabItemHandler.getTypeHandler();
+ typeHandler.close();
+ }
}
}
- }
- // Execution Information tabs are not IHopFileTypeHandlers, so they never appear in
- // getItems(). Close them explicitly so project switches (and File → Close All) do not leave
- // viewers from the previous project open. Callers that need to remember tabs (project switch)
- // must call ExecutionPerspective.saveState() first.
- //
- ExecutionPerspective executionPerspective = ExecutionPerspective.getInstance();
- if (executionPerspective != null) {
- executionPerspective.closeAllTabs();
- }
+ // Execution Information tabs are not IHopFileTypeHandlers, so they never appear in
+ // getItems(). Close them explicitly so project switches (and File → Close All) do not leave
+ // viewers from the previous project open. Callers that need to remember tabs (project switch)
+ // must call ExecutionPerspective.saveState() first.
+ //
+ ExecutionPerspective executionPerspective = ExecutionPerspective.getInstance();
+ if (executionPerspective != null) {
+ executionPerspective.closeAllTabs();
+ }
+ } finally {
+ this.isClosing = false;
- this.isClosing = false;
+ IHopFileTypeHandler activeHandler = hopGui.getActiveFileTypeHandler();
+ if (activeHandler == null) {
+ hopGui.handleFileCapabilities(new EmptyFileType(), false, false, false);
+ } else {
+ hopGui.handleFileCapabilities(
+ activeHandler.getFileType(), activeHandler, activeHandler.hasChanged(), false, false);
+ }
+ }
}
/** When the app exits we need to see if all open files are saved in all perspectives... */
diff --git a/ui/src/main/resources/org/apache/hop/ui/hopgui/messages/messages_en_US.properties b/ui/src/main/resources/org/apache/hop/ui/hopgui/messages/messages_en_US.properties
index 8d474ca9e9..c36babb2f9 100644
--- a/ui/src/main/resources/org/apache/hop/ui/hopgui/messages/messages_en_US.properties
+++ b/ui/src/main/resources/org/apache/hop/ui/hopgui/messages/messages_en_US.properties
@@ -96,6 +96,19 @@ HopGui.Menu.File=&File
HopGui.Menu.File.Close=&Close
HopGui.Menu.File.Close.All=Close All
HopGui.Menu.File.ExportToSVG=Export to SVG
+HopGui.Menu.File.ExportProjectZip=Export Project ZIP
+HopGui.Menu.File.ImportKettleZip=Import Kettle/PDI ZIP
+HopGui.Menu.File.User=File Browser
+HopGui.FileBrowser.Error.Title=File Browser
+HopGui.FileBrowser.Error.Upload=Unable to upload the selected file. Verify its type and size and try again.
+HopGui.FileBrowser.Error.Open=Unable to open the selected Hop pipeline or workflow.
+HopGui.FileBrowser.Error.Save=Unable to download the active file.
+HopGui.FileBrowser.Error.NoActiveFile=Select a pipeline or workflow before downloading it.
+HopGui.FileBrowser.Error.ExportSvg=Unable to export the active pipeline or workflow to SVG.
+HopGui.FileBrowser.Error.ExportProject=Unable to export the current project to the browser.
+HopGui.FileBrowser.Error.ImportKettle=Unable to import the selected Kettle/PDI ZIP file. Verify the archive and try again.
+HopGui.FileBrowser.SaveAs.Title=Download As
+HopGui.FileBrowser.SaveAs.Label=File name
HopGui.Menu.File.New=&New
HopGui.Menu.File.Open=&Open...
HopGui.Menu.File.Open.Recent=Open Recent...
diff --git a/ui/src/main/resources/org/apache/hop/ui/hopgui/messages/messages_es_ES.properties b/ui/src/main/resources/org/apache/hop/ui/hopgui/messages/messages_es_ES.properties
index 5ce0ad92e6..f0a9ffd3fd 100644
--- a/ui/src/main/resources/org/apache/hop/ui/hopgui/messages/messages_es_ES.properties
+++ b/ui/src/main/resources/org/apache/hop/ui/hopgui/messages/messages_es_ES.properties
@@ -97,6 +97,19 @@ HopGui.Menu.File=&Fichero
HopGui.Menu.File.Close=&Cerrar
HopGui.Menu.File.Close.All=Cerrar Todo
HopGui.Menu.File.ExportToSVG=Exportar a SVG
+HopGui.Menu.File.ExportProjectZip=Exportar proyecto ZIP
+HopGui.Menu.File.ImportKettleZip=Importar ZIP de Kettle/PDI
+HopGui.Menu.File.User=File Browser
+HopGui.FileBrowser.Error.Title=Navegador de archivos
+HopGui.FileBrowser.Error.Upload=No se pudo subir el archivo seleccionado. Verifique su tipo y tamaño e inténtelo de nuevo.
+HopGui.FileBrowser.Error.Open=No se pudo abrir el pipeline o workflow de Hop seleccionado.
+HopGui.FileBrowser.Error.Save=No se pudo descargar el archivo activo.
+HopGui.FileBrowser.Error.NoActiveFile=Seleccione un pipeline o workflow antes de descargarlo.
+HopGui.FileBrowser.Error.ExportSvg=No se pudo exportar el pipeline o workflow activo a SVG.
+HopGui.FileBrowser.Error.ExportProject=No se pudo exportar el proyecto actual al navegador.
+HopGui.FileBrowser.Error.ImportKettle=No se pudo importar el ZIP de Kettle/PDI seleccionado. Verifique el archivo e inténtelo de nuevo.
+HopGui.FileBrowser.SaveAs.Title=Descargar como
+HopGui.FileBrowser.SaveAs.Label=Nombre del archivo
HopGui.Menu.File.New=&Nuevo
HopGui.Menu.File.Open=&Abrir
HopGui.Menu.File.Open.Recent=Abrir reciente...
diff --git a/plugins/misc/import/src/main/resources/kettle-logo.svg b/ui/src/main/resources/ui/images/kettle-logo.svg
similarity index 54%
rename from plugins/misc/import/src/main/resources/kettle-logo.svg
rename to ui/src/main/resources/ui/images/kettle-logo.svg
index 22f3ed2e05..3fa73ab55b 100644
--- a/plugins/misc/import/src/main/resources/kettle-logo.svg
+++ b/ui/src/main/resources/ui/images/kettle-logo.svg
@@ -1,10 +1,26 @@
-<?xml version="1.0" encoding="utf-8"?>
-<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
-<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
-<svg version="1.1" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px"
- viewBox="0 0 16 16" enable-background="new 0 0 16 16">
- <rect fill="none" width="16" height="16"/>
- <rect fill="#005CA7" class="cls-1" width="16" height="16" rx="2" ry="2"/>
- <path fill="#FEFEFE"
- d="M13,8.11a.94.94,0,0,0-.82.48L8.83,8s0-.07,0-.11a1.68,1.68,0,0,0-.11-.59L12,4.95a1.13,1.13,0,1,0-.36-.51L8.41,6.77a1.69,1.69,0,0,0-.64-.45l.19-1.08a1,1,0,1,0-.62-.06l-.18,1A1.68,1.68,0,0,0,6,6.62L4.43,5.13A1.25,1.25,0,1,0,4,5.59L5.65,7.11a1.69,1.69,0,0,0,0,1.54l-1,.71A.94.94,0,1,0,5,9.88L6,9.15a1.69,1.69,0,0,0,1.64.35L8.81,11a1.26,1.26,0,1,0,.51-.37L8.22,9.2a1.69,1.69,0,0,0,.46-.59l3.38.59A.94.94,0,1,0,13,8.11Z"/>
-</svg>
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ 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.
+-->
+<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px"
+ viewBox="0 0 16 16" enable-background="new 0 0 16 16">
+ <rect fill="none" width="16" height="16"/>
+ <rect fill="#005CA7" class="cls-1" width="16" height="16" rx="2" ry="2"/>
+ <path fill="#FEFEFE"
+ d="M13,8.11a.94.94,0,0,0-.82.48L8.83,8s0-.07,0-.11a1.68,1.68,0,0,0-.11-.59L12,4.95a1.13,1.13,0,1,0-.36-.51L8.41,6.77a1.69,1.69,0,0,0-.64-.45l.19-1.08a1,1,0,1,0-.62-.06l-.18,1A1.68,1.68,0,0,0,6,6.62L4.43,5.13A1.25,1.25,0,1,0,4,5.59L5.65,7.11a1.69,1.69,0,0,0,0,1.54l-1,.71A.94.94,0,1,0,5,9.88L6,9.15a1.69,1.69,0,0,0,1.64.35L8.81,11a1.26,1.26,0,1,0,.51-.37L8.22,9.2a1.69,1.69,0,0,0,.46-.59l3.38.59A.94.94,0,1,0,13,8.11Z"/>
+</svg>
diff --git a/ui/src/test/java/org/apache/hop/ui/hopgui/HopGuiWebUserFileMenuTest.java b/ui/src/test/java/org/apache/hop/ui/hopgui/HopGuiWebUserFileMenuTest.java
new file mode 100644
index 0000000000..072d67718c
--- /dev/null
+++ b/ui/src/test/java/org/apache/hop/ui/hopgui/HopGuiWebUserFileMenuTest.java
@@ -0,0 +1,75 @@
+/*
+ * 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.hop.ui.hopgui;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import org.apache.hop.core.Const;
+import org.apache.hop.pipeline.PipelineMeta;
+import org.apache.hop.ui.hopgui.file.IHopFileTypeHandler;
+import org.apache.hop.workflow.WorkflowMeta;
+import org.junit.jupiter.api.Test;
+
+class HopGuiWebUserFileMenuTest {
+
+ @Test
+ void browserSaveAllowsUnchangedFlowsButRejectsOtherFileTypesAndDeniedPermission() {
+ IHopFileTypeHandler handler = mock(IHopFileTypeHandler.class);
+ when(handler.hasChanged()).thenReturn(false);
+ when(handler.getSubject()).thenReturn(new PipelineMeta());
+ assertTrue(HopWebUserFileMenuState.canDownload(handler, true));
+ when(handler.getSubject()).thenReturn(new WorkflowMeta());
+ assertTrue(HopWebUserFileMenuState.canDownload(handler, true));
+ assertFalse(HopWebUserFileMenuState.canDownload(handler, false));
+ when(handler.getSubject()).thenReturn("text editor");
+ assertFalse(HopWebUserFileMenuState.canDownload(handler, true));
+ assertFalse(HopWebUserFileMenuState.canDownload(null, true));
+ }
+
+ @Test
+ void projectExportRequiresAnActiveProjectPluginAndPermission() {
+ assertFalse(HopWebUserFileMenuState.shouldShowProjectExport(null, true, true));
+ assertFalse(HopWebUserFileMenuState.shouldShowProjectExport("", true, true));
+ assertFalse(
+ HopWebUserFileMenuState.shouldShowProjectExport(Const.VAR_PROJECT_HOME, true, true));
+ assertFalse(HopWebUserFileMenuState.shouldShowProjectExport("project", false, true));
+ assertFalse(HopWebUserFileMenuState.shouldShowProjectExport("project", true, false));
+ assertTrue(HopWebUserFileMenuState.shouldShowProjectExport("project", true, true));
+ }
+
+ @Test
+ void kettleImportRequiresThePluginAndWritePermissions() {
+ assertFalse(HopWebUserFileMenuState.shouldShowKettleImport(false, true, true));
+ assertFalse(HopWebUserFileMenuState.shouldShowKettleImport(true, false, true));
+ assertFalse(HopWebUserFileMenuState.shouldShowKettleImport(true, true, false));
+ assertTrue(HopWebUserFileMenuState.shouldShowKettleImport(true, true, true));
+ }
+
+ @Test
+ void svgExportRequiresAnOpenFlowAndExportPermission() {
+ assertFalse(HopWebUserFileMenuState.shouldShowSvgExport(false, false, true));
+ assertFalse(HopWebUserFileMenuState.shouldShowSvgExport(true, false, false));
+ assertFalse(HopWebUserFileMenuState.shouldShowSvgExport(false, true, false));
+ assertTrue(HopWebUserFileMenuState.shouldShowSvgExport(true, false, true));
+ assertTrue(HopWebUserFileMenuState.shouldShowSvgExport(false, true, true));
+ }
+}