• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

eXist-db / exist / 40903079005

29 Oct 2026 03:35PM UTC coverage: 58.179% (-0.01%) from 58.193%
40903079005

push

github

web-flow
Merge pull request #6944 from eXist-db/dependabot/maven/greenmail.version-2.1.15

Bump greenmail.version from 2.1.14 to 2.1.15

32492 of 61720 branches covered (52.64%)

Branch coverage included in aggregate %.

85570 of 141209 relevant lines covered (60.6%)

2.73 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

56.25
/exist-core/src/main/java/org/exist/http/ws/EvalWebSocketEndpoint.java
1
/*
2
 * eXist-db Open Source Native XML Database
3
 * Copyright (C) 2001 The eXist-db Authors
4
 *
5
 * info@exist-db.org
6
 * http://www.exist-db.org
7
 *
8
 * This library is free software; you can redistribute it and/or
9
 * modify it under the terms of the GNU Lesser General Public
10
 * License as published by the Free Software Foundation; either
11
 * version 2.1 of the License, or (at your option) any later version.
12
 *
13
 * This library is distributed in the hope that it will be useful,
14
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
15
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
16
 * Lesser General Public License for more details.
17
 *
18
 * You should have received a copy of the GNU Lesser General Public
19
 * License along with this library; if not, write to the Free Software
20
 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
21
 */
22
package org.exist.http.ws;
23

24
import org.apache.commons.codec.binary.Base64;
25
import org.apache.logging.log4j.LogManager;
26
import org.apache.logging.log4j.Logger;
27
import org.exist.EXistException;
28
import org.exist.security.AuthenticationException;
29
import org.exist.security.SecurityManager;
30
import org.exist.security.Subject;
31
import org.exist.storage.BrokerPool;
32
import org.exist.storage.ProcessMonitor;
33

34
import jakarta.websocket.*;
35
import jakarta.websocket.server.HandshakeRequest;
36
import jakarta.websocket.server.ServerEndpoint;
37
import jakarta.websocket.server.ServerEndpointConfig;
38
import java.io.IOException;
39
import java.nio.channels.ClosedChannelException;
40
import java.util.List;
41
import java.util.Locale;
42
import java.util.Map;
43
import java.util.concurrent.ConcurrentHashMap;
44
import java.util.concurrent.ExecutorService;
45
import java.util.concurrent.Executors;
46

47
import static java.nio.charset.StandardCharsets.UTF_8;
48

49
/**
50
 * WebSocket endpoint for real-time, streaming XQuery evaluation.
51
 *
52
 * Supports:
53
 * <ul>
54
 *   <li>Query evaluation with streaming results</li>
55
 *   <li>Query cancellation</li>
56
 *   <li>Compile-only checks (no execution)</li>
57
 *   <li>Progress reporting</li>
58
 *   <li>Timing breakdown (parse/compile/evaluate/serialize)</li>
59
 * </ul>
60
 *
61
 * Authentication is performed during the WebSocket handshake using
62
 * Basic authentication from the HTTP upgrade request headers.
63
 */
