66import edu .caltech .ipac .firefly .data .FileInfo ;
77import edu .caltech .ipac .firefly .server .ServerContext ;
88import edu .caltech .ipac .firefly .server .SrvParam ;
9+ import edu .caltech .ipac .firefly .server .network .HttpServiceInput ;
10+ import edu .caltech .ipac .firefly .server .network .HttpServices ;
911import edu .caltech .ipac .firefly .server .util .LockingRetrieve ;
1012import edu .caltech .ipac .firefly .server .util .Logger ;
13+ import edu .caltech .ipac .util .AppProperties ;
1114import edu .caltech .ipac .util .FileUtil ;
15+ import edu .caltech .ipac .util .FormatUtil ;
1216import edu .caltech .ipac .util .StringUtils ;
1317import edu .caltech .ipac .util .cache .Cache ;
1418import edu .caltech .ipac .util .cache .CacheManager ;
2024import jakarta .servlet .http .Cookie ;
2125import jakarta .servlet .http .HttpServletRequest ;
2226import jakarta .servlet .http .HttpServletResponse ;
27+ import org .apache .commons .httpclient .HttpMethod ;
2328
2429import java .io .File ;
2530import java .io .IOException ;
31+ import java .io .InputStream ;
32+ import java .io .OutputStream ;
2633import java .net .URL ;
34+ import java .nio .charset .StandardCharsets ;
2735
2836import 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 ;
0 commit comments