99
1010import edu .caltech .ipac .firefly .server .db .DuckDbReadable ;
1111import edu .caltech .ipac .firefly .server .util .Logger ;
12- import nom .tam .fits .Fits ;
13- import nom .tam .fits .FitsException ;
12+ import org .apache .commons .compress .compressors .bzip2 .BZip2CompressorInputStream ;
1413
1514import javax .annotation .Nonnull ;
16- import java .nio .file .Files ;
17- import java .nio .file .Path ;
18- import java .util .EnumSet ;
19- import java .util .Set ;
2015import java .io .BufferedReader ;
2116import java .io .CharArrayReader ;
2217import java .io .File ;
18+ import java .io .FileInputStream ;
2319import java .io .FileReader ;
2420import java .io .IOException ;
21+ import java .io .InputStream ;
22+ import java .util .EnumSet ;
23+ import java .util .Set ;
24+ import java .util .zip .GZIPInputStream ;
2525
2626import static edu .caltech .ipac .firefly .core .Util .Opt .ifNotNull ;
2727import static edu .caltech .ipac .table .IpacTableUtil .isIpacTable ;
3636public class FormatUtil {
3737 private static final int SAMPLE_SIZE = (int ) (8 * FileUtil .K );
3838 private static final Logger .LoggerImpl LOGGER = Logger .getLogger ();
39+ private static final int [] FITS_MAGIC = {'S' ,'I' ,'M' ,'P' ,'L' ,'E' ,' ' ,' ' ,'=' };
3940
4041 public record MimeDesc (String mime , String desc ) {}
4142
@@ -114,7 +115,7 @@ public static Format fromMime(String mime) {
114115
115116 /**
116117 * Detects the MIME type of file using magic-byte inspection of the file header,
117- * falling back to OS-level and extension-based guessing for unrecognized types.
118+ * falling back to extension-based guessing for unrecognized types.
118119 * This is platform-independent for all binary formats handled here; text-based
119120 * formats (FITS text headers, VOTable, IPAC table, etc.) are handled downstream
120121 * by {@link FormatUtil#detect}.
@@ -159,17 +160,21 @@ public static MimeDesc getMimeType(String inFile) {
159160 if (magic (hdr , 257 , 'u' ,'s' ,'t' ,'a' ,'r' ))
160161 return new MimeDesc (TAR .mime (), "POSIX tar archive" );
161162 // Binary FITS: header block starts with "SIMPLE ="
162- if (magic (hdr , 0 , 'S' , 'I' , 'M' , 'P' , 'L' , 'E' , ' ' , ' ' , '=' ))
163+ if (magic (hdr , 0 , FITS_MAGIC ))
163164 return new MimeDesc (FITS .mime (), "FITS image data" );
164165
166+ var hdrStr = new String (hdr );
167+ if ((hdrStr .startsWith ("<?xml" ) && hdrStr .contains ("<VOTABLE" )) || hdrStr .startsWith ("<VOTABLE" )) { // this is not a magic number but close and might fit in the 264 bytes
168+ return new MimeDesc (VO_TABLE .mime (), "VO Table" );
169+ }
170+
165171 } catch (Exception ex ) {
166172 Logger .getLogger ().error (ex , "Failed to read header for mime detection: " + inFile );
167173 }
168174
169- // Fall back to OS content probing and extension-based guess for text formats
175+ // Fall back to extension-based guess for text formats
170176 try {
171- String mime = Files .probeContentType (Path .of (inFile ));
172- if (mime == null ) mime = java .net .URLConnection .guessContentTypeFromName (inFile );
177+ var mime = java .net .URLConnection .guessContentTypeFromName (inFile );
173178 if (mime != null ) return new MimeDesc (mime , mime );
174179 } catch (Exception ex ) {
175180 Logger .getLogger ().error (ex , "Failed to detect mime type for: " + inFile );
@@ -208,14 +213,8 @@ public static Format detect(File inFile) throws IOException {
208213 }
209214
210215
211- if ((format == BZIP2 || format == GZIP ) &&
212- inFile .getName ().toLowerCase ().contains ("fit" )) {
213- // special case and a very heavy operation: we can handle fits bz2 or gzip files but don't try unless we are pretty sure
214- // in archives: legacy files are often named a.fits.gz or a.fits.bz2
215- try (var ignored = new Fits (inFile )) {
216- return FITS ;
217- }
218- catch (FitsException ignore ) {}
216+ if (isCompressedFits (inFile , format )) { // archives often serve a.fits.gz or a.fits.bz2; check the decompressed header
217+ return FITS ;
219218 }
220219
221220 format = ifNotNull (guessBySamplingContent (inFile )).getOrElse (format );
@@ -307,7 +306,7 @@ private static Format guessBySamplingContent(File inf) throws IOException {
307306 //NAME-RESOLVER: xxx
308307 return FIXEDTARGETS ;
309308 } else if (line .startsWith ("<VOTABLE" ) ||
310- (line .contains ("<?xml" ) && line .contains ("<VOTABLE " ))) {
309+ (line .contains ("<?xml" ) && line .contains ("<VOTABLE" ))) {
311310 return VO_TABLE ;
312311 } else if (isUwsEl (line )) {
313312 return UWS ;
@@ -325,6 +324,20 @@ private static boolean isUwsEl(String line) {
325324 return isUws && line .matches ("<(.+:)?job .*" );
326325 }
327326
327+ /**
328+ * Check the first bytes of a gzip or bzip2 file, after decompressing, for the FITS "SIMPLE =" keyword
329+ */
330+ private static boolean isCompressedFits (File inFile , Format format ) {
331+ if (format != BZIP2 && format != GZIP ) return false ;
332+ try (var fis = new FileInputStream (inFile );
333+ InputStream in = format == GZIP ? new GZIPInputStream (fis ) : new BZip2CompressorInputStream (fis )) {
334+ byte [] hdr = in .readNBytes (9 );
335+ return magic (hdr , 0 , FITS_MAGIC );
336+ } catch (IOException e ) {
337+ return false ;
338+ }
339+ }
340+
328341 /** Returns true when {@code data[offset..]} starts with the given byte values. */
329342 private static boolean magic (byte [] data , int offset , int ... expected ) {
330343 if (data .length < offset + expected .length ) return false ;
0 commit comments