Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<os>.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).
6 changes: 6 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@
<artifactId>commons-fileupload</artifactId>
<version>1.6.0</version>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.13.2</version>
<scope>test</scope>
</dependency>
</dependencies>

<repositories>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -48,6 +57,7 @@
public class SparkVersionManager implements Component {

private static final Logger Log = LoggerFactory.getLogger(SparkVersionManager.class);
private static final Map<Path, CachedChecksum> CHECKSUM_CACHE = new HashMap<>();

private final ComponentManager componentManager;
public static String SERVICE_NAME = "updater";
Expand Down Expand Up @@ -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);

Expand All @@ -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()) {
Expand All @@ -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");
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
}