| 
40 | 40 | import java.nio.charset.Charset;  | 
41 | 41 | import java.nio.file.Files;  | 
42 | 42 | import java.nio.file.StandardOpenOption;  | 
 | 43 | +import java.util.UUID;  | 
43 | 44 | import java.util.zip.GZIPInputStream;  | 
 | 45 | +import net.sf.json.JSONObject;  | 
44 | 46 | import org.apache.commons.io.output.CountingOutputStream;  | 
45 | 47 | import org.kohsuke.stapler.ReflectionUtils;  | 
 | 48 | +import org.kohsuke.stapler.Stapler;  | 
46 | 49 | import org.kohsuke.stapler.StaplerRequest;  | 
47 | 50 | import org.kohsuke.stapler.StaplerRequest2;  | 
48 | 51 | import org.kohsuke.stapler.StaplerResponse;  | 
@@ -84,12 +87,19 @@ public interface Source {  | 
84 | 87 |         boolean exists();  | 
85 | 88 |     }  | 
86 | 89 | 
 
  | 
 | 90 | +    /**  | 
 | 91 | +     * For multipart/form-data streaming mode: Enable searching for the first new line character after the provided ?start and abort searching at the given position.  | 
 | 92 | +     */  | 
 | 93 | +    protected static final String SEARCH_STOP_PARAMETER = "searchNewLineUntil";  | 
 | 94 | + | 
87 | 95 |     private final Source source;  | 
88 | 96 | 
 
  | 
89 | 97 |     protected final Charset charset;  | 
90 | 98 | 
 
  | 
91 | 99 |     private volatile boolean completed;  | 
92 | 100 | 
 
  | 
 | 101 | +    private JSONObject streamingMeta;  | 
 | 102 | + | 
93 | 103 |     public LargeText(File file, boolean completed) {  | 
94 | 104 |         this(file, Charset.defaultCharset(), completed);  | 
95 | 105 |     }  | 
@@ -257,13 +267,24 @@ public long writeLogTo(long start, OutputStream out) throws IOException {  | 
257 | 267 |     }  | 
258 | 268 | 
 
  | 
259 | 269 |     private void writeLogUncounted(long start, OutputStream os) throws IOException {  | 
260 |  | - | 
261 | 270 |         try (Session f = source.open()) {  | 
262 | 271 |             if (f.skip(start) != start) {  | 
263 | 272 |                 throw new EOFException("Attempted to read past the end of the log");  | 
264 | 273 |             }  | 
265 | 274 | 
 
  | 
266 |  | -            if (completed) {  | 
 | 275 | +            if (isStreamingRequest(Stapler.getCurrentRequest2())) {  | 
 | 276 | +                // write everything until the previously determined EOF in bulk  | 
 | 277 | +                long end = source.length();  | 
 | 278 | +                long pos = start;  | 
 | 279 | +                byte[] buf = new byte[64 * 1024];  | 
 | 280 | +                int n;  | 
 | 281 | +                while (pos < end && (n = f.read(buf)) >= 0) {  | 
 | 282 | +                    long remaining = end - pos;  | 
 | 283 | +                    if (n > remaining) n = (int) remaining;  | 
 | 284 | +                    os.write(buf, 0, n);  | 
 | 285 | +                    pos += n;  | 
 | 286 | +                }  | 
 | 287 | +            } else if (completed) {  | 
267 | 288 |                 // write everything till EOF  | 
268 | 289 |                 byte[] buf = new byte[1024];  | 
269 | 290 |                 int sz;  | 
@@ -310,7 +331,111 @@ public void doProgressText(StaplerRequest req, StaplerResponse rsp) throws IOExc  | 
310 | 331 |         doProgressTextImpl(StaplerRequest.toStaplerRequest2(req), StaplerResponse.toStaplerResponse2(rsp));  | 
311 | 332 |     }  | 
312 | 333 | 
 
  | 
 | 334 | +    /**  | 
 | 335 | +     * Detect use of streaming mode.  | 
 | 336 | +     * @param req The current request.  | 
 | 337 | +     * @return true if the new streaming mode is requested.  | 
 | 338 | +     */  | 
 | 339 | +    public boolean isStreamingRequest(StaplerRequest2 req) {  | 
 | 340 | +        if (req == null) return false;  | 
 | 341 | +        String accept = req.getHeader("Accept");  | 
 | 342 | +        if (accept == null || accept.isEmpty()) return false;  | 
 | 343 | +        return accept.startsWith("multipart/form-data");  | 
 | 344 | +    }  | 
 | 345 | + | 
 | 346 | +    /**  | 
 | 347 | +     * Add additional meta data to a streaming response.  | 
 | 348 | +     * @param key The field to (over)write meta data for.  | 
 | 349 | +     * @param value The meta data value.  | 
 | 350 | +     */  | 
 | 351 | +    protected void putStreamingMeta(String key, Object value) {  | 
 | 352 | +        if (streamingMeta == null) streamingMeta = new JSONObject();  | 
 | 353 | +        streamingMeta.put(key, value);  | 
 | 354 | +    }  | 
 | 355 | + | 
 | 356 | +    private long findNextLineStart(long start, long stop) throws IOException {  | 
 | 357 | +        try (var f = source.open()) {  | 
 | 358 | +            if (f.skip(start) != start) {  | 
 | 359 | +                // The log file rolled over, send it in full.  | 
 | 360 | +                return 0;  | 
 | 361 | +            }  | 
 | 362 | +            byte[] buf = new byte[64 * 1024];  | 
 | 363 | +            long searchPosition = start;  | 
 | 364 | +            int n;  | 
 | 365 | +            while (searchPosition + 1 < stop && (n = f.read(buf)) > 0) {  | 
 | 366 | +                for (int i = 0; i < n && searchPosition + 1 < stop; i++) {  | 
 | 367 | +                    if (buf[i] == '\n') {  | 
 | 368 | +                        // We have a match, send from after the \n.  | 
 | 369 | +                        putStreamingMeta("startFromNewLine", true);  | 
 | 370 | +                        return searchPosition + 1;  | 
 | 371 | +                    }  | 
 | 372 | +                    searchPosition++;  | 
 | 373 | +                }  | 
 | 374 | +            }  | 
 | 375 | +        }  | 
 | 376 | +        // fall back to original start.  | 
 | 377 | +        return start;  | 
 | 378 | +    }  | 
 | 379 | + | 
 | 380 | +    private void doProgressTextStreaming(StaplerRequest2 req, StaplerResponse2 rsp) throws IOException {  | 
 | 381 | +        setContentType(rsp);  | 
 | 382 | +        String contentType = rsp.getContentType();  | 
 | 383 | +        if (contentType == null || contentType.isEmpty()) {  | 
 | 384 | +            contentType = "text/plain;charset=UTF-8";  | 
 | 385 | +        }  | 
 | 386 | +        if (contentType.contains("\r") || contentType.contains("\n")) {  | 
 | 387 | +            throw new IOException("Found CR/LF in Content-Type. Aborting streaming mode");  | 
 | 388 | +        }  | 
 | 389 | +        String boundary = UUID.randomUUID().toString();  | 
 | 390 | +        rsp.setContentType("multipart/form-data;boundary=" + boundary);  | 
 | 391 | +        rsp.setStatus(HttpServletResponse.SC_OK);  | 
 | 392 | +        putStreamingMeta("completed", completed);  | 
 | 393 | + | 
 | 394 | +        try (var writer = rsp.getWriter()) {  | 
 | 395 | +            writer.write("--" + boundary + "\r\n" // preamble for first part  | 
 | 396 | +                    + "Content-Disposition: form-data;name=text\r\n"  | 
 | 397 | +                    + "Content-Type: " + contentType + "\r\n"  | 
 | 398 | +                    + "\r\n");  | 
 | 399 | +            long length = source.exists() ? source.length() : 0;  | 
 | 400 | + | 
 | 401 | +            String s = req.getParameter("start");  | 
 | 402 | +            long start = (s != null) ? Long.parseLong(s) : 0;  | 
 | 403 | +            if (start > length) {  | 
 | 404 | +                // text rolled over, send in full  | 
 | 405 | +                start = 0;  | 
 | 406 | +            } else if (start < 0 && length <= -start) {  | 
 | 407 | +                // tail on small file, send in full  | 
 | 408 | +                start = 0;  | 
 | 409 | +            } else if (start < 0) {  | 
 | 410 | +                // tail on large file, start at first new line in tail  | 
 | 411 | +                start = findNextLineStart(length + start, length);  | 
 | 412 | +            } else if (req.getParameter(SEARCH_STOP_PARAMETER) != null) {  | 
 | 413 | +                // fetch more, start at first new line before last start  | 
 | 414 | +                long searchStop = Long.parseLong(req.getParameter(SEARCH_STOP_PARAMETER));  | 
 | 415 | +                start = findNextLineStart(start, searchStop);  | 
 | 416 | +            }  | 
 | 417 | +            putStreamingMeta("start", start);  | 
 | 418 | + | 
 | 419 | +            long end = length;  | 
 | 420 | +            if (start != end) {  | 
 | 421 | +                end = writeLogTo(start, writer);  | 
 | 422 | +            }  | 
 | 423 | +            putStreamingMeta("end", end);  | 
 | 424 | + | 
 | 425 | +            writer.write("\r\n--" + boundary + "\r\n" // transition to next part  | 
 | 426 | +                    + "Content-Disposition: form-data;name=meta\r\n"  | 
 | 427 | +                    + "Content-Type: application/json;charset=utf-8\r\n"  | 
 | 428 | +                    + "\r\n"  | 
 | 429 | +                    + streamingMeta + "\r\n--" + boundary + "--");  | 
 | 430 | +        }  | 
 | 431 | +    }  | 
 | 432 | + | 
313 | 433 |     private void doProgressTextImpl(StaplerRequest2 req, StaplerResponse2 rsp) throws IOException {  | 
 | 434 | +        if (isStreamingRequest(req)) {  | 
 | 435 | +            doProgressTextStreaming(req, rsp);  | 
 | 436 | +            return;  | 
 | 437 | +        }  | 
 | 438 | + | 
314 | 439 |         setContentType(rsp);  | 
315 | 440 |         rsp.setStatus(HttpServletResponse.SC_OK);  | 
316 | 441 | 
 
  | 
 | 
0 commit comments