Skip to content

Commit 00e2f38

Browse files
committed
FIREFLY-2032: Add direct download of files
1 parent 07e8e69 commit 00e2f38

9 files changed

Lines changed: 856 additions & 36 deletions

File tree

‎src/firefly/java/edu/caltech/ipac/firefly/server/network/HttpServiceInput.java‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ public class HttpServiceInput implements Cloneable, Serializable {
3939
private String userId;
4040
private String passwd;
4141
private boolean followRedirect = true;
42+
private int readTimeout; // milliseconds to wait for the next byte from the remote server; 0 waits forever
4243

4344
public HttpServiceInput() {}
4445

@@ -207,6 +208,12 @@ public HttpServiceInput setFollowRedirect(boolean followRedirect) {
207208
return this;
208209
}
209210

211+
public int getReadTimeout() { return readTimeout; }
212+
public HttpServiceInput setReadTimeout(int readTimeout) {
213+
this.readTimeout = readTimeout;
214+
return this;
215+
}
216+
210217
public String getDesc() {
211218
StringBuilder sb = new StringBuilder();
212219

‎src/firefly/java/edu/caltech/ipac/firefly/server/network/HttpServices.java‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,10 @@ public static Status executeMethod(HttpMethod method, HttpServiceInput input, Ha
218218
}
219219

220220
HttpClient httpClient = newHttpClient();
221+
if (input.getReadTimeout() > 0) {
222+
// on the connection manager, not the method: HttpClient 3 drops a method's timeout while it waits for a chunked body
223+
httpClient.getHttpConnectionManager().getParams().setSoTimeout(input.getReadTimeout());
224+
}
221225

222226
handleAuth(httpClient, method, input.getUserId(), input.getPasswd());
223227

@@ -386,6 +390,8 @@ public Status handleResponse(HttpMethod method) {
386390
input.getHeaders().entrySet().stream()
387391
.filter(h -> isForwardHeader(h.getKey()))
388392
.forEach(h -> redirect.setHeader(h.getKey(), h.getValue()));
393+
// carry over the read timeout
394+
redirect.setReadTimeout(input.getReadTimeout());
389395

390396
return getData(redirect, maxFollow-1, handler);
391397
} else {

‎src/firefly/java/edu/caltech/ipac/firefly/server/servlets/AnyFileDownload.java‎

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,13 @@
66
import edu.caltech.ipac.firefly.data.FileInfo;
77
import edu.caltech.ipac.firefly.server.ServerContext;
88
import edu.caltech.ipac.firefly.server.SrvParam;
9+
import edu.caltech.ipac.firefly.server.network.HttpServiceInput;
10+
import edu.caltech.ipac.firefly.server.network.HttpServices;
911
import edu.caltech.ipac.firefly.server.util.LockingRetrieve;
1012
import edu.caltech.ipac.firefly.server.util.Logger;
13+
import edu.caltech.ipac.util.AppProperties;
1114
import edu.caltech.ipac.util.FileUtil;
15+
import edu.caltech.ipac.util.FormatUtil;
1216
import edu.caltech.ipac.util.StringUtils;
1317
import edu.caltech.ipac.util.cache.Cache;
1418
import edu.caltech.ipac.util.cache.CacheManager;
@@ -20,10 +24,14 @@
2024
import jakarta.servlet.http.Cookie;
2125
import jakarta.servlet.http.HttpServletRequest;
2226
import jakarta.servlet.http.HttpServletResponse;
27+
import org.apache.commons.httpclient.HttpMethod;
2328

2429
import java.io.File;
2530
import java.io.IOException;
31+
import java.io.InputStream;
32+
import java.io.OutputStream;
2633
import java.net.URL;
34+
import java.nio.charset.StandardCharsets;
2735

2836
import static edu.caltech.ipac.util.StringUtils.isEmpty;
2937

@@ -40,6 +48,7 @@ public class AnyFileDownload extends BaseHttpServlet {
4048

4149

4250
public static final String EXT_URL = "externalURL"; // required for downloading something through the firefly server
51+
public static final String STREAM_URL = "streamURL"; // relay a remote file to the browser as it arrives, keeping no copy on the server
4352
public static final String HIPS_PARAM = "hipsUrl"; // required for HiPS
4453
public static final String ALWAYS_USE_CACHED = "alwaysUseCached"; // true if use the cached without checking
4554
public static final String FILE_PARAM = "file"; // required for other request
@@ -56,6 +65,7 @@ public class AnyFileDownload extends BaseHttpServlet {
5665
private static final String BASE_URL = "servlet/Download?"+ LOG_PARAM +"=true&" + FILE_PARAM +"=";
5766
private static final String RET_FILE = "&"+RETURN_PARAM+"=";
5867
private static final String DOWNLOAD_COOKIE_PREFIX = "ffdl_";
68+
static final String STREAM_READ_TIMEOUT = "download.stream.read.timeout.secs"; // seconds of silence from the remote server before a streamURL download fails
5969

6070
public static String getDownloadURL(File file, String suggestedFileName) {
6171
return getDownloadURL(file, suggestedFileName, ServerContext.getRequestOwner().getBaseUrl());
@@ -80,6 +90,9 @@ protected void processRequest(HttpServletRequest req, HttpServletResponse res) t
8090
else if (req.getParameterMap().containsKey(EXT_URL)) {
8191
handleExternalURLRequest(req,res,true);
8292
}
93+
else if (req.getParameterMap().containsKey(STREAM_URL)) {
94+
handleStreamURLRequest(req,res,true);
95+
}
8396
else {
8497
handleInternalFileRequest(req,res,true);
8598
}
@@ -94,6 +107,9 @@ protected void doHead(HttpServletRequest req, HttpServletResponse res) throws IO
94107
else if (req.getParameterMap().containsKey(EXT_URL)) {
95108
handleExternalURLRequest(req,res,false);
96109
}
110+
else if (req.getParameterMap().containsKey(STREAM_URL)) {
111+
handleStreamURLRequest(req,res,false);
112+
}
97113
else {
98114
handleInternalFileRequest(req,res,false);
99115
}
@@ -210,6 +226,165 @@ private void handleExternalURLRequest(HttpServletRequest req, HttpServletRespons
210226
}
211227

212228

229+
/**
230+
* The Firefly server fetches the remote URL and passes its bytes to the browser as they arrive,
231+
* so a large file starts saving right away instead of after the server has a full copy.
232+
* HttpServiceInput adds the user's login token for the hosts the SsoAdapter allows, also after a redirect.
233+
* The browser's cookies are not forwarded. Nothing is cached or kept on the server, and the bytes are not decoded.
234+
*/
235+
private void handleStreamURLRequest(HttpServletRequest req, HttpServletResponse res, boolean fullBody)
236+
throws IOException {
237+
SrvParam sp = new SrvParam(req.getParameterMap());
238+
String urlStr = sp.getRequired(STREAM_URL);
239+
if (!urlStr.matches("(?i)https?://.+")) {
240+
sendTextError(res, HttpServletResponse.SC_BAD_REQUEST, "Only http and https URLs can be downloaded: " + urlStr);
241+
return;
242+
}
243+
244+
StreamRelay relay = new StreamRelay(res, sp.getOptional(RETURN_PARAM), sp.getOptional(DOWNLOAD_TOKEN), fullBody);
245+
// Without a read timeout, a remote server that goes silent holds this Firefly server thread forever, even after
246+
// the browser cancels, because the relay sees a cancel only when it writes. A job's result is already computed,
247+
// so 5 minutes of silence means the remote server has stalled.
248+
int readTimeout = AppProperties.getIntProperty(STREAM_READ_TIMEOUT, 300) * 1000;
249+
HttpServices.Status status = HttpServices.getData(new HttpServiceInput(urlStr).setReadTimeout(readTimeout), relay);
250+
251+
if (relay.readError != null) {
252+
// The remote server failed after it started sending. Before the response is committed, the browser
253+
// can still get an error page. After that, the exception makes Tomcat drop the connection,
254+
// so the browser marks the download failed instead of saving a truncated file as complete.
255+
if (!res.isCommitted()) {
256+
res.reset();
257+
sendTextError(res, HttpServletResponse.SC_BAD_GATEWAY, "Download failed while reading " + urlStr + ": " + relay.readError.getMessage());
258+
return;
259+
}
260+
throw new IOException("Download failed while reading " + urlStr, relay.readError);
261+
}
262+
if (relay.writeError != null) {
263+
_log.info("Stream download stopped by the browser after " + relay.bytes + " bytes: " + urlStr);
264+
} else if (relay.started) {
265+
_log.info("Stream download -- URL: " + urlStr + ", bytes: " + relay.bytes);
266+
} else {
267+
// the remote server never returned the file; the client's download() shows this text in a popup
268+
String reason = status.getStatusCode() + (isEmpty(status.getErrMsg()) ? "" : " " + status.getErrMsg());
269+
sendTextError(res, HttpServletResponse.SC_BAD_GATEWAY, "Download failed for " + urlStr + ": " + reason);
270+
}
271+
}
272+
273+
private static void sendTextError(HttpServletResponse res, int status, String msg) throws IOException {
274+
byte[] body = msg.getBytes(StandardCharsets.UTF_8);
275+
res.setStatus(status);
276+
res.setContentType("text/plain;charset=utf-8");
277+
res.setContentLength(body.length);
278+
res.getOutputStream().write(body);
279+
}
280+
281+
/**
282+
* Copies the remote server's response to the browser. HttpServices calls it only for a 2xx response,
283+
* after following any redirects.
284+
*/
285+
static class StreamRelay implements HttpServices.Handler {
286+
private final HttpServletResponse res;
287+
private final String returnName;
288+
private final String downloadToken;
289+
private final boolean fullBody;
290+
boolean started;
291+
long bytes;
292+
IOException readError; // the remote server failed
293+
IOException writeError; // the browser went away
294+
295+
StreamRelay(HttpServletResponse res, String returnName, String downloadToken, boolean fullBody) {
296+
this.res = res;
297+
this.returnName = returnName;
298+
this.downloadToken = downloadToken;
299+
this.fullBody = fullBody;
300+
}
301+
302+
public HttpServices.Status handleResponse(HttpMethod method) {
303+
String type = HttpServices.getResHeader(method, "Content-Type", null);
304+
String length = HttpServices.getResHeader(method, "Content-Length", null);
305+
String encoding = HttpServices.getResHeader(method, "Content-Encoding", null);
306+
String disposition = HttpServices.getResHeader(method, "Content-Disposition", null);
307+
308+
res.setStatus(HttpServletResponse.SC_OK);
309+
if (type != null) res.setContentType(type);
310+
if (length != null) res.setHeader("Content-Length", length);
311+
if (encoding != null) res.setHeader("Content-Encoding", encoding);
312+
res.setHeader("Content-Disposition", URLDownload.makeContentDisposition(fileName(method, type, disposition)));
313+
res.setHeader("X-Content-Type-Options", "nosniff");
314+
res.setHeader("Cache-Control", "private, no-store");
315+
sendDownloadStartedCookie(downloadToken, res);
316+
started = true;
317+
318+
if (!fullBody) {
319+
method.abort();
320+
return HttpServices.Status.ok();
321+
}
322+
323+
// The stream is not closed here: when the copy stops early, closing it would make HttpClient read the
324+
// rest of the remote file. abort() drops the connection instead, and HttpServices releases it.
325+
byte[] buffer = new byte[HttpServices.BUFFER_SIZE];
326+
try {
327+
InputStream in = method.getResponseBodyAsStream(); // raw bytes; HttpServices.getResponseBodyAsStream would gunzip
328+
if (in == null) throw new IOException("the remote server sent no response body"); // HttpClient gives null when a chunked body never starts
329+
OutputStream out = res.getOutputStream();
330+
while (true) {
331+
int n;
332+
try {
333+
n = in.read(buffer);
334+
} catch (IOException e) {
335+
readError = e;
336+
break;
337+
}
338+
if (n == -1) break;
339+
try {
340+
out.write(buffer, 0, n);
341+
} catch (IOException e) {
342+
writeError = e;
343+
break;
344+
}
345+
bytes += n;
346+
}
347+
} catch (IOException e) {
348+
if (readError == null && writeError == null) readError = e; // getResponseBodyAsStream() or getOutputStream() failed
349+
}
350+
// HttpClient 3 ends the stream quietly when the remote server closes before its Content-Length,
351+
// so the relay compares the bytes it sent with the Content-Length it passed to the browser
352+
long expected = StringUtils.getLong(length, -1);
353+
if (readError == null && writeError == null && bytes < expected) {
354+
readError = new IOException("the remote server closed the connection after " + bytes + " of " + expected + " bytes");
355+
}
356+
if (readError != null || writeError != null) method.abort();
357+
return HttpServices.Status.ok();
358+
}
359+
360+
/**
361+
* The remote server's file name if it gives one. Otherwise the client's return name, or the file name
362+
* in the final URL, with an extension from the content type or from that URL.
363+
*/
364+
private String fileName(HttpMethod method, String type, String disposition) {
365+
String name = isEmpty(disposition) ? null : URLDownload.getSuggestedFileName(disposition);
366+
if (!isEmpty(name)) return name;
367+
368+
String urlName = "";
369+
try {
370+
urlName = URLDownload.sanitizeFilename(new File(method.getURI().getPath()).getName());
371+
} catch (Exception ignored) {}
372+
if (isEmpty(returnName) && !FileUtil.getExtension(urlName).isEmpty()) return urlName;
373+
name = !isEmpty(returnName) ? returnName : !isEmpty(urlName) ? urlName : "download";
374+
375+
String mime = type == null ? "" : type.split(";")[0].trim();
376+
String ext = FormatUtil.Format.fromMime(mime).fileExt();
377+
if (ext == null) {
378+
String urlExt = FileUtil.getExtension(urlName);
379+
if (urlExt.matches("[a-z0-9]{1,5}")) ext = "." + urlExt;
380+
else if (mime.matches("(?i)(text|application)/xml")) ext = ".xml"; // e.g. the MAST and HEASARC TAP services send VOTables as text/xml
381+
else ext = "";
382+
}
383+
return name.toLowerCase().endsWith(ext) ? name : name + ext;
384+
}
385+
}
386+
387+
213388
/** Let the client know the download has started by setting a short-lived cookie named after its download token. */
214389
public static void sendDownloadStartedCookie(String token, HttpServletResponse res) {
215390
if (isEmpty(token) || !token.matches("[A-Za-z0-9_-]{1,64}")) return;

‎src/firefly/js/core/background/BackgroundUtil.js‎

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {makeUniquePlotIdFromBase} from '../../visualize/PlotViewUtil';
99

1010
import {flux} from '../ReduxFlux';
1111
import {BACKGROUND_PATH, BG_JOB_INFO, dispatchBgLoadJobs, dispatchJobAdd} from './BackgroundCntlr.js';
12-
import {getCmdSrvAsyncURL, isURL} from '../../util/WebUtil.js';
12+
import {encodeUrl, getCmdSrvAsyncURL, getRootURL, isURL} from '../../util/WebUtil.js';
1313
import {COMPONENT_STATE_CHANGE, dispatchComponentStateChange, getComponentState} from '../ComponentCntlr.js';
1414
import {dispatchAddActionWatcher} from '../MasterSaga.js';
1515
import {jsonFetch} from '../JsonUtils.js';
@@ -314,6 +314,37 @@ export function fixTapResults(jobInfo) {
314314
}
315315

316316

317+
/**
318+
* Returns the URL that downloads a job result's original file, or undefined when there is nothing to download.
319+
* It goes by the result's href, not the job type, so UWS jobs opened from the Upload panel work too.
320+
* @param {Object} job
321+
* @param {number} [resultIdx=0]
322+
* @return {string|undefined}
323+
*/
324+
export function getResultDownloadUrl(job, resultIdx=0) {
325+
const {results=[]} = job ?? {};
326+
const href = results[resultIdx]?.href?.trim();
327+
if (!href) return;
328+
329+
const resultId = results.length > 1 ? `-${results[resultIdx].id ?? resultIdx}` : '';
330+
const fileName = `${getJobTitle(job) ?? 'result'}${resultId}`;
331+
const downloadUrl = getRootURL() + 'servlet/Download';
332+
333+
if (href.startsWith('${')) { // a file the Firefly server saved for a SEARCH job
334+
const ext = href.match(/(\.[A-Za-z0-9]{1,5})$/)?.[1] ?? '';
335+
return encodeUrl(downloadUrl, {file: href, return: fileName + ext});
336+
}
337+
if (!/^https?:\/\//i.test(href) || !isURL(href)) return;
338+
339+
const rootPath = new URL(getRootURL()).pathname;
340+
const {pathname} = new URL(href);
341+
if (pathname === rootPath + 'servlet/Download') return href; // the Firefly server's own download URL, e.g. a PACKAGE result
342+
if (pathname.startsWith(rootPath + 'CmdSrv/async/')) return; // reruns the search and returns Firefly's internal JSON, not a file
343+
344+
// the Firefly server relays the remote file as it arrives, and adds an extension to the name
345+
return encodeUrl(downloadUrl, {streamURL: href, return: fileName});
346+
}
347+
317348
export function loadJobResult({jobInfo, resultIdx}) {
318349
const {request, tbl_id, loader, href, mimeType, id, size} = getMetadata({jobInfo, resultIdx});
319350
loader?.({jobInfo, request, href, mimeType, id, size});

‎src/firefly/js/core/background/JobInfo.jsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {TableErrorMsg} from 'firefly/tables/ui/TablePanel.jsx';
1414
import {showInfoPopup} from 'firefly/ui/PopupUtil';
1515
import {PrismADQLAware} from '../../ui/tap/AdvancedADQL';
1616
import {getFieldVal} from '../../fieldGroup/FieldGroupUtils';
17-
import {jobMonitorGroupKey, ResultsBlock, toDateString, useLocalTimeKey} from '../../core/background/JobMonitor';
17+
import {DownloadBtn, jobMonitorGroupKey, ResultsBlock, toDateString, useLocalTimeKey} from '../../core/background/JobMonitor';
1818

1919
const dialogID = 'show-job-info';
2020

@@ -96,7 +96,7 @@ export function UwsJobInfo({jobInfo, sx, isOpen=false}) {
9696
<CollapsibleGroup>
9797
<OptionalBlock label='Error Summary' title='Referred to as "errorSummary" in UWS' value={errorSummary} Component={ErrorBlock} job={jobInfo} isOpen={isOpen}/>
9898
<OptionalBlock label='Parameters' title='Referred to as "parameters" in UWS' value={parameters} isOpen={isOpen}/>
99-
<OptionalBlock label='Results' title='Referred to as "results" in UWS' value={results} Component={ResultsBlock} job={jobInfo} isOpen={isOpen}/>
99+
<OptionalBlock label='Results' title='Referred to as "results" in UWS' value={results} Component={ResultsBlock} job={jobInfo} ActionBtn={DownloadBtn} isOpen={isOpen}/>
100100
<OptionalBlock label='Extra Information' title='Referred to as "jobInfo" in UWS' value={aux} isOpen={isOpen}/>
101101
</CollapsibleGroup>
102102
)}

0 commit comments

Comments
 (0)