From 2df3a9a9f6fca30b2f149d96afa4b9114ec677e1 Mon Sep 17 00:00:00 2001 From: max199 Date: Mon, 27 Jul 2026 21:18:05 +0300 Subject: [PATCH] Add architecture-aware Spark updates --- README.md | 17 ++ pom.xml | 6 + .../spark/manager/SparkDownloadServlet.java | 23 ++- .../spark/manager/SparkVersionManager.java | 161 +++++++++++++++--- .../manager/SparkVersionManagerTest.java | 82 +++++++++ 5 files changed, 252 insertions(+), 37 deletions(-) create mode 100644 src/test/java/org/jivesoftware/openfire/plugin/spark/manager/SparkVersionManagerTest.java diff --git a/README.md b/README.md index 5fd0a65ec..bbeb1d699 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,23 @@ Allows to specify XMPP clients that are allowed to connect to the server, which [![Build Status](https://github.com/igniterealtime/openfire-clientControl-plugin/workflows/Java%20CI/badge.svg)](https://github.com/igniterealtime/openfire-clientControl-plugin/actions) +## Architecture-specific Spark packages + +The existing `spark..client` properties remain the defaults. Newer Spark clients can additionally request a package that matches their runtime architecture. The plugin recognizes `windows`, `mac` and `linux` together with `x86`, `x64` and `arm64`. + +For example: + +- `spark.windows.x86.client` +- `spark.windows.x64.client` +- `spark.windows.arm64.client` +- `spark.mac.x64.client` +- `spark.mac.arm64.client` +- `spark.linux.x86.client` +- `spark.linux.x64.client` +- `spark.linux.arm64.client` + +If an architecture-specific property is absent or blank, the plugin falls back to the corresponding generic property, such as `spark.windows.client`, `spark.mac.client` or `spark.linux.client`. This keeps existing deployments and older Spark clients compatible. + ## Reporting Issues Issues may be reported to the [forums](https://discourse.igniterealtime.org) or via this repo's [Github Issues](https://github.com/igniterealtime/openfire-clientControl-plugin). diff --git a/pom.xml b/pom.xml index bb46bfd89..dda276484 100644 --- a/pom.xml +++ b/pom.xml @@ -31,6 +31,12 @@ commons-fileupload 1.6.0 + + junit + junit + 4.13.2 + test + diff --git a/src/java/org/jivesoftware/openfire/plugin/spark/manager/SparkDownloadServlet.java b/src/java/org/jivesoftware/openfire/plugin/spark/manager/SparkDownloadServlet.java index 6c291861f..e4ffdd045 100644 --- a/src/java/org/jivesoftware/openfire/plugin/spark/manager/SparkDownloadServlet.java +++ b/src/java/org/jivesoftware/openfire/plugin/spark/manager/SparkDownloadServlet.java @@ -89,21 +89,26 @@ else if(clientFile.getName().endsWith(".tar.gz") && "linux".equals(os)){ } private void sendClientBuild(HttpServletResponse resp, final String clientBuild) throws IOException { - // Determine release location. All builds should be put into the C:\Program files\Openfire\enterprise\spark or /usr/share/enterprise/spark directory - // and be named appropriately (ex. spark_1_0_0.exe, spark_1_0_1.dmg) - Path clientFile = JiveGlobals.getHomePath().resolve("enterprise").resolve("spark").resolve(clientBuild); + final Path buildDir = JiveGlobals.getHomePath().resolve("enterprise").resolve("spark").toAbsolutePath().normalize(); + final Path clientFile = buildDir.resolve(Path.of(clientBuild).getFileName()).normalize(); + if (!clientFile.startsWith(buildDir) || !Files.isRegularFile(clientFile)) { + resp.sendError(HttpServletResponse.SC_NOT_FOUND, "Spark client package not found"); + return; + } - // Set content size resp.setContentType("application/octet-stream"); - resp.setHeader("Content-Disposition", "attachment; filename=" + clientBuild); - resp.setContentLength((int)Files.size(clientFile)); + String fileName = clientFile.getFileName().toString() + .replace('\r', '_') + .replace('\n', '_') + .replace('"', '_'); + resp.setHeader("Content-Disposition", "attachment; filename=\"" + fileName + "\""); + resp.setContentLengthLong(Files.size(clientFile)); + resp.setHeader("X-Content-Type-Options", "nosniff"); - // Open the file and output streams try (final InputStream in = Files.newInputStream(clientFile); final OutputStream out = resp.getOutputStream()) { - // Copy the contents of the file to the output stream - byte[] buf = new byte[1024]; + byte[] buf = new byte[64 * 1024]; int count; while ((count = in.read(buf)) >= 0) { out.write(buf, 0, count); diff --git a/src/java/org/jivesoftware/openfire/plugin/spark/manager/SparkVersionManager.java b/src/java/org/jivesoftware/openfire/plugin/spark/manager/SparkVersionManager.java index 97a689a0a..60a569241 100644 --- a/src/java/org/jivesoftware/openfire/plugin/spark/manager/SparkVersionManager.java +++ b/src/java/org/jivesoftware/openfire/plugin/spark/manager/SparkVersionManager.java @@ -17,9 +17,18 @@ package org.jivesoftware.openfire.plugin.spark.manager; import java.io.IOException; +import java.io.InputStream; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.attribute.FileTime; +import java.security.MessageDigest; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import org.dom4j.Element; import org.jivesoftware.openfire.XMPPServer; @@ -48,6 +57,7 @@ public class SparkVersionManager implements Component { private static final Logger Log = LoggerFactory.getLogger(SparkVersionManager.class); + private static final Map CHECKSUM_CACHE = new HashMap<>(); private final ComponentManager componentManager; public static String SERVICE_NAME = "updater"; @@ -125,6 +135,8 @@ private void handleSparkIQ(IQ packet) { // Define default values Element osElement = iq.element("os"); String os = osElement != null ? osElement.getText() : null; + Element archElement = iq.element("arch"); + String arch = archElement != null ? archElement.getTextTrim().toLowerCase(Locale.ROOT) : null; reply = IQ.createResultIQ(packet); @@ -137,51 +149,47 @@ private void handleSparkIQ(IQ packet) { } Element sparkElement = reply.setChildElement("query", "jabber:iq:spark"); - String client = null; - switch (os) { - case "windows": // Handle Windows clients - client = JiveGlobals.getProperty("spark.windows.client"); - break; - case "mac": // Handle Mac clients. - client = JiveGlobals.getProperty("spark.mac.client"); - break; - case "linux": // Handle Linux Client. - client = JiveGlobals.getProperty("spark.linux.client"); - break; - } - - if (client != null) { - int index = client.indexOf("_"); - - // Add version number - String versionNumber = client.substring(index + 1); - int indexOfPeriod = versionNumber.indexOf("."); - - versionNumber = versionNumber.substring(0, indexOfPeriod); - versionNumber = versionNumber.replace("_", "."); - - sparkElement.addElement("version").setText(versionNumber); + String client = resolveClientPackage(os, arch); - // Add updated time. - Path clientFile = JiveGlobals.getHomePath().resolve("enterprise").resolve("spark").resolve(client); - if (!Files.exists(clientFile)) { + if (client != null && !client.isBlank()) { + Path buildDir = JiveGlobals.getHomePath().resolve("enterprise").resolve("spark").toAbsolutePath().normalize(); + Path clientFile = buildDir.resolve(client).normalize(); + if (!clientFile.startsWith(buildDir) || !Files.isRegularFile(clientFile)) { reply.setChildElement(packet.getChildElement().createCopy()); reply.setError(new PacketError(PacketError.Condition.item_not_found, PacketError.Type.cancel, "Client package not found")); sendPacket(reply); return; } + + String fileName = clientFile.getFileName().toString(); + String versionNumber = extractVersion(fileName); + if (versionNumber == null) { + reply.setChildElement(packet.getChildElement().createCopy()); + reply.setError(new PacketError(PacketError.Condition.not_acceptable, PacketError.Type.modify, "Unable to determine package version from filename")); + sendPacket(reply); + return; + } + sparkElement.addElement("version").setText(versionNumber); + try { FileTime updatedTime = Files.getLastModifiedTime(clientFile); sparkElement.addElement("updatedTime").setText(String.valueOf(updatedTime.toInstant().toEpochMilli())); } catch (IOException e) { Log.info("Unable to determine the last-modified time of file {}", clientFile, e); } + sparkElement.addElement("fileName").setText(fileName); + try { + sparkElement.addElement("sha256").setText(sha256(clientFile)); + } catch (Exception e) { + Log.warn("Unable to calculate SHA-256 for {}", clientFile, e); + } + // Add download url String downloadURL = JiveGlobals.getProperty("spark.client.downloadURL"); String server = XMPPServer.getInstance().getServerInfo().getXMPPDomain(); downloadURL = downloadURL.replace("127.0.0.1", server); - sparkElement.addElement("downloadURL").setText(downloadURL + "?client=" + client); + sparkElement.addElement("downloadURL").setText(downloadURL + "?client=" + URLEncoder.encode(fileName, StandardCharsets.UTF_8)); String displayMessage = JiveGlobals.getProperty("spark.client.displayMessage"); if (displayMessage != null && !displayMessage.trim().isEmpty()) { @@ -198,6 +206,103 @@ private void handleSparkIQ(IQ packet) { sendPacket(reply); } + static String architecturePropertyName(String os, String arch) { + if (os == null || arch == null) { + return null; + } + String normalizedOs = os.toLowerCase(Locale.ROOT); + String normalizedArch = arch.toLowerCase(Locale.ROOT); + if (!normalizedOs.equals("windows") && !normalizedOs.equals("mac") && !normalizedOs.equals("linux")) { + return null; + } + if (!normalizedArch.equals("x86") && !normalizedArch.equals("x64") && !normalizedArch.equals("arm64")) { + return null; + } + return "spark." + normalizedOs + "." + normalizedArch + ".client"; + } + + private static String resolveClientPackage(String os, String arch) { + String architectureProperty = architecturePropertyName(os, arch); + if (architectureProperty != null) { + String architectureClient = JiveGlobals.getProperty(architectureProperty); + if (architectureClient != null && !architectureClient.isBlank()) { + return architectureClient; + } + } + return JiveGlobals.getProperty("spark." + os + ".client"); + } + + private static final Pattern VERSION_PATTERN = Pattern.compile( + "(\\d+(?:[._]\\d+){1,3})(?:[-._]?((?:snapshot|alpha|beta|rc)\\d*))?", + Pattern.CASE_INSENSITIVE + ); + + static String extractVersion(String client) { + Matcher matcher = VERSION_PATTERN.matcher(client); + if (!matcher.find()) { + return null; + } + String version = matcher.group(1).replace('_', '.'); + String qualifier = matcher.group(2); + if (qualifier == null) { + return version; + } + return version + "-" + qualifier.toLowerCase(Locale.ROOT); + } + + static synchronized String sha256(Path path) throws Exception { + Path normalizedPath = path.toAbsolutePath().normalize(); + for (int attempt = 0; attempt < 3; attempt++) { + long size = Files.size(normalizedPath); + FileTime modified = Files.getLastModifiedTime(normalizedPath); + CachedChecksum cached = CHECKSUM_CACHE.get(normalizedPath); + if (cached != null && cached.matches(size, modified)) { + return cached.sha256; + } + + String sha256 = calculateSha256(normalizedPath); + long finalSize = Files.size(normalizedPath); + FileTime finalModified = Files.getLastModifiedTime(normalizedPath); + if (size == finalSize && modified.equals(finalModified)) { + CHECKSUM_CACHE.put(normalizedPath, new CachedChecksum(finalSize, finalModified, sha256)); + return sha256; + } + } + throw new IOException("Spark client package changed while its SHA-256 was calculated: " + normalizedPath); + } + + private static String calculateSha256(Path path) throws Exception { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + try (InputStream in = Files.newInputStream(path)) { + byte[] buffer = new byte[64 * 1024]; + int read; + while ((read = in.read(buffer)) >= 0) { + digest.update(buffer, 0, read); + } + } + StringBuilder result = new StringBuilder(64); + for (byte value : digest.digest()) { + result.append(String.format("%02x", value)); + } + return result.toString(); + } + + private static class CachedChecksum { + private final long size; + private final FileTime modified; + private final String sha256; + + private CachedChecksum(long size, FileTime modified, String sha256) { + this.size = size; + this.modified = modified; + this.sha256 = sha256; + } + + private boolean matches(long size, FileTime modified) { + return this.size == size && this.modified.equals(modified); + } + } + private void handleDiscoItems(IQ packet) { IQ replyPacket = IQ.createResultIQ(packet); replyPacket.setChildElement("query", "http://jabber.org/protocol/disco#items"); diff --git a/src/test/java/org/jivesoftware/openfire/plugin/spark/manager/SparkVersionManagerTest.java b/src/test/java/org/jivesoftware/openfire/plugin/spark/manager/SparkVersionManagerTest.java new file mode 100644 index 000000000..d5dca9ac0 --- /dev/null +++ b/src/test/java/org/jivesoftware/openfire/plugin/spark/manager/SparkVersionManagerTest.java @@ -0,0 +1,82 @@ +package org.jivesoftware.openfire.plugin.spark.manager; + +import org.junit.Test; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +public class SparkVersionManagerTest { + + @Test + public void buildsArchitecturePropertiesForAllSupportedPlatforms() { + assertEquals("spark.windows.x86.client", SparkVersionManager.architecturePropertyName("windows", "x86")); + assertEquals("spark.windows.x64.client", SparkVersionManager.architecturePropertyName("windows", "x64")); + assertEquals("spark.windows.arm64.client", SparkVersionManager.architecturePropertyName("windows", "arm64")); + assertEquals("spark.mac.x64.client", SparkVersionManager.architecturePropertyName("mac", "x64")); + assertEquals("spark.mac.arm64.client", SparkVersionManager.architecturePropertyName("mac", "arm64")); + assertEquals("spark.linux.x86.client", SparkVersionManager.architecturePropertyName("linux", "x86")); + assertEquals("spark.linux.x64.client", SparkVersionManager.architecturePropertyName("linux", "x64")); + assertEquals("spark.linux.arm64.client", SparkVersionManager.architecturePropertyName("linux", "arm64")); + } + + @Test + public void rejectsUnsupportedArchitectureProperties() { + assertNull(SparkVersionManager.architecturePropertyName("windows", "sparc")); + assertNull(SparkVersionManager.architecturePropertyName("android", "arm64")); + assertNull(SparkVersionManager.architecturePropertyName(null, "x64")); + assertNull(SparkVersionManager.architecturePropertyName("linux", null)); + } + + @Test + public void extractsReleaseVersion() { + assertEquals("3.1.0", SparkVersionManager.extractVersion("spark_3_1_0-with-jre.exe")); + } + + @Test + public void extractsPrereleaseVersion() { + assertEquals("3.1.1-rc2", SparkVersionManager.extractVersion("spark_3_1_1_rc2-with-jre.exe")); + } + + @Test + public void rejectsFilenameWithoutVersion() { + assertNull(SparkVersionManager.extractVersion("spark-current.exe")); + } + + @Test + public void invalidatesCachedSha256WhenFileChanges() throws Exception { + Path file = Files.createTempFile("spark-client-cache", ".exe"); + try { + Files.write(file, "Spark".getBytes(StandardCharsets.UTF_8)); + assertEquals( + "529bc3b07127ecb7e53a4dcf1991d9152c24537d919178022b2c42657f79a26b", + SparkVersionManager.sha256(file) + ); + + Files.write(file, "Spark2".getBytes(StandardCharsets.UTF_8)); + assertEquals( + "76b41059a0f13be29af4dc2343f59a0e07e866d385a14262c073bdeb0fbb3f5f", + SparkVersionManager.sha256(file) + ); + } finally { + Files.deleteIfExists(file); + } + } + + @Test + public void calculatesSha256() throws Exception { + Path file = Files.createTempFile("spark-client", ".exe"); + try { + Files.write(file, "Spark".getBytes(StandardCharsets.UTF_8)); + assertEquals( + "529bc3b07127ecb7e53a4dcf1991d9152c24537d919178022b2c42657f79a26b", + SparkVersionManager.sha256(file) + ); + } finally { + Files.deleteIfExists(file); + } + } +}