Skip to content
7 changes: 5 additions & 2 deletions src/main/java/com/github/ibm/mapepire/MapepireServer.java
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
import com.github.ibm.mapepire.authfile.AuthFile;
import com.github.ibm.mapepire.certstuff.ServerCertGetter;
import com.github.ibm.mapepire.certstuff.ServerCertInfo;
import com.github.ibm.mapepire.http.BlobServlet;
import com.github.ibm.mapepire.http.InstallLocationServlet;
import com.github.ibm.mapepire.http.Routes;
import com.github.ibm.mapepire.http.VersionServlet;
Expand Down Expand Up @@ -122,6 +123,7 @@ public static void main(final String[] _args) {
context.setContextPath("/");
context.addServlet(new ServletHolder(VersionServlet.class), Routes.VERSION);
context.addServlet(new ServletHolder(InstallLocationServlet.class), Routes.SOURCE);
context.addServlet(new ServletHolder(BlobServlet.class), Routes.BLOB);

Constraint constraint = new Constraint();
constraint.setName("Disable TRACE");
Expand Down Expand Up @@ -175,8 +177,9 @@ public static void main(final String[] _args) {
NativeWebSocketServletContainerInitializer.configure(context,
(servletContext, nativeWebSocketConfiguration) -> {
nativeWebSocketConfiguration.getPolicy().setMaxTextMessageBufferSize(65535);
// Configure max message size
int maxWsMessageSize = 50 * 1024 * 1024; // 50MB
// Max WS message size — default Integer.MAX_VALUE (unlimited).
// Can be capped via MAX_WS_MESSAGE_SIZE env var if desired.
int maxWsMessageSize = Integer.MAX_VALUE;
String maxWsMessageSizeStr = System.getenv("MAX_WS_MESSAGE_SIZE");
if (StringUtils.isNonEmpty(maxWsMessageSizeStr)) {
maxWsMessageSize = Integer.parseInt(maxWsMessageSizeStr);
Expand Down
13 changes: 13 additions & 0 deletions src/main/java/com/github/ibm/mapepire/SystemConnection.java
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ public enum ConnectionMethod {
private final ClientSpecialRegisters m_clientRegs;
private String m_applicationName;
private final String clientAddress;
// Raw Base64 "user:pass" from the WebSocket Authorization header.
// Stored so BlobStore can validate HTTP /blob/{token} requests.
private String m_rawCredentials = null;

public SystemConnection() throws IOException {
if (!MapepireServer.isSingleMode()) {
Expand Down Expand Up @@ -57,6 +60,16 @@ public SystemConnection(String clientHost, String clientAddress, String host, St
this.m_clientRegs = new ClientSpecialRegistersRemote(clientHost, clientAddress, user);
}

/** Returns the raw Base64 Authorization credentials, or {@code null} in single mode. */
public String getRawCredentials() {
return m_rawCredentials;
}

/** Called by {@link com.github.ibm.mapepire.ws.DbWebsocketClient} at connection time. */
public void setRawCredentials(String rawCredentials) {
this.m_rawCredentials = rawCredentials;
}

public static boolean isRunningOnIBMi() {
return System.getProperty("os.name", "").contains("400");
}
Expand Down
4 changes: 2 additions & 2 deletions src/main/java/com/github/ibm/mapepire/Version.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
package com.github.ibm.mapepire;
public class Version {
static public final String s_compileDateTime = "2024-08-08 00:36:20 (GMT)";
static public final String s_version = "2.0.0-rc1";
static public final String s_compileDateTime = "2026-07-28 14:46:43 (GMT)";
static public final String s_version = "2.3.5";
}
106 changes: 106 additions & 0 deletions src/main/java/com/github/ibm/mapepire/http/BlobServlet.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package com.github.ibm.mapepire.http;

import com.github.ibm.mapepire.Tracer;

import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;

/**
* Streams a previously-stored BLOB to the caller.
*
* <p>URL pattern: {@code GET /blob/{token}}</p>
*
* <p>The caller must supply the same Basic-Auth credentials that were used
* when the originating WebSocket connection ran the query. The token itself
* is single-use and expires after the configured TTL.</p>
*
* <p>For large BLOBs the backing data may still be spooling to disk when this
* request arrives. {@link BlobStore.BlobEntry#openStream()} will block until
* the spool is complete before any response bytes are written, ensuring the
* HTTP status code is always set correctly before the body begins.</p>
*/
public class BlobServlet extends HttpServlet {

@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {

// ---- Extract token from path /blob/{token} -------------------------
String pathInfo = req.getPathInfo();
if (pathInfo == null || pathInfo.length() <= 1) {
resp.sendError(HttpServletResponse.SC_BAD_REQUEST, "Missing blob token");
return;
}
String token = pathInfo.substring(1); // strip leading '/'

// ---- Validate Basic Auth --------------------------------------------
String authHeader = req.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Basic ")) {
resp.setHeader("WWW-Authenticate", "Basic realm=\"mapepire\"");
resp.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Authorization required");
return;
}
String suppliedCredentials = authHeader.substring(6).trim(); // raw Base64 "user:pass"

// ---- Consume token --------------------------------------------------
BlobStore.BlobEntry entry = BlobStore.getInstance().consume(token);
if (entry == null) {
resp.sendError(HttpServletResponse.SC_NOT_FOUND, "Blob token not found or expired");
return;
}

// ---- Verify credentials match ---------------------------------------
if (!suppliedCredentials.equals(entry.credentials)) {
// Put the entry back? No — single-use, deny and discard to prevent brute-force
entry.cleanup();
resp.setHeader("WWW-Authenticate", "Basic realm=\"mapepire\"");
resp.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid credentials");
return;
}

// ---- Wait for spool (if still in progress) and open stream ----------
// openStream() blocks until the background spool thread finishes or the
// TTL elapses. We must open the stream BEFORE committing any response
// headers so that a spool failure can still be reported as a 500.
InputStream in = null;
try {
in = entry.openStream();
} catch (IOException e) {
entry.cleanup();
Tracer.err(e);
resp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
"Blob data unavailable");
return;
}

// ---- Stream bytes ---------------------------------------------------
// Headers are committed only after openStream() succeeds, ensuring the
// status code is always meaningful.
// Use actual file size after spool completes — may differ from the JDBC-declared
// length for some IBM i LOB types.
long actualSize = entry.getActualSize();
resp.setContentType("application/octet-stream");
resp.setHeader("Content-Disposition", "attachment; filename=\"blob\"");
resp.setHeader("Content-Length", String.valueOf(actualSize));
resp.setHeader("Cache-Control", "no-store");

try {
OutputStream out = resp.getOutputStream();
byte[] buf = new byte[65536];
int read;
while ((read = in.read(buf)) != -1) {
out.write(buf, 0, read);
}
out.flush();
// Sanitise token before logging to prevent log injection.
String safeToken = token.replaceAll("[^a-fA-F0-9\\-]", "?");
Tracer.info("BlobServlet: streamed token " + safeToken + " (" + actualSize + " bytes)");
} finally {
try { in.close(); } catch (IOException ignored) {}
entry.cleanup();
}
}
}
Loading
Loading