64
@ServerEndpoint(
65
        value = "/ws/eval",
66
        configurator = EvalWebSocketEndpoint.AuthConfigurator.class
67
)
68
public class EvalWebSocketEndpoint {
3 ✔
69

70
    private static final Logger LOG = LogManager.getLogger(EvalWebSocketEndpoint.class);
3 ✔
71
    private static final String USER_PROPERTY = "exist.eval.subject";
72

73
    private static final Map<Session, EvalSession> sessions = new ConcurrentHashMap<>();
4 ✔
74
    private static final int MAX_TEXT_MESSAGE_SIZE = 10 * 1024 * 1024; // 10MB
75
    private static ExecutorService queryExecutorService = createExecutorService();
3 ✔
76

77
    private static ExecutorService createExecutorService() {
78
        return Executors.newCachedThreadPool(r -> {
3 ✔
79
            final Thread t = new Thread(r, "ws-eval-worker");
6 ✔
80
            t.setDaemon(true);
3 ✔
81
            return t;
2 ✔
82
        });
83
    }
84

85
    /**
86
     * Shutdown the WebSocket eval subsystem.
87
     */
88
    public static synchronized void shutdown() {
89
        if (queryExecutorService != null) {
2 ✔
90
            queryExecutorService.shutdown();
2 ✔
91
            queryExecutorService = null;
2 ✔
92
        }
93
        sessions.clear();
2 ✔
94
    }
1 ✔
95

96
    /**
97
     * Re-initialize the executor service if needed.
98
     */
99
    private static synchronized ExecutorService getExecutorService() {
100
        if (queryExecutorService == null) {
2 !
101
            queryExecutorService = createExecutorService();
×
102
        }
103
        return queryExecutorService;
2 ✔
104
    }
105

106
    /**
107
     * Configurator that extracts Basic auth credentials during the WebSocket
108
     * handshake and authenticates the user against eXist's SecurityManager.
109
     */
110
    public static class AuthConfigurator extends ServerEndpointConfig.Configurator {
3 ✔
111
        @Override
112
        public void modifyHandshake(final ServerEndpointConfig sec,
113
                                     final HandshakeRequest request,
114
                                     final HandshakeResponse response) {
115
            final Map<String, List<String>> headers = request.getHeaders();
3 ✔
116
            final List<String> authHeaders = headers.get("authorization");
5 ✔
117

118
            Subject subject = null;
2 ✔
119
            if (authHeaders != null && !authHeaders.isEmpty()) {
5 !
120
                final String credentials = authHeaders.getFirst();
4 ✔
121
                if (credentials.toLowerCase(Locale.ROOT).startsWith("basic ")) {
6 !
122
                    try {
123
                        final byte[] decoded = Base64.decodeBase64(
2 ✔
124
                                credentials.substring("basic ".length()));
4 ✔
125
                        final String s = new String(decoded, UTF_8);
6 ✔
126
                        final int p = s.indexOf(':');
4 ✔
127
                        final String username = p < 0 ? s : s.substring(0, p);
7 !
128
                        final String password = p < 0 ? null : s.substring(p + 1);
8 !
129

130
                        final BrokerPool pool = BrokerPool.getInstance();
2 ✔
131
                        final SecurityManager secman = pool.getSecurityManager();
3 ✔
132
                        subject = secman.authenticate(username, password);
5 ✔
133
                    } catch (final AuthenticationException e) {
1 ✔
134
                        LOG.warn("WebSocket eval authentication failed: {}", e.getMessage());
×
135
                    } catch (final EXistException e) {
×
136
                        LOG.error("Failed to get BrokerPool for WebSocket auth: {}", e.getMessage());
×
137
                    }
138
                }
139
            }
140

141
            // No guest fallback — authentication is required for WebSocket eval.
142
            // Unauthenticated connections will be rejected in onOpen().
143
            if (subject != null) {
2 !
144
                sec.getUserProperties().put(USER_PROPERTY, subject);
6 ✔
145
            } else {
1 ✔
146
                LOG.debug("WebSocket eval connection without valid credentials — will be rejected in onOpen");
×
147
            }
148
        }
1 ✔
149
    }
150

151
    @OnOpen
152
    public void onOpen(final Session session, final EndpointConfig config) {
153
        session.setMaxIdleTimeout(0); // no idle timeout for eval sessions
3 ✔
154
        session.setMaxTextMessageBufferSize(MAX_TEXT_MESSAGE_SIZE);
3 ✔
155

156
        final Subject subject = (Subject) config.getUserProperties().get(USER_PROPERTY);
6 ✔
157
        if (subject == null) {
2 !
158
            try {
159
                session.close(new CloseReason(
×
160
                        CloseReason.CloseCodes.VIOLATED_POLICY, "Authentication failed"));
×
161
            } catch (final IOException e) {
×
162
                LOG.debug("Error closing unauthenticated session: {}", e.getMessage());
×
163
            }
164
            return;
×
165
        }
166

167
        final EvalSession evalSession = new EvalSession(subject);
5 ✔
168
        sessions.put(session, evalSession);
5 ✔
169
        LOG.debug("Eval WebSocket opened for user: {}", subject.getName());
5 ✔
170
    }
1 ✔
171

172
    @OnMessage
173
    public void onMessage(final String message, final Session session) {
174
        final EvalSession evalSession = sessions.get(session);
5 ✔
175
        if (evalSession == null) {
2 !
176
            LOG.warn("Received message on unregistered session");
×
177
            return;
×
178
        }
179

180
        final EvalProtocol.ClientMessage msg;
181
        try {
182
            msg = EvalProtocol.parseClientMessage(message);
3 ✔
183
        } catch (final IOException e) {
2 ✔
184
            try {
185
                session.getBasicRemote().sendText(
3 ✔
186
                        EvalProtocol.errorMessage("unknown", null,
3 ✔
187
                                "Invalid message: " + e.getMessage(), 0, 0, null));
6 ✔
188
            } catch (final IOException ex) {
1 ✔
189
                LOG.debug("Failed to send parse error: {}", ex.getMessage());
×
190
            }
191
            return;
1 ✔
192
        }
193

194
        switch (msg.action()) {
4 !
195
            case EvalProtocol.ACTION_EVAL -> handleEval(session, evalSession, msg);
6 ✔
196
            case EvalProtocol.ACTION_CANCEL -> handleCancel(evalSession, msg);
5 ✔
197
            case EvalProtocol.ACTION_COMPILE -> handleCompile(session, evalSession, msg);
6 ✔
198
            case EvalProtocol.ACTION_ADMIN_CANCEL -> handleAdminCancel(session, evalSession, msg);
6 ✔
199
            default -> {
200
                try {
201
                    session.getBasicRemote().sendText(
×
202
                            EvalProtocol.errorMessage(msg.id(), null,
×
203
                                    "Unknown action: " + msg.action(), 0, 0, null));
×
204
                } catch (final IOException e) {
×
205
                    LOG.debug("Failed to send unknown action error: {}", e.getMessage());
×
206
                }
207
            }
208
        }
209
    }
1 ✔
210

211
    @OnClose
212
    public void onClose(final Session session, final CloseReason reason) {
213
        final EvalSession evalSession = sessions.remove(session);
5 ✔
214
        if (evalSession != null) {
2 !
215
            evalSession.cancelAll();
2 ✔
216
            LOG.debug("Eval WebSocket closed: {}", reason.getReasonPhrase());
5 ✔
217
        }
218
    }
1 ✔
219

220
    @OnError
221
    public void onError(final Session session, final Throwable error) {
222
        final EvalSession evalSession = sessions.remove(session);
×
223
        if (evalSession != null) {
×
224
            evalSession.cancelAll();
×
225
        }
226
        if (error instanceof ClosedChannelException) {
×
227
            LOG.debug("WebSocket eval client disconnected abruptly: session {}", session.getId());
×
228
        } else if (error.getMessage() != null && error.getMessage().contains("Text message size")) {
×
229
            LOG.warn("WebSocket message exceeds {}MB buffer limit: {}", MAX_TEXT_MESSAGE_SIZE / (1024 * 1024), error.getMessage());
×
230
        } else {
×
231
            LOG.warn("WebSocket eval error: {}", error.getMessage(), error);
×
232
        }
233
    }
×
234

235
    private void handleEval(final Session session, final EvalSession evalSession,
236
                             final EvalProtocol.ClientMessage msg) {
237
        if (msg.query() == null || msg.query().isEmpty()) {
7 !
238
            try {
239
                session.getBasicRemote().sendText(
3 ✔
240
                        EvalProtocol.errorMessage(msg.id(), null,
4 ✔
241
                                "Missing required field: query", 0, 0, null));
4 ✔
242
            } catch (final IOException e) {
1 ✔
243
                LOG.debug("Failed to send missing query error: {}", e.getMessage());
×
244
            }
245
            return;
1 ✔
246
        }
247

248
        // Execute on a worker thread to avoid blocking the WebSocket message thread
249
        getExecutorService().submit(() -> {
7 ✔
250
            try {
251
                final BrokerPool pool = BrokerPool.getInstance();
2 ✔
252
                final QueryExecutor executor = new QueryExecutor(pool);
5 ✔
253
                executor.execute(session, evalSession, msg);
5 ✔
254
            } catch (final EXistException e) {
1 ✔
255
                try {
256
                    session.getBasicRemote().sendText(
×
257
                            EvalProtocol.errorMessage(msg.id(), null,
×
258
                                    "Database unavailable: " + e.getMessage(), 0, 0, null));
×
259
                } catch (final IOException ex) {
×
260
                    LOG.debug("Failed to send database error: {}", ex.getMessage());
×
261
                }
262
            }
263
        });
1 ✔
264
    }
1 ✔
265

266
    private void handleCancel(final EvalSession evalSession,
267
                               final EvalProtocol.ClientMessage msg) {
268
        final boolean cancelled = evalSession.cancelQuery(msg.id());
5 ✔
269
        if (!cancelled) {
2 !
270
            LOG.debug("Cancel requested for unknown query: {}", msg.id());
×
271
        }
272
    }
1 ✔
273

274
    /**
275
     * Admin cancel: kill any running query by its context identity hash code.
276
     * Requires DBA role. Uses the ProcessMonitor (same as system:kill-running-xquery).
277
     */
278
    private void handleAdminCancel(final Session session, final EvalSession evalSession,
279
                                    final EvalProtocol.ClientMessage msg) {
280
        if (!evalSession.getSubject().hasDbaRole()) {
4 !
281
            try {
282
                session.getBasicRemote().sendText(
3 ✔
283
                        EvalProtocol.errorMessage(msg.id(), null,
4 ✔
284
                                "Permission denied: admin-cancel requires DBA role", 0, 0, null));
4 ✔
285
            } catch (final IOException e) {
1 ✔
286
                LOG.debug("Failed to send permission error: {}", e.getMessage());
×
287
            }
288
            return;
1 ✔
289
        }
290

291
        try {
292
            final BrokerPool pool = BrokerPool.getInstance();
×
293
            final ProcessMonitor monitor = pool.getProcessMonitor();
×
294
            final int targetId = Integer.parseInt(msg.id());
×
295

296
            boolean found = false;
×
297
            for (final org.exist.xquery.XQueryWatchDog wd : monitor.getRunningXQueries()) {
×
298
                if (System.identityHashCode(wd.getContext()) == targetId) {
×
299
                    wd.kill(0);
×
300
                    found = true;
×
301
                    break;
×
302
                }
303
            }
304

305
            if (!found) {
×
306
                LOG.debug("Admin cancel: query {} not found", msg.id());
×
307
            }
308
        } catch (final Exception e) {
×
309
            LOG.warn("Admin cancel failed: {}", e.getMessage());
×
310
        }
311
    }
×
312

313
    private void handleCompile(final Session session, final EvalSession evalSession,
314
                                final EvalProtocol.ClientMessage msg) {
315
        if (msg.query() == null || msg.query().isEmpty()) {
7 !
316
            try {
317
                session.getBasicRemote().sendText(
×
318
                        EvalProtocol.errorMessage(msg.id(), null,
×
319
                                "Missing required field: query", 0, 0, null));
×
320
            } catch (final IOException e) {
×
321
                LOG.debug("Failed to send missing query error: {}", e.getMessage());
×
322
            }
323
            return;
×
324
        }
325

326
        getExecutorService().submit(() -> {
7 ✔
327
            try {
328
                final BrokerPool pool = BrokerPool.getInstance();
2 ✔
329
                final QueryExecutor executor = new QueryExecutor(pool);
5 ✔
330
                executor.compile(session, evalSession, msg);
5 ✔
331
            } catch (final EXistException e) {
1 ✔
332
                try {
333
                    session.getBasicRemote().sendText(
×
334
                            EvalProtocol.errorMessage(msg.id(), null,
×
335
                                    "Database unavailable: " + e.getMessage(), 0, 0, null));
×
336
                } catch (final IOException ex) {
×
337
                    LOG.debug("Failed to send database error: {}", ex.getMessage());
×
338
                }
339
            }
340
        });
1 ✔
341
    }
1 ✔
342
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc