diff --git a/src/integration/java/org/jlab/epics2web/ClientNameTest.java b/src/integration/java/org/jlab/epics2web/ClientNameTest.java
new file mode 100644
index 0000000..44bcfcc
--- /dev/null
+++ b/src/integration/java/org/jlab/epics2web/ClientNameTest.java
@@ -0,0 +1,60 @@
+package org.jlab.epics2web;
+
+import static org.junit.Assert.assertTrue;
+
+import java.net.URI;
+import java.net.URLEncoder;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.net.http.WebSocket;
+import java.nio.charset.StandardCharsets;
+import java.util.UUID;
+import org.junit.Test;
+
+/** Tests that clients are named by the clientName parameter of the WebSocket handshake. */
+public class ClientNameTest {
+
+ private static final HttpClient HTTP = HttpClient.newHttpClient();
+
+ /** The name is read even when the query has other parameters, and may contain = and &. */
+ @Test
+ public void consoleShowsClientNameAmongOtherParameters() throws Exception {
+ String name = "name-test-" + UUID.randomUUID() + "?a=1&b=2";
+ WebSocket socket =
+ HTTP.newWebSocketBuilder()
+ .buildAsync(
+ URI.create(
+ "ws://localhost:8080/epics2web/monitor?other=1&clientName="
+ + URLEncoder.encode(name, StandardCharsets.UTF_8)
+ + "&another=2"),
+ new WebSocket.Listener() {})
+ .join();
+
+ try {
+ assertTrue("Client name not on the console", consoleShows(name.replace("&", "&")));
+ } finally {
+ socket.abort();
+ }
+ }
+
+ /** Waits for the console to show the text; the server may list the client after the handshake. */
+ private static boolean consoleShows(String text) throws Exception {
+ long deadline = System.currentTimeMillis() + 5_000;
+ while (true) {
+ if (get("console").body().contains("
" + text + " | ")) {
+ return true;
+ }
+ if (System.currentTimeMillis() > deadline) {
+ return false;
+ }
+ Thread.sleep(100);
+ }
+ }
+
+ private static HttpResponse get(String path) throws Exception {
+ return HTTP.send(
+ HttpRequest.newBuilder().uri(URI.create("http://localhost:8080/epics2web/" + path)).build(),
+ HttpResponse.BodyHandlers.ofString());
+ }
+}
diff --git a/src/main/java/org/jlab/epics2web/websocket/MonitorEndpoint.java b/src/main/java/org/jlab/epics2web/websocket/MonitorEndpoint.java
index 9104401..6c42e7c 100644
--- a/src/main/java/org/jlab/epics2web/websocket/MonitorEndpoint.java
+++ b/src/main/java/org/jlab/epics2web/websocket/MonitorEndpoint.java
@@ -14,8 +14,8 @@
import jakarta.websocket.Session;
import jakarta.websocket.server.ServerEndpoint;
import java.io.StringReader;
-import java.io.UnsupportedEncodingException;
import java.net.URLDecoder;
+import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.List;
import java.util.Map;
@@ -77,24 +77,7 @@ public void onOpen(Session session, EndpointConfig config) {
}
}
- String name = "";
- String q = session.getQueryString();
- if (q != null) {
- String[] tokens = q.split("=");
- if (tokens.length == 2) {
- try {
- name = URLDecoder.decode(tokens[1], "UTF-8");
- } catch (UnsupportedEncodingException e) {
- LOGGER.log(
- Level.WARNING,
- "JVM doesn't support UTF-8 so can't decode clientName parameter",
- e);
- }
- }
- if (name == null) {
- name = "";
- }
- }
+ String name = clientName(session.getQueryString());
session.getUserProperties().put("agent", agent);
session.getUserProperties().put("ip", ip);
@@ -126,6 +109,34 @@ public void onOpen(Session session, EndpointConfig config) {
}
}
+ /**
+ * Read the clientName parameter from a handshake's query string. The query may have other
+ * parameters, in any order.
+ *
+ * @param query The raw query string, or null
+ * @return The decoded name, or empty if there's none or it's malformed
+ */
+ static String clientName(String query) {
+ if (query == null) {
+ return "";
+ }
+
+ for (String parameter : query.split("&")) {
+ int equals = parameter.indexOf('=');
+
+ if (equals > 0 && "clientName".equals(parameter.substring(0, equals))) {
+ try {
+ return URLDecoder.decode(parameter.substring(equals + 1), StandardCharsets.UTF_8);
+ } catch (IllegalArgumentException e) {
+ LOGGER.log(Level.FINE, "Malformed clientName parameter: {0}", parameter);
+ return "";
+ }
+ }
+ }
+
+ return "";
+ }
+
@OnClose
public void onClose(Session session, CloseReason reason) {
// LOGGER.log(Level.FINEST, "close; Reason: {0}", reason);
diff --git a/src/main/webapp/resources/js/epics2web.js b/src/main/webapp/resources/js/epics2web.js
index caa1060..f8f796c 100644
--- a/src/main/webapp/resources/js/epics2web.js
+++ b/src/main/webapp/resources/js/epics2web.js
@@ -138,7 +138,7 @@ jlab.epics2web.ClientConnection = function (options) {
let u = this.url;
if(this.clientName !== null) {
- u = u + '?clientName=' + encodeURIComponent(this.clientName);
+ u = u + (u.indexOf('?') === -1 ? '?' : '&') + 'clientName=' + encodeURIComponent(this.clientName);
}
socket = new WebSocket(u);
diff --git a/src/test/java/org/jlab/epics2web/websocket/MonitorEndpointTest.java b/src/test/java/org/jlab/epics2web/websocket/MonitorEndpointTest.java
index 6bd9a28..d02d771 100644
--- a/src/test/java/org/jlab/epics2web/websocket/MonitorEndpointTest.java
+++ b/src/test/java/org/jlab/epics2web/websocket/MonitorEndpointTest.java
@@ -1,5 +1,6 @@
package org.jlab.epics2web.websocket;
+import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
@@ -55,4 +56,32 @@ public void onCloseStopsWriterWithoutInterrupting() throws Exception {
assertNull("Write queue not closed", client.writeQueue.take());
assertTrue(manager.toSet().isEmpty());
}
+
+ @Test
+ public void clientNameIsReadAmongOtherParameters() {
+ assertEquals("screen", MonitorEndpoint.clientName("clientName=screen"));
+ assertEquals("screen", MonitorEndpoint.clientName("a=1&clientName=screen"));
+ assertEquals("screen", MonitorEndpoint.clientName("clientName=screen&a=1&b"));
+ assertEquals("screen", MonitorEndpoint.clientName("a&clientName=screen"));
+ }
+
+ /** The JavaScript client sends its page's address, which may have a query string of its own. */
+ @Test
+ public void clientNameIsDecoded() {
+ assertEquals(
+ "https://example.org/wedm/screen?edl=a b.edl&x=1",
+ MonitorEndpoint.clientName(
+ "clientName=https%3A%2F%2Fexample.org%2Fwedm%2Fscreen%3Fedl%3Da%20b.edl%26x%3D1"));
+ }
+
+ @Test
+ public void clientNameIsEmptyWhenMissingOrMalformed() {
+ assertEquals("", MonitorEndpoint.clientName(null));
+ assertEquals("", MonitorEndpoint.clientName(""));
+ assertEquals("", MonitorEndpoint.clientName("a=1"));
+ assertEquals("", MonitorEndpoint.clientName("xclientName=screen"));
+ assertEquals("", MonitorEndpoint.clientName("clientName"));
+ assertEquals("", MonitorEndpoint.clientName("clientName="));
+ assertEquals("", MonitorEndpoint.clientName("clientName=%zz"));
+ }
}