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

eXist-db / exist / 39745471938

22 Oct 2026 08:07AM UTC coverage: 57.812%. Remained the same
39745471938

push

github

web-flow
Merge pull request #6911 from eXist-db/dependabot/maven/io.github.hakky54-logcaptor-2.13.0

Bump io.github.hakky54:logcaptor from 2.12.7 to 2.13.0

32284 of 61732 branches covered (52.3%)

Branch coverage included in aggregate %.

85034 of 141197 relevant lines covered (60.22%)

2.71 hits per line

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

57.21
/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.Map;
42
import java.util.concurrent.ConcurrentHashMap;
43
import java.util.concurrent.ExecutorService;
44
import java.util.concurrent.Executors;
45

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

325
        getExecutorService().submit(() -> {
7 ✔
326
            try {
327
                final BrokerPool pool = BrokerPool.getInstance();
2 ✔
328
                final QueryExecutor executor = new QueryExecutor(pool);
5 ✔
329
                executor.compile(session, evalSession, msg);
5 ✔
330
            } catch (final EXistException e) {
1 ✔
331
                try {
332
                    session.getBasicRemote().sendText(
×
333
                            EvalProtocol.errorMessage(msg.id(), null,
×
334
                                    "Database unavailable: " + e.getMessage(), 0, 0, null));
×
335
                } catch (final IOException ex) {
×
336
                    LOG.debug("Failed to send database error: {}", ex.getMessage());
×
337
                }
338
            }
339
        });
1 ✔
340
    }
1 ✔
341
}
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