From 00e2f381d34977db1cc3b6bb770eceff3ea3f530 Mon Sep 17 00:00:00 2001 From: Alejandro Ventura De Moya Date: Wed, 21 Oct 2026 12:25:24 -0700 Subject: [PATCH] FIREFLY-2032: Add direct download of files --- .../server/network/HttpServiceInput.java | 7 + .../firefly/server/network/HttpServices.java | 6 + .../server/servlets/AnyFileDownload.java | 175 ++++++++ .../js/core/background/BackgroundUtil.js | 33 +- src/firefly/js/core/background/JobInfo.jsx | 4 +- src/firefly/js/core/background/JobMonitor.jsx | 80 ++-- .../__tests__/BackgroundUtil-test.js | 74 +++ .../background/__tests__/JobMonitor-test.jsx | 93 ++++ .../server/servlets/AnyFileDownloadTest.java | 420 ++++++++++++++++++ 9 files changed, 856 insertions(+), 36 deletions(-) create mode 100644 src/firefly/js/core/background/__tests__/BackgroundUtil-test.js create mode 100644 src/firefly/js/core/background/__tests__/JobMonitor-test.jsx create mode 100644 src/firefly/test/edu/caltech/ipac/firefly/server/servlets/AnyFileDownloadTest.java diff --git a/src/firefly/java/edu/caltech/ipac/firefly/server/network/HttpServiceInput.java b/src/firefly/java/edu/caltech/ipac/firefly/server/network/HttpServiceInput.java index 5d399aad29..efcac7b2b3 100644 --- a/src/firefly/java/edu/caltech/ipac/firefly/server/network/HttpServiceInput.java +++ b/src/firefly/java/edu/caltech/ipac/firefly/server/network/HttpServiceInput.java @@ -39,6 +39,7 @@ public class HttpServiceInput implements Cloneable, Serializable { private String userId; private String passwd; private boolean followRedirect = true; + private int readTimeout; // milliseconds to wait for the next byte from the remote server; 0 waits forever public HttpServiceInput() {} @@ -207,6 +208,12 @@ public HttpServiceInput setFollowRedirect(boolean followRedirect) { return this; } + public int getReadTimeout() { return readTimeout; } + public HttpServiceInput setReadTimeout(int readTimeout) { + this.readTimeout = readTimeout; + return this; + } + public String getDesc() { StringBuilder sb = new StringBuilder(); diff --git a/src/firefly/java/edu/caltech/ipac/firefly/server/network/HttpServices.java b/src/firefly/java/edu/caltech/ipac/firefly/server/network/HttpServices.java index 01124235cf..d6f0e2d225 100644 --- a/src/firefly/java/edu/caltech/ipac/firefly/server/network/HttpServices.java +++ b/src/firefly/java/edu/caltech/ipac/firefly/server/network/HttpServices.java @@ -218,6 +218,10 @@ public static Status executeMethod(HttpMethod method, HttpServiceInput input, Ha } HttpClient httpClient = newHttpClient(); + if (input.getReadTimeout() > 0) { + // on the connection manager, not the method: HttpClient 3 drops a method's timeout while it waits for a chunked body + httpClient.getHttpConnectionManager().getParams().setSoTimeout(input.getReadTimeout()); + } handleAuth(httpClient, method, input.getUserId(), input.getPasswd()); @@ -386,6 +390,8 @@ public Status handleResponse(HttpMethod method) { input.getHeaders().entrySet().stream() .filter(h -> isForwardHeader(h.getKey())) .forEach(h -> redirect.setHeader(h.getKey(), h.getValue())); + // carry over the read timeout + redirect.setReadTimeout(input.getReadTimeout()); return getData(redirect, maxFollow-1, handler); } else { diff --git a/src/firefly/java/edu/caltech/ipac/firefly/server/servlets/AnyFileDownload.java b/src/firefly/java/edu/caltech/ipac/firefly/server/servlets/AnyFileDownload.java index 1206ca2997..e323819d0f 100644 --- a/src/firefly/java/edu/caltech/ipac/firefly/server/servlets/AnyFileDownload.java +++ b/src/firefly/java/edu/caltech/ipac/firefly/server/servlets/AnyFileDownload.java @@ -6,9 +6,13 @@ import edu.caltech.ipac.firefly.data.FileInfo; import edu.caltech.ipac.firefly.server.ServerContext; import edu.caltech.ipac.firefly.server.SrvParam; +import edu.caltech.ipac.firefly.server.network.HttpServiceInput; +import edu.caltech.ipac.firefly.server.network.HttpServices; import edu.caltech.ipac.firefly.server.util.LockingRetrieve; import edu.caltech.ipac.firefly.server.util.Logger; +import edu.caltech.ipac.util.AppProperties; import edu.caltech.ipac.util.FileUtil; +import edu.caltech.ipac.util.FormatUtil; import edu.caltech.ipac.util.StringUtils; import edu.caltech.ipac.util.cache.Cache; import edu.caltech.ipac.util.cache.CacheManager; @@ -20,10 +24,14 @@ import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import org.apache.commons.httpclient.HttpMethod; import java.io.File; import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; import java.net.URL; +import java.nio.charset.StandardCharsets; import static edu.caltech.ipac.util.StringUtils.isEmpty; @@ -40,6 +48,7 @@ public class AnyFileDownload extends BaseHttpServlet { public static final String EXT_URL = "externalURL"; // required for downloading something through the firefly server + public static final String STREAM_URL = "streamURL"; // relay a remote file to the browser as it arrives, keeping no copy on the server public static final String HIPS_PARAM = "hipsUrl"; // required for HiPS public static final String ALWAYS_USE_CACHED = "alwaysUseCached"; // true if use the cached without checking public static final String FILE_PARAM = "file"; // required for other request @@ -56,6 +65,7 @@ public class AnyFileDownload extends BaseHttpServlet { private static final String BASE_URL = "servlet/Download?"+ LOG_PARAM +"=true&" + FILE_PARAM +"="; private static final String RET_FILE = "&"+RETURN_PARAM+"="; private static final String DOWNLOAD_COOKIE_PREFIX = "ffdl_"; + static final String STREAM_READ_TIMEOUT = "download.stream.read.timeout.secs"; // seconds of silence from the remote server before a streamURL download fails public static String getDownloadURL(File file, String suggestedFileName) { return getDownloadURL(file, suggestedFileName, ServerContext.getRequestOwner().getBaseUrl()); @@ -80,6 +90,9 @@ protected void processRequest(HttpServletRequest req, HttpServletResponse res) t else if (req.getParameterMap().containsKey(EXT_URL)) { handleExternalURLRequest(req,res,true); } + else if (req.getParameterMap().containsKey(STREAM_URL)) { + handleStreamURLRequest(req,res,true); + } else { handleInternalFileRequest(req,res,true); } @@ -94,6 +107,9 @@ protected void doHead(HttpServletRequest req, HttpServletResponse res) throws IO else if (req.getParameterMap().containsKey(EXT_URL)) { handleExternalURLRequest(req,res,false); } + else if (req.getParameterMap().containsKey(STREAM_URL)) { + handleStreamURLRequest(req,res,false); + } else { handleInternalFileRequest(req,res,false); } @@ -210,6 +226,165 @@ private void handleExternalURLRequest(HttpServletRequest req, HttpServletRespons } + /** + * The Firefly server fetches the remote URL and passes its bytes to the browser as they arrive, + * so a large file starts saving right away instead of after the server has a full copy. + * HttpServiceInput adds the user's login token for the hosts the SsoAdapter allows, also after a redirect. + * The browser's cookies are not forwarded. Nothing is cached or kept on the server, and the bytes are not decoded. + */ + private void handleStreamURLRequest(HttpServletRequest req, HttpServletResponse res, boolean fullBody) + throws IOException { + SrvParam sp = new SrvParam(req.getParameterMap()); + String urlStr = sp.getRequired(STREAM_URL); + if (!urlStr.matches("(?i)https?://.+")) { + sendTextError(res, HttpServletResponse.SC_BAD_REQUEST, "Only http and https URLs can be downloaded: " + urlStr); + return; + } + + StreamRelay relay = new StreamRelay(res, sp.getOptional(RETURN_PARAM), sp.getOptional(DOWNLOAD_TOKEN), fullBody); + // Without a read timeout, a remote server that goes silent holds this Firefly server thread forever, even after + // the browser cancels, because the relay sees a cancel only when it writes. A job's result is already computed, + // so 5 minutes of silence means the remote server has stalled. + int readTimeout = AppProperties.getIntProperty(STREAM_READ_TIMEOUT, 300) * 1000; + HttpServices.Status status = HttpServices.getData(new HttpServiceInput(urlStr).setReadTimeout(readTimeout), relay); + + if (relay.readError != null) { + // The remote server failed after it started sending. Before the response is committed, the browser + // can still get an error page. After that, the exception makes Tomcat drop the connection, + // so the browser marks the download failed instead of saving a truncated file as complete. + if (!res.isCommitted()) { + res.reset(); + sendTextError(res, HttpServletResponse.SC_BAD_GATEWAY, "Download failed while reading " + urlStr + ": " + relay.readError.getMessage()); + return; + } + throw new IOException("Download failed while reading " + urlStr, relay.readError); + } + if (relay.writeError != null) { + _log.info("Stream download stopped by the browser after " + relay.bytes + " bytes: " + urlStr); + } else if (relay.started) { + _log.info("Stream download -- URL: " + urlStr + ", bytes: " + relay.bytes); + } else { + // the remote server never returned the file; the client's download() shows this text in a popup + String reason = status.getStatusCode() + (isEmpty(status.getErrMsg()) ? "" : " " + status.getErrMsg()); + sendTextError(res, HttpServletResponse.SC_BAD_GATEWAY, "Download failed for " + urlStr + ": " + reason); + } + } + + private static void sendTextError(HttpServletResponse res, int status, String msg) throws IOException { + byte[] body = msg.getBytes(StandardCharsets.UTF_8); + res.setStatus(status); + res.setContentType("text/plain;charset=utf-8"); + res.setContentLength(body.length); + res.getOutputStream().write(body); + } + + /** + * Copies the remote server's response to the browser. HttpServices calls it only for a 2xx response, + * after following any redirects. + */ + static class StreamRelay implements HttpServices.Handler { + private final HttpServletResponse res; + private final String returnName; + private final String downloadToken; + private final boolean fullBody; + boolean started; + long bytes; + IOException readError; // the remote server failed + IOException writeError; // the browser went away + + StreamRelay(HttpServletResponse res, String returnName, String downloadToken, boolean fullBody) { + this.res = res; + this.returnName = returnName; + this.downloadToken = downloadToken; + this.fullBody = fullBody; + } + + public HttpServices.Status handleResponse(HttpMethod method) { + String type = HttpServices.getResHeader(method, "Content-Type", null); + String length = HttpServices.getResHeader(method, "Content-Length", null); + String encoding = HttpServices.getResHeader(method, "Content-Encoding", null); + String disposition = HttpServices.getResHeader(method, "Content-Disposition", null); + + res.setStatus(HttpServletResponse.SC_OK); + if (type != null) res.setContentType(type); + if (length != null) res.setHeader("Content-Length", length); + if (encoding != null) res.setHeader("Content-Encoding", encoding); + res.setHeader("Content-Disposition", URLDownload.makeContentDisposition(fileName(method, type, disposition))); + res.setHeader("X-Content-Type-Options", "nosniff"); + res.setHeader("Cache-Control", "private, no-store"); + sendDownloadStartedCookie(downloadToken, res); + started = true; + + if (!fullBody) { + method.abort(); + return HttpServices.Status.ok(); + } + + // The stream is not closed here: when the copy stops early, closing it would make HttpClient read the + // rest of the remote file. abort() drops the connection instead, and HttpServices releases it. + byte[] buffer = new byte[HttpServices.BUFFER_SIZE]; + try { + InputStream in = method.getResponseBodyAsStream(); // raw bytes; HttpServices.getResponseBodyAsStream would gunzip + if (in == null) throw new IOException("the remote server sent no response body"); // HttpClient gives null when a chunked body never starts + OutputStream out = res.getOutputStream(); + while (true) { + int n; + try { + n = in.read(buffer); + } catch (IOException e) { + readError = e; + break; + } + if (n == -1) break; + try { + out.write(buffer, 0, n); + } catch (IOException e) { + writeError = e; + break; + } + bytes += n; + } + } catch (IOException e) { + if (readError == null && writeError == null) readError = e; // getResponseBodyAsStream() or getOutputStream() failed + } + // HttpClient 3 ends the stream quietly when the remote server closes before its Content-Length, + // so the relay compares the bytes it sent with the Content-Length it passed to the browser + long expected = StringUtils.getLong(length, -1); + if (readError == null && writeError == null && bytes < expected) { + readError = new IOException("the remote server closed the connection after " + bytes + " of " + expected + " bytes"); + } + if (readError != null || writeError != null) method.abort(); + return HttpServices.Status.ok(); + } + + /** + * The remote server's file name if it gives one. Otherwise the client's return name, or the file name + * in the final URL, with an extension from the content type or from that URL. + */ + private String fileName(HttpMethod method, String type, String disposition) { + String name = isEmpty(disposition) ? null : URLDownload.getSuggestedFileName(disposition); + if (!isEmpty(name)) return name; + + String urlName = ""; + try { + urlName = URLDownload.sanitizeFilename(new File(method.getURI().getPath()).getName()); + } catch (Exception ignored) {} + if (isEmpty(returnName) && !FileUtil.getExtension(urlName).isEmpty()) return urlName; + name = !isEmpty(returnName) ? returnName : !isEmpty(urlName) ? urlName : "download"; + + String mime = type == null ? "" : type.split(";")[0].trim(); + String ext = FormatUtil.Format.fromMime(mime).fileExt(); + if (ext == null) { + String urlExt = FileUtil.getExtension(urlName); + if (urlExt.matches("[a-z0-9]{1,5}")) ext = "." + urlExt; + else if (mime.matches("(?i)(text|application)/xml")) ext = ".xml"; // e.g. the MAST and HEASARC TAP services send VOTables as text/xml + else ext = ""; + } + return name.toLowerCase().endsWith(ext) ? name : name + ext; + } + } + + /** Let the client know the download has started by setting a short-lived cookie named after its download token. */ public static void sendDownloadStartedCookie(String token, HttpServletResponse res) { if (isEmpty(token) || !token.matches("[A-Za-z0-9_-]{1,64}")) return; diff --git a/src/firefly/js/core/background/BackgroundUtil.js b/src/firefly/js/core/background/BackgroundUtil.js index 5919538e7a..87ab814427 100644 --- a/src/firefly/js/core/background/BackgroundUtil.js +++ b/src/firefly/js/core/background/BackgroundUtil.js @@ -9,7 +9,7 @@ import {makeUniquePlotIdFromBase} from '../../visualize/PlotViewUtil'; import {flux} from '../ReduxFlux'; import {BACKGROUND_PATH, BG_JOB_INFO, dispatchBgLoadJobs, dispatchJobAdd} from './BackgroundCntlr.js'; -import {getCmdSrvAsyncURL, isURL} from '../../util/WebUtil.js'; +import {encodeUrl, getCmdSrvAsyncURL, getRootURL, isURL} from '../../util/WebUtil.js'; import {COMPONENT_STATE_CHANGE, dispatchComponentStateChange, getComponentState} from '../ComponentCntlr.js'; import {dispatchAddActionWatcher} from '../MasterSaga.js'; import {jsonFetch} from '../JsonUtils.js'; @@ -314,6 +314,37 @@ export function fixTapResults(jobInfo) { } +/** + * Returns the URL that downloads a job result's original file, or undefined when there is nothing to download. + * It goes by the result's href, not the job type, so UWS jobs opened from the Upload panel work too. + * @param {Object} job + * @param {number} [resultIdx=0] + * @return {string|undefined} + */ +export function getResultDownloadUrl(job, resultIdx=0) { + const {results=[]} = job ?? {}; + const href = results[resultIdx]?.href?.trim(); + if (!href) return; + + const resultId = results.length > 1 ? `-${results[resultIdx].id ?? resultIdx}` : ''; + const fileName = `${getJobTitle(job) ?? 'result'}${resultId}`; + const downloadUrl = getRootURL() + 'servlet/Download'; + + if (href.startsWith('${')) { // a file the Firefly server saved for a SEARCH job + const ext = href.match(/(\.[A-Za-z0-9]{1,5})$/)?.[1] ?? ''; + return encodeUrl(downloadUrl, {file: href, return: fileName + ext}); + } + if (!/^https?:\/\//i.test(href) || !isURL(href)) return; + + const rootPath = new URL(getRootURL()).pathname; + const {pathname} = new URL(href); + if (pathname === rootPath + 'servlet/Download') return href; // the Firefly server's own download URL, e.g. a PACKAGE result + if (pathname.startsWith(rootPath + 'CmdSrv/async/')) return; // reruns the search and returns Firefly's internal JSON, not a file + + // the Firefly server relays the remote file as it arrives, and adds an extension to the name + return encodeUrl(downloadUrl, {streamURL: href, return: fileName}); +} + export function loadJobResult({jobInfo, resultIdx}) { const {request, tbl_id, loader, href, mimeType, id, size} = getMetadata({jobInfo, resultIdx}); loader?.({jobInfo, request, href, mimeType, id, size}); diff --git a/src/firefly/js/core/background/JobInfo.jsx b/src/firefly/js/core/background/JobInfo.jsx index 5a30acee7b..280b69ec69 100644 --- a/src/firefly/js/core/background/JobInfo.jsx +++ b/src/firefly/js/core/background/JobInfo.jsx @@ -14,7 +14,7 @@ import {TableErrorMsg} from 'firefly/tables/ui/TablePanel.jsx'; import {showInfoPopup} from 'firefly/ui/PopupUtil'; import {PrismADQLAware} from '../../ui/tap/AdvancedADQL'; import {getFieldVal} from '../../fieldGroup/FieldGroupUtils'; -import {jobMonitorGroupKey, ResultsBlock, toDateString, useLocalTimeKey} from '../../core/background/JobMonitor'; +import {DownloadBtn, jobMonitorGroupKey, ResultsBlock, toDateString, useLocalTimeKey} from '../../core/background/JobMonitor'; const dialogID = 'show-job-info'; @@ -96,7 +96,7 @@ export function UwsJobInfo({jobInfo, sx, isOpen=false}) { - + )} diff --git a/src/firefly/js/core/background/JobMonitor.jsx b/src/firefly/js/core/background/JobMonitor.jsx index adc8522709..da46172afd 100644 --- a/src/firefly/js/core/background/JobMonitor.jsx +++ b/src/firefly/js/core/background/JobMonitor.jsx @@ -4,16 +4,16 @@ import {IconButton, Button, Sheet, Skeleton, Stack, Typography, ListItemDecorato import moment from 'moment'; import {Slot, useStoreConnector} from '../../ui/SimpleComponent'; -import {getBackgroundInfo, getJobInfo, getJobTitle, getMetadata, getPhaseTips, isActive, isArchived, isDone, isExecuting, isFail, isSearchJob, isSuccess, loadAllJobs, Phase, loadJobResult, fixTapResults, getProgressMsg} from './BackgroundUtil'; +import {getBackgroundInfo, getJobInfo, getJobTitle, getMetadata, getPhaseTips, isActive, isArchived, isDone, isExecuting, isFail, isSearchJob, isSuccess, loadAllJobs, Phase, loadJobResult, fixTapResults, getProgressMsg, getResultDownloadUrl} from './BackgroundUtil'; import {TablePanel} from '../../tables/ui/TablePanel'; import {ToolbarHorizontalSeparator} from '../../ui/ToolbarButton'; import {getAppOptions} from '../AppDataCntlr'; -import {dispatchBgJobInfo, dispatchBgSetInfo, dispatchJobCancel, dispatchJobRemove, dispatchSetJobNotif} from './BackgroundCntlr'; +import {dispatchBgSetInfo, dispatchJobCancel, dispatchJobRemove, dispatchSetJobNotif} from './BackgroundCntlr'; import {InputField} from '../../ui/InputField'; import Validate from '../../util/Validate'; import {dispatchTableAddLocal, TABLE_HIGHLIGHT} from '../../tables/TablesCntlr'; import {showInfoPopup, showYesNoPopup} from '../../ui/PopupUtil'; -import {isDefined, updateSet} from '../../util/WebUtil'; +import {isDefined} from '../../util/WebUtil'; import {download} from '../../util/fetch'; import {isJobInfoOpen, showJobInfo} from './JobInfo'; import {dispatchHideDropDown, dispatchShowDropDown} from '../LayoutCntlr'; @@ -121,7 +121,7 @@ export function showMultiDownloads(job) { const {results} = job || []; const popup = results.map( (r, idx) => ( - + )); @@ -309,8 +309,9 @@ function ControlRenderer({cellInfo}) { const job = useStoreConnector(() => getJobInfo(jobId), [jobId]); if (!job?.meta?.jobId) return null; + // the table reuses a row's cells for whichever job sorts into that row; the key gives each job its own button state return ( - + @@ -375,7 +376,13 @@ function Results({job}) { if (!isSuccess(job)) return null; if (isSearchJob(job)) { const fJob = fixTapResults(job); - return fJob?.results?.length === 1 ? : ; + if (fJob?.results?.length !== 1) return ; + return ( + <> + + + + ); } else { if (job?.results?.length === 1) { return ; @@ -400,9 +407,9 @@ export function showMultiMultiResults(job) { } else { showInfoPopup( - This job has multiple results. Please click on each icon to load the corresponding result. + This job has multiple results. Please click on each icon to load or download the corresponding result. - + ); @@ -412,7 +419,7 @@ export function showMultiMultiResults(job) { export function ResultsBlock({job, ActionBtn}) { const {results=[]} = job; return results.map( (r, idx) => ( - + {ActionBtn && } @@ -423,10 +430,10 @@ export function ResultDesc({job, resultIdx=0}) { const {href, id, mimeType} = getMetadata({jobInfo:job, resultIdx}); const idDesc = id || resultIdx; return ( - + - + ); } @@ -447,19 +454,38 @@ export function ShowResultButton({job, resultIdx=0}) { } } +function ShowResultAndDownloadButtons(props) { + return ( + <> + + + + ); +} -function DownloadBtn({job, index=0}) { - const dlState = useStoreConnector( () => getJobInfo(job?.meta?.jobId)?.downloadState?.[index], [job?.meta?.jobId, index]); - const stateMap = (state) => ({ - DONE: ['neutral', 'Downloaded'], +/** + * Downloads a result's original file. Renders nothing when the result has no file to download. + * The download state is kept in the component's state, not in the job store: every poll replaces the job objects, + * and a UWS job opened from the Upload panel is not in the job store. + * @param {Object} p - props + * @param {Object} p.job + * @param {number} [p.resultIdx=0] + */ +export function DownloadBtn({job, resultIdx=0}) { + const [dlState, setDlState] = useState(); + const url = getResultDownloadUrl(job, resultIdx); + if (!url) return null; + + const [color, title] = { + DONE: ['neutral', 'Download started'], FAIL: ['danger', 'Download may have failed or timed out'], - }[state] ?? ['primary', 'Click to download file']); - const loading = dlState === 'WORKING'; - const color = stateMap(dlState)[0]; - const title = stateMap(dlState)[1]; - + }[dlState] ?? ['primary', 'Click to download file']; + const onClick = () => { + setDlState('WORKING'); + download(url).then((started) => setDlState(started ? 'DONE' : 'FAIL')); + }; return ( - doDownload(job, index)} {...{color, loading, title}}> + ); } @@ -516,18 +542,6 @@ function defaultRequest(doFilter) { return {sortInfo, filters}; } -function doDownload(job, index) { - const url = job?.results?.[index]?.href; - if (!url) { - showInfoPopup('Bad URL. Cannot download'); - return; - } - dispatchBgJobInfo( updateSet(job, ['downloadState',index], 'WORKING') ); - download(url).then( (started) => { - dispatchBgJobInfo( updateSet(job, ['downloadState',index], started ? 'DONE' : 'FAIL') ); - }); -} - function getMonitoredJob(jobMap) { return (jobMap ? Object.values(jobMap) : []) .filter((job) => job?.meta?.monitored) // only monitored jobs diff --git a/src/firefly/js/core/background/__tests__/BackgroundUtil-test.js b/src/firefly/js/core/background/__tests__/BackgroundUtil-test.js new file mode 100644 index 0000000000..eda1d96a43 --- /dev/null +++ b/src/firefly/js/core/background/__tests__/BackgroundUtil-test.js @@ -0,0 +1,74 @@ +/*eslint-env node, jest */ + +import {getResultDownloadUrl} from '../BackgroundUtil.js'; +import {getRootURL} from '../../../util/WebUtil.js'; + +const downloadServlet = () => getRootURL() + 'servlet/Download'; + +// splits a download URL into the servlet it calls and the parameters the Firefly server decodes from it +function parse(url) { + const {origin, pathname, searchParams} = new URL(url); + return {servlet: origin + pathname, params: Object.fromEntries(searchParams)}; +} + +const makeJob = (results, title = 'TAP 2MASS M81') => ({jobId: '25113176', jobInfo: {title}, results}); + +describe('getResultDownloadUrl', () => { + + test('builds a Download servlet URL with the remote href as streamURL, unchanged', () => { + // a signed storage URL breaks if a single character changes, including its escapes + const href = 'https://storage.example.org/b/result.parquet?X-Goog-Signature=ab%2Fcd%3D&X-Goog-Expires=3600'; + + const {servlet, params} = parse(getResultDownloadUrl(makeJob([{id: 'result', href}]))); + + expect(servlet).toBe(downloadServlet()); + expect(params).toEqual({streamURL: href, return: 'TAP 2MASS M81'}); + }); + + test('builds a Download servlet URL for a SEARCH job\'s saved file, with its extension on the name', () => { + const href = '${stage}/jobs/GX/GatorQuery2114264923686407498.tbl'; + + const {servlet, params} = parse(getResultDownloadUrl(makeJob([{id: 'result', href}], 'Gator 2MASS M81'))); + + expect(servlet).toBe(downloadServlet()); + expect(params).toEqual({file: href, return: 'Gator 2MASS M81.tbl'}); + }); + + test('returns a PACKAGE result, which is already a Firefly download URL, unchanged', () => { + const href = downloadServlet() + '?log=true&file=%24%7Bstage%7D%2Fjobs%2FKJ%2FSIASearch-K5na.zip&return=SIASearch.zip'; + + expect(getResultDownloadUrl(makeJob([{id: 'SIASearch.zip', href}]))).toBe(href); + }); + + test('returns no URL for a Firefly async result', () => { + const href = getRootURL() + 'CmdSrv/async/KJ2wy2kHzq6eW7Lz9KK5na/results/result'; + + expect(getResultDownloadUrl(makeJob([{id: 'result', href}]))).toBeUndefined(); + }); + + test.each([ + ['no results', undefined], + ['an empty href', [{href: ' '}]], + ['a relative href', [{href: 'results/result'}]], + ['an ftp href', [{href: 'ftp://ftp.example.org/x.fits'}]], + ['a file href', [{href: 'file:///etc/passwd'}]], + ])('returns no URL for %s', (desc, results) => { + expect(getResultDownloadUrl(makeJob(results))).toBeUndefined(); + }); + + test('appends each result\'s id, or its position when it has none, to the download name of a multi-result job', () => { + const job = makeJob([ + {id: 'curl-script', href: 'https://example.org/a.sh'}, + {href: 'https://example.org/b.txt'}, + ]); + + expect(parse(getResultDownloadUrl(job, 0)).params).toEqual({streamURL: 'https://example.org/a.sh', return: 'TAP 2MASS M81-curl-script'}); + expect(parse(getResultDownloadUrl(job, 1)).params).toEqual({streamURL: 'https://example.org/b.txt', return: 'TAP 2MASS M81-1'}); + }); + + test('names the download after the jobId when the job has no title or runId', () => { + const job = {jobId: '25113176', results: [{id: 'result', href: 'https://irsa.ipac.caltech.edu/TAP/async/25113176/results/result'}]}; + + expect(parse(getResultDownloadUrl(job)).params.return).toBe('25113176'); + }); +}); diff --git a/src/firefly/js/core/background/__tests__/JobMonitor-test.jsx b/src/firefly/js/core/background/__tests__/JobMonitor-test.jsx new file mode 100644 index 0000000000..215e09dd13 --- /dev/null +++ b/src/firefly/js/core/background/__tests__/JobMonitor-test.jsx @@ -0,0 +1,93 @@ +/*eslint-env node, jest */ + +import React from 'react'; +import '@testing-library/jest-dom'; +import {render, screen, waitFor} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import {cloneDeep} from 'lodash'; + +import * as fetchUtil from '../../../util/fetch.js'; +import {getJobInfo} from '../BackgroundUtil.js'; +import {dispatchBgLoadJobs} from '../BackgroundCntlr.js'; +import {getRootURL} from '../../../util/WebUtil.js'; + +// __jest__/InitTest.js replaces JobMonitor with a stub for every suite +const {DownloadBtn, ResultsBlock} = jest.requireActual('../JobMonitor.jsx'); + +const job = { + meta: {jobId: 'tap-job'}, + jobId: '25113176', + results: [ + {id: 'result', href: 'https://irsa.ipac.caltech.edu/TAP/async/25113176/results/result'}, + {id: 'log', href: 'https://irsa.ipac.caltech.edu/TAP/async/25113176/results/log'}, + ], +}; + +// a download() that stays in progress until the test settles it +function stubDownload() { + let settle; + const spy = jest.spyOn(fetchUtil, 'download').mockReturnValue(new Promise((resolve) => settle = resolve)); + return {spy, settle: (started) => settle(started)}; +} + +describe('DownloadBtn', () => { + + afterEach(() => { + jest.restoreAllMocks(); + dispatchBgLoadJobs({jobs: {}}); + }); + + test('renders nothing for a result that has no file to download', () => { + const asyncJob = {...job, results: [{id: 'result', href: getRootURL() + 'CmdSrv/async/x/results/result'}]}; + + const {container} = render(); + + expect(container).toBeEmptyDOMElement(); + }); + + test('calls download() with the URL of the row clicked in the Results block', async () => { + const {spy} = stubDownload(); + render(); + + await userEvent.click(screen.getAllByRole('button', {name: 'Click to download file'})[1]); + + expect(spy).toHaveBeenCalledTimes(1); + expect(new URL(spy.mock.calls[0][0]).searchParams.get('streamURL')).toBe(job.results[1].href); + }); + + test('shows progress, then that the download started', async () => { + const {settle} = stubDownload(); + render(); + + await userEvent.click(screen.getByRole('button')); + expect(screen.getByRole('button')).toBeDisabled(); + + settle(true); + await waitFor(() => expect(screen.getByRole('button')).toHaveAccessibleName('Download started')); + expect(screen.getByRole('button')).toBeEnabled(); + }); + + test('shows a failed download', async () => { + const {settle} = stubDownload(); + render(); + + await userEvent.click(screen.getByRole('button')); + settle(false); + + await waitFor(() => expect(screen.getByRole('button')).toHaveAccessibleName('Download may have failed or timed out')); + }); + + test('keeps its state when a poll replaces the job in the store', async () => { + dispatchBgLoadJobs({jobs: {[job.meta.jobId]: job}}); + const {settle} = stubDownload(); + const {rerender} = render(); + await userEvent.click(screen.getByRole('button')); + settle(true); + await waitFor(() => expect(screen.getByRole('button')).toHaveAccessibleName('Download started')); + + dispatchBgLoadJobs({jobs: {[job.meta.jobId]: cloneDeep(job)}}); // what the Job Monitor's poll does + rerender(); + + expect(screen.getByRole('button')).toHaveAccessibleName('Download started'); + }); +}); diff --git a/src/firefly/test/edu/caltech/ipac/firefly/server/servlets/AnyFileDownloadTest.java b/src/firefly/test/edu/caltech/ipac/firefly/server/servlets/AnyFileDownloadTest.java new file mode 100644 index 0000000000..72d6056423 --- /dev/null +++ b/src/firefly/test/edu/caltech/ipac/firefly/server/servlets/AnyFileDownloadTest.java @@ -0,0 +1,420 @@ +/* + * License information at https://github.com/Caltech-IPAC/firefly/blob/master/License.txt + */ +package edu.caltech.ipac.firefly.server.servlets; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; +import edu.caltech.ipac.firefly.ConfigTest; +import jakarta.servlet.ServletOutputStream; +import jakarta.servlet.WriteListener; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.lang.reflect.Proxy; +import java.net.InetSocketAddress; +import java.net.SocketTimeoutException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import java.util.zip.GZIPOutputStream; + +import static edu.caltech.ipac.firefly.server.servlets.AnyFileDownload.DOWNLOAD_TOKEN; +import static edu.caltech.ipac.firefly.server.servlets.AnyFileDownload.RETURN_PARAM; +import static edu.caltech.ipac.firefly.server.servlets.AnyFileDownload.STREAM_READ_TIMEOUT; +import static edu.caltech.ipac.firefly.server.servlets.AnyFileDownload.STREAM_URL; +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +/** + * Tests the streamURL mode of servlet/Download against a remote server running in this JVM. + */ +public class AnyFileDownloadTest extends ConfigTest { + + private HttpServer upstream; + private String baseUrl; + + @Before + public void startUpstream() throws IOException { + upstream = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + upstream.setExecutor(java.util.concurrent.Executors.newCachedThreadPool()); + upstream.start(); + baseUrl = "http://127.0.0.1:" + upstream.getAddress().getPort(); + } + + @After + public void stopUpstream() { + upstream.stop(0); + } + + @Test + public void passesBytesThroughUnchanged() throws Exception { + byte[] gz = new byte[200_000]; + new Random(1).nextBytes(gz); + upstream.createContext("/cat.vot.gz", ex -> { + ex.getResponseHeaders().set("Content-Type", "application/gzip"); + ex.getResponseHeaders().set("Content-Disposition", "attachment; filename=\"cat.vot.gz\""); + ex.sendResponseHeaders(200, gz.length); + try (OutputStream os = ex.getResponseBody()) { os.write(gz); } + }); + + FakeResponse res = new FakeResponse(new ByteArrayOutputStream()); + new AnyFileDownload().processRequest(request(STREAM_URL, baseUrl + "/cat.vot.gz", RETURN_PARAM, "job-7", DOWNLOAD_TOKEN, "tok1"), res.proxy); + + assertEquals(200, res.status); + assertArrayEquals(gz, res.body()); + assertEquals("application/gzip", res.headers.get("Content-Type")); + assertEquals(String.valueOf(gz.length), res.headers.get("Content-Length")); + assertNull("not decoded, so no encoding to pass on", res.headers.get("Content-Encoding")); + assertTrue(res.headers.get("Content-Disposition").startsWith("attachment; filename=\"cat.vot.gz\"")); + assertEquals("nosniff", res.headers.get("X-Content-Type-Options")); + assertEquals("ffdl_tok1", res.cookies.get(0).getName()); + } + + @Test + public void passesGzipEncodingThroughForTheBrowserToDecode() throws Exception { + ByteArrayOutputStream compressed = new ByteArrayOutputStream(); + try (GZIPOutputStream gz = new GZIPOutputStream(compressed)) { + gz.write("10.6841.27\n".repeat(5_000).getBytes(StandardCharsets.UTF_8)); + } + byte[] gzBody = compressed.toByteArray(); + upstream.createContext("/cat.vot", ex -> { + ex.getResponseHeaders().set("Content-Type", "application/x-votable+xml"); + ex.getResponseHeaders().set("Content-Encoding", "gzip"); + ex.sendResponseHeaders(200, gzBody.length); + try (OutputStream os = ex.getResponseBody()) { os.write(gzBody); } + }); + + FakeResponse res = new FakeResponse(new ByteArrayOutputStream()); + new AnyFileDownload().processRequest(request(STREAM_URL, baseUrl + "/cat.vot", RETURN_PARAM, "job-7"), res.proxy); + + assertEquals(200, res.status); + assertArrayEquals("still compressed", gzBody, res.body()); + assertEquals("gzip", res.headers.get("Content-Encoding")); + assertEquals(String.valueOf(gzBody.length), res.headers.get("Content-Length")); + } + + @Test + public void startsSendingBeforeTheRemoteFileEnds() throws Exception { + CountDownLatch finishUpstream = new CountDownLatch(1); + upstream.createContext("/slow", ex -> { + ex.sendResponseHeaders(200, 0); // chunked: no length known up front + OutputStream os = ex.getResponseBody(); + os.write(new byte[64 * 1024]); + os.flush(); + await(finishUpstream); + os.write(new byte[64 * 1024]); + os.close(); + }); + + CountDownLatch firstPartArrived = new CountDownLatch(1); + FakeResponse res = new FakeResponse(new ByteArrayOutputStream() { + @Override public synchronized void write(byte[] b, int off, int len) { + super.write(b, off, len); + if (size() >= 64 * 1024) firstPartArrived.countDown(); + } + }); + Thread t = new Thread(() -> runQuietly(() -> new AnyFileDownload().processRequest(request(STREAM_URL, baseUrl + "/slow"), res.proxy))); + t.start(); + + assertTrue("the first 64 KB should reach the browser while the remote server is still sending", + firstPartArrived.await(10, TimeUnit.SECONDS)); + finishUpstream.countDown(); + t.join(10_000); + assertEquals(128 * 1024, res.body().length); + } + + @Test + public void remoteErrorBecomesAReadableMessage() throws Exception { + upstream.createContext("/gone", ex -> { + ex.sendResponseHeaders(404, -1); + ex.close(); + }); + + FakeResponse res = new FakeResponse(new ByteArrayOutputStream()); + new AnyFileDownload().processRequest(request(STREAM_URL, baseUrl + "/gone", DOWNLOAD_TOKEN, "tok2"), res.proxy); + + assertEquals(502, res.status); + String text = new String(res.body(), StandardCharsets.UTF_8); + assertTrue(text, text.contains(baseUrl + "/gone") && text.contains("404")); + assertNull(res.headers.get("Content-Disposition")); + assertTrue("no download-started cookie on failure", res.cookies.isEmpty()); + } + + @Test + public void remoteFileCutShortBeforeSendingBecomesAReadableMessage() throws Exception { + upstream.createContext("/cut0", cutShort(1000, new byte[0])); + + FakeResponse res = new FakeResponse(new ByteArrayOutputStream()); + new AnyFileDownload().processRequest(request(STREAM_URL, baseUrl + "/cut0", DOWNLOAD_TOKEN, "tok3"), res.proxy); + + assertEquals(502, res.status); + String text = new String(res.body(), StandardCharsets.UTF_8); + assertTrue(text, text.contains("0 of 1000 bytes")); + assertTrue("no download-started cookie on failure", res.cookies.isEmpty()); + } + + @Test + public void remoteFileCutShortAfterSendingDropsTheConnection() throws Exception { + upstream.createContext("/cut", cutShort(200_000, new byte[100_000])); + + FakeResponse res = new FakeResponse(new ByteArrayOutputStream()); + try { + new AnyFileDownload().processRequest(request(STREAM_URL, baseUrl + "/cut"), res.proxy); + assertTrue("the servlet should throw so Tomcat drops the connection", false); + } catch (IOException e) { + assertTrue(e.getCause().getMessage(), e.getCause().getMessage().contains("100000 of 200000 bytes")); + } + } + + @Test + public void remoteServerGoingSilentAfterARedirectEndsTheDownload() throws Exception { + upstream.createContext("/stall", sendsThenHangs(new byte[64 * 1024])); + upstream.createContext("/moved", ex -> { // TAP result URLs often redirect; the timeout must carry over + ex.getResponseHeaders().set("Location", baseUrl + "/stall"); + ex.sendResponseHeaders(302, -1); + ex.close(); + }); + + FakeResponse res = new FakeResponse(new ByteArrayOutputStream()); + System.setProperty(STREAM_READ_TIMEOUT, "1"); + try { + new AnyFileDownload().processRequest(request(STREAM_URL, baseUrl + "/moved"), res.proxy); + assertTrue("the servlet should throw so Tomcat drops the connection", false); + } catch (IOException e) { + assertTrue(String.valueOf(e.getCause()), e.getCause() instanceof SocketTimeoutException); + } finally { + System.clearProperty(STREAM_READ_TIMEOUT); + } + assertEquals(64 * 1024, res.body().length); + } + + @Test + public void remoteServerSilentBeforeTheFirstChunkBecomesAReadableMessage() throws Exception { + upstream.createContext("/silent", sendsThenHangs(new byte[0])); + + FakeResponse res = new FakeResponse(new ByteArrayOutputStream()); + System.setProperty(STREAM_READ_TIMEOUT, "1"); + try { + new AnyFileDownload().processRequest(request(STREAM_URL, baseUrl + "/silent", DOWNLOAD_TOKEN, "tok4"), res.proxy); + } finally { + System.clearProperty(STREAM_READ_TIMEOUT); + } + + assertEquals(502, res.status); + String text = new String(res.body(), StandardCharsets.UTF_8); + assertTrue(text, text.contains("no response body")); + assertTrue("no download-started cookie on failure", res.cookies.isEmpty()); + } + + @Test + public void rejectsNonHttpUrls() throws Exception { + FakeResponse res = new FakeResponse(new ByteArrayOutputStream()); + new AnyFileDownload().processRequest(request(STREAM_URL, "file:///etc/passwd"), res.proxy); + assertEquals(400, res.status); + } + + @Test + public void namesTheFileFromReturnAndContentType() throws Exception { + // return name + extension of the content type: "my query" + ".xml" (application/x-votable+xml) + upstream.createContext("/async/42/results/result", text("application/x-votable+xml", "")); + FakeResponse res = new FakeResponse(new ByteArrayOutputStream()); + new AnyFileDownload().processRequest(request(STREAM_URL, baseUrl + "/async/42/results/result", RETURN_PARAM, "my query"), res.proxy); + assertTrue(res.headers.get("Content-Disposition"), res.headers.get("Content-Disposition").startsWith("attachment; filename=\"my query.xml\"")); + + // no return name: URL file name + extension of the content type: "result" + ".xml" + FakeResponse noReturn = new FakeResponse(new ByteArrayOutputStream()); + new AnyFileDownload().processRequest(request(STREAM_URL, baseUrl + "/async/42/results/result"), noReturn.proxy); + assertTrue(noReturn.headers.get("Content-Disposition"), noReturn.headers.get("Content-Disposition").startsWith("attachment; filename=\"result.xml\"")); + + // no return name, and the URL file name has an extension: the URL file name as is: "cat.vot" + upstream.createContext("/data/cat.vot", text("application/x-votable+xml", "")); + FakeResponse urlName = new FakeResponse(new ByteArrayOutputStream()); + new AnyFileDownload().processRequest(request(STREAM_URL, baseUrl + "/data/cat.vot"), urlName.proxy); + assertTrue(urlName.headers.get("Content-Disposition"), urlName.headers.get("Content-Disposition").startsWith("attachment; filename=\"cat.vot\"")); + + // text/xml has no known extension, nor does the URL file name: return name + ".xml": "mast query" + ".xml" + upstream.createContext("/async/43/results/result", text("text/xml; charset=utf-8", "")); + FakeResponse textXml = new FakeResponse(new ByteArrayOutputStream()); + new AnyFileDownload().processRequest(request(STREAM_URL, baseUrl + "/async/43/results/result", RETURN_PARAM, "mast query"), textXml.proxy); + assertTrue(textXml.headers.get("Content-Disposition"), textXml.headers.get("Content-Disposition").startsWith("attachment; filename=\"mast query.xml\"")); + + // text/xml has no known extension: return name + extension of the URL file name: "q" + ".vot" + upstream.createContext("/data/other.vot", text("text/xml", "")); + FakeResponse urlExtFirst = new FakeResponse(new ByteArrayOutputStream()); + new AnyFileDownload().processRequest(request(STREAM_URL, baseUrl + "/data/other.vot", RETURN_PARAM, "q"), urlExtFirst.proxy); + assertTrue(urlExtFirst.headers.get("Content-Disposition"), urlExtFirst.headers.get("Content-Disposition").startsWith("attachment; filename=\"q.vot\"")); + } + + @Test + public void browserCancelStopsReadingTheRemoteFile() throws Exception { + AtomicLong sent = new AtomicLong(); + upstream.createContext("/big", throttled(20_000_000, sent)); // ~16 s to send it all + + OutputStream failsAfterFirstWrite = new OutputStream() { + int writes; + @Override public void write(int b) throws IOException { write(new byte[]{(byte) b}, 0, 1); } + @Override public void write(byte[] b, int off, int len) throws IOException { + if (++writes > 1) throw new IOException("Connection reset by peer"); + } + }; + new AnyFileDownload().processRequest(request(STREAM_URL, baseUrl + "/big"), new FakeResponse(failsAfterFirstWrite).proxy); + + assertTrue("the remote server sent " + sent.get() + " bytes", sent.get() < 20_000_000); + } + + @Test + public void headRequestDoesNotReadTheBody() throws Exception { + AtomicLong sent = new AtomicLong(); + upstream.createContext("/big", throttled(20_000_000, sent)); + + FakeResponse res = new FakeResponse(new ByteArrayOutputStream()); + new AnyFileDownload().doHead(request(STREAM_URL, baseUrl + "/big"), res.proxy); + + assertTrue("the remote server sent " + sent.get() + " bytes", sent.get() < 20_000_000); + assertEquals("20000000", res.headers.get("Content-Length")); + assertEquals(0, res.body().length); + } + +//==================================================================== +// helpers +//==================================================================== + + private static HttpHandler text(String type, String body) { + return ex -> { + byte[] b = body.getBytes(StandardCharsets.UTF_8); + ex.getResponseHeaders().set("Content-Type", type); + ex.sendResponseHeaders(200, b.length); + try (OutputStream os = ex.getResponseBody()) { os.write(b); } + }; + } + + /** Promises {@code declared} bytes but sends only {@code body}, then drops the connection. */ + private static HttpHandler cutShort(long declared, byte[] body) { + return ex -> { + ex.sendResponseHeaders(200, declared); + OutputStream os = ex.getResponseBody(); + os.write(body); + os.flush(); + os.close(); // throws "insufficient bytes written to stream", and the server closes the connection + }; + } + + /** Sends the headers (chunked), waits 300 ms, sends {@code body}, then hangs for 10 s with the connection open before ending the response. */ + private static HttpHandler sendsThenHangs(byte[] body) { + return ex -> { + ex.sendResponseHeaders(200, 0); + try (OutputStream os = ex.getResponseBody()) { + Thread.sleep(300); + os.write(body); + os.flush(); + Thread.sleep(10_000); // well past the 1 s read timeout + } catch (IOException | InterruptedException ignored) {} + }; + } + + /** Sends {@code total} bytes, 64 KB every 50 ms, counting each chunk before writing it, until the client hangs up. */ + private static HttpHandler throttled(long total, AtomicLong sent) { + return (HttpExchange ex) -> { + ex.sendResponseHeaders(200, total); + byte[] chunk = new byte[64 * 1024]; + try (OutputStream os = ex.getResponseBody()) { + while (sent.get() < total) { + int n = (int) Math.min(chunk.length, total - sent.get()); + sent.addAndGet(n); // counted before the write, so the count is complete before Firefly can read the last bytes + os.write(chunk, 0, n); + os.flush(); + Thread.sleep(50); + } + } catch (IOException | InterruptedException ignored) {} + }; + } + + private static HttpServletRequest request(String... keyVals) { + Map params = new HashMap<>(); + for (int i = 0; i < keyVals.length; i += 2) params.put(keyVals[i], new String[]{keyVals[i + 1]}); + return (HttpServletRequest) Proxy.newProxyInstance(AnyFileDownloadTest.class.getClassLoader(), + new Class[]{HttpServletRequest.class}, + (proxy, m, args) -> switch (m.getName()) { + case "getParameterMap" -> params; + case "getParameter" -> params.containsKey(args[0]) ? params.get(args[0])[0] : null; + default -> null; + }); + } + + private static void await(CountDownLatch latch) { + try { latch.await(10, TimeUnit.SECONDS); } catch (InterruptedException ignored) {} + } + + private interface ThrowingRunnable { void run() throws Exception; } + private static void runQuietly(ThrowingRunnable r) { + try { r.run(); } catch (Exception e) { throw new RuntimeException(e); } + } + + /** Records what the servlet sets on the response; the body goes to the given stream. */ + private static class FakeResponse implements java.lang.reflect.InvocationHandler { + int status = 200; + final Map headers = new HashMap<>(); + final List cookies = new ArrayList<>(); + private final OutputStream out; + private boolean committed; + + final HttpServletResponse proxy = (HttpServletResponse) Proxy.newProxyInstance( + AnyFileDownloadTest.class.getClassLoader(), new Class[]{HttpServletResponse.class}, this); + + FakeResponse(OutputStream out) { this.out = out; } + + byte[] body() { return out instanceof ByteArrayOutputStream b ? b.toByteArray() : new byte[0]; } + + @Override + public Object invoke(Object proxy, java.lang.reflect.Method m, Object[] args) throws Throwable { + switch (m.getName()) { + case "setStatus" -> status = (int) args[0]; + case "setContentType" -> headers.put("Content-Type", (String) args[0]); + case "setContentLength", "setContentLengthLong" -> headers.put("Content-Length", String.valueOf(args[0])); + case "setHeader", "addHeader" -> headers.put((String) args[0], (String) args[1]); + case "addCookie" -> cookies.add((Cookie) args[0]); + case "isCommitted" -> { return committed; } + case "reset" -> { + if (committed) throw new IllegalStateException("committed"); + status = 200; headers.clear(); cookies.clear(); + if (out instanceof ByteArrayOutputStream b) b.reset(); + } + case "getOutputStream" -> { return servletStream(); } + default -> { + if (m.getReturnType() == boolean.class) return false; + if (m.getReturnType() == int.class) return 0; + } + } + return null; + } + + private ServletOutputStream servletStream() { + return new ServletOutputStream() { + @Override public boolean isReady() { return true; } + @Override public void setWriteListener(WriteListener l) {} + @Override public void write(int b) throws IOException { write(new byte[]{(byte) b}, 0, 1); } + @Override public void write(byte[] b, int off, int len) throws IOException { + committed = true; + out.write(b, off, len); + } + }; + } + } +}