// Near Infinity - An Infinity Engine Browser and Editor // Copyright (C) 2001 - 2005 Jon Olav Hauglid // See LICENSE.txt for license information package org.infinity.updater; import java.awt.Desktop; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.io.UnsupportedEncodingException; import java.math.BigInteger; import java.net.HttpURLConnection; import java.net.MalformedURLException; import java.net.ProtocolException; import java.net.Proxy; import java.net.URI; import java.net.URISyntaxException; import java.net.URL; import java.net.URLConnection; import java.net.UnknownServiceException; import java.nio.charset.Charset; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.security.cert.Certificate; import java.security.cert.X509Certificate; import java.util.Calendar; import java.util.EventListener; import java.util.EventObject; import java.util.Iterator; import java.util.List; import java.util.Locale; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.regex.PatternSyntaxException; import java.util.zip.GZIPInputStream; import java.util.zip.ZipEntry; import java.util.zip.ZipException; import java.util.zip.ZipInputStream; import javax.net.ssl.HttpsURLConnection; import javax.net.ssl.SSLPeerUnverifiedException; /** * Generic collection of updater-related methods. */ public class Utils { // System-specific temp folder private static final String TEMP_FOLDER = System.getProperty("java.io.tmpdir"); // Path to java executable private static final String JAVA_EXECUTABLE = System.getProperty("java.home") + File.separator + "bin" + File.separator + "java"; // The algorithm used for calculating hash strings private static final String HASH_TYPE = "md5"; /** Returns the full path to the system temp folder. */ public static String getTempFolder() { return TEMP_FOLDER; } /** Returns the full path to the java executable. */ public static String getJavaExecutable() { return JAVA_EXECUTABLE; } /** * Attempts to determine the full filepath of the java application. * @return The full path of the current JAR file or an empty string on error. */ public static String getJarFileName(Class<? extends Object> classType) { if (classType != null) { URL url = classType.getProtectionDomain().getCodeSource().getLocation(); if (url != null) { try { Path file = Paths.get(url.toURI()); if (Files.exists(file)) { return file.toString(); } } catch (URISyntaxException e) { } } } return ""; } /** * Converts a Calendar object into a timestamp string in ISO 8601 format. */ public static String toTimeStamp(Calendar cal) { if (cal == null) { cal = Calendar.getInstance(); } StringBuilder sb = new StringBuilder(); sb.append(String.format("%1$04d", cal.get(Calendar.YEAR))); sb.append('-').append(String.format("%1$02d", cal.get(Calendar.MONTH) + 1)); sb.append('-').append(String.format("%1$02d", cal.get(Calendar.DAY_OF_MONTH))); sb.append('T').append(String.format("%1$02d", cal.get(Calendar.HOUR_OF_DAY))); sb.append(':').append(String.format("%1$02d", cal.get(Calendar.MINUTE))); sb.append(':').append(String.format("%1$02d", cal.get(Calendar.SECOND))); int ofs = cal.get(Calendar.ZONE_OFFSET); if (ofs != 0) { char sign = (ofs < 0) ? '-' : '+'; ofs = Math.abs(ofs); int ofsHour = ofs / 3600000; int ofsMin = (ofs / 60000) % 60; sb.append(sign).append(String.format("%1$02d", ofsHour)); sb.append(':').append(String.format("%1$02d", ofsMin)); } return sb.toString(); } /** * Converts a timestamp string in ISO 8601 format into a Calendar object. */ public static Calendar toCalendar(String timeStamp) { Calendar retVal = null; if (timeStamp != null && !timeStamp.isEmpty()) { final String regDate = "(\\d{4})-?([0-1][0-9])-?([0-3][0-9])"; final String regTime = "T([0-2][0-9]):?([0-5][0-9])?:?([0-5][0-9])?"; final String regZone = "(([-+])([0-2][0-9]):?([0-5][0-9])|(Z))"; int year = 0, month = -1, day = 0, hour = 0, minute = 0, second = 0, ofsHour = 0, ofsMinute = 0; char sign = 0; Matcher m; try { String s = timeStamp; // processing date m = Pattern.compile(regDate).matcher(s); if (m.find()) { year = toNumber(m.group(1), 0); month = toNumber(m.group(2), 0) - 1; day = toNumber(m.group(3), 0); // processing time s = s.substring(m.end()); m = Pattern.compile(regTime).matcher(s); if (m.find()) { hour = toNumber(m.group(1), 0); if (m.groupCount() >= 2) { minute = toNumber(m.group(2), 0); if (m.groupCount() >= 3) { second = toNumber(m.group(3), 0); } } // processing timezone offset s = s.substring(m.end()); m = Pattern.compile(regZone).matcher(s); if (m.find()) { if (m.group(5) != null) { sign = '+'; } else { sign = m.group(2).charAt(0); ofsHour = toNumber(m.group(3), 0); if (m.groupCount() >= 3) { ofsMinute = toNumber(m.group(4), 0); } } } } } } catch (PatternSyntaxException e) { e.printStackTrace(); } if (year > 0 && month >= 0 && day > 0) { retVal = Calendar.getInstance(); retVal.set(year, month, day, hour, minute, second); // applying timezone offset (in ms) if (sign != 0) { int amount = (ofsHour*60 + ofsMinute)*60*1000; switch (sign) { case '+': retVal.set(Calendar.ZONE_OFFSET, amount); break; case '-': retVal.set(Calendar.ZONE_OFFSET, -amount); break; } } } } return retVal; } /** * Returns an number based on the specified timestamp. * @param timestamp The timestamp in ISO 8601 format. Either in UTC or using time offset.<br> * Syntax: YYYY-MM-DD[Thh:mm[:ss][(±hh[:mm])|Z]]<br> * (Example: 2007-11-26T14:53+06:00) * @return A comparable numeric value of the timestamp (higher = newer). Returns 0 on error. */ public static long toTimeValue(String timestamp) { Calendar cal = toCalendar(timestamp); if (cal != null) { return cal.getTimeInMillis(); } else { return 0L; } } /** * Returns a number based on the date and time of the specified Calendar object. * @param cal The calendar object or {@code null} to get a number based on the current time. * @return A comparable numeric value derived from the calendar object (higher = newer). */ public static long getTimeValue(Calendar cal) { if (cal == null) { cal = Calendar.getInstance(); } return cal.getTimeInMillis(); } /** * Calculates the hash value from input stream data using the specified hash type. * @param is The input stream to read the data from. * @param hashType The has type (One of MD5, SHA-1 and SHA-256). */ public static String generateMD5Hash(InputStream is) { if (is != null) { try { MessageDigest m = MessageDigest.getInstance(HASH_TYPE); int len; byte[] buffer = new byte[65536]; while ((len = is.read(buffer)) > 0) { m.update(buffer, 0, len); } StringBuilder sb = new StringBuilder(); sb.append((new BigInteger(1, m.digest())).toString(16).toLowerCase(Locale.ENGLISH)); int digits = m.getDigestLength() * 2; while (sb.length() < digits) { sb.insert(0, '0'); } return sb.toString(); } catch (NoSuchAlgorithmException nsae) { nsae.printStackTrace(); } catch (IOException ioe) { ioe.printStackTrace(); } } return ""; } /** * Checks whether the specified string contains a valid URL. * @param url The URL string to test. * @return {@code true} if the string contains a valid URL, {@code false} otherwise. */ public static boolean isUrlValid(String url) { if (url != null) { try { new URL(url); return true; } catch (MalformedURLException e) { } } return false; } /** * Checks whether the specified URL points to a valid resource. * @param url The URL to check. * @param proxy An optional proxy definition. Can be null. * @return true if the URL is available, false otherwise. * @throws IOException * @throws IllegalArgumentException * @throws UnsupportedOperationException * @throws ProtocolException */ public static boolean isUrlAvailable(URL url, Proxy proxy) throws IOException, IllegalArgumentException, UnsupportedOperationException, ProtocolException { if (url != null) { if (url.getProtocol().equalsIgnoreCase("http") || url.getProtocol().equalsIgnoreCase("https")) { // We only need to check header for HTTP protocol HttpURLConnection.setFollowRedirects(true); HttpURLConnection conn = null; if (proxy != null) { conn = (HttpURLConnection)url.openConnection(proxy); } else { conn = (HttpURLConnection)url.openConnection(); } if (conn != null) { int timeout = conn.getConnectTimeout(); conn.setConnectTimeout(6000); conn.setRequestMethod("HEAD"); int responseCode; try { responseCode = conn.getResponseCode(); conn.setConnectTimeout(timeout); return (responseCode == HttpURLConnection.HTTP_OK); } catch (IOException e) { conn.setConnectTimeout(timeout); } } } else { // more generic method InputStream is = null; URLConnection conn = null; if (proxy != null) { conn = url.openConnection(proxy); } else { conn = url.openConnection(); } if (conn != null) { is = url.openStream(); is.close(); return true; } } } return false; } /** * Attempts to determine the size of the file specified by url. * @param url The URL pointing to a file of any kind. * @param proxy An optional proxy definition. Can be {@code null}. * @return The size of the file or -1 on error. * @throws IOException */ public static int getFileSizeUrl(URL url, Proxy proxy) throws IOException { if (url != null) { URLConnection conn = url.openConnection(); return conn.getContentLength(); } return -1; } /** * Attempts to return a valid URL from either an absolute 'path' or a relative 'path' * combined with 'base'. * @param base An optional URL which is used with a relative path parameter. * @param path Either an absolute path or a relative path together with the base parameter. * @return A valid URL or {@code null} in case of an error. * @throws MalformedURLException */ public static URL getUrl(URL base, String path) throws MalformedURLException { URL retVal = null; if (path != null) { try { // try absolute url first retVal = new URL(path); } catch (MalformedURLException mue) { retVal = null; } if (retVal == null && base != null) { // try relative url String baseUrl = base.toExternalForm(); int idx = baseUrl.indexOf('?'); String basePath = (idx >= 0) ? baseUrl.substring(0, idx) : baseUrl; String suffix = (idx >= 0) ? baseUrl.substring(idx) : ""; if (basePath.contains(path)) { retVal = new URL(basePath + suffix); } else { int cnt = 0; if (basePath.charAt(basePath.length() - 1) == '/') { cnt++; } if (path.charAt(0) == '/') { cnt++; } if (cnt == 2) { path = path.substring(1); } else if (cnt == 0) { path = '/' + path; } retVal = new URL(basePath + path + suffix); } } } return retVal; } /** Returns whether the specified link is using the https protocol. */ public static boolean isSecureUrl(String link) { if (link != null) { try { URL url = new URL(link); return url.getProtocol().equalsIgnoreCase("https"); } catch (MalformedURLException e) { } } return false; } /** * Checks whether the certificates of the specified HTTPS url are valid. * @param url The url to test. Must be HTTPS! * @return {@code true} if the certificate chain of the specified URL is valid. * {@code false} if the certificate chain is invalid or the connection does not use * the HTTPS protocol. * @throws Exception * @throws IOException * @throws IllegalArgumentException * @throws UnsupportedOperationException * @throws SSLPeerUnverifiedException * @throws IllegalStateException */ public static boolean validateSecureConnection(URL url, Proxy proxy) throws Exception, IOException, IllegalArgumentException, UnsupportedOperationException, SSLPeerUnverifiedException, IllegalStateException { if (url != null && url.getProtocol().equalsIgnoreCase("https")) { HttpsURLConnection conn = null; try { if (proxy != null) { conn = (HttpsURLConnection)url.openConnection(proxy); } else { conn = (HttpsURLConnection)url.openConnection(); } if (conn != null) { conn.connect(); Certificate[] certs = conn.getServerCertificates(); for (final Certificate cert: certs) { if (cert instanceof X509Certificate) { ((X509Certificate)cert).checkValidity(); } else { throw new Exception("Not a X.509 certificate"); } } conn.disconnect(); return true; } } finally { if (conn != null) { conn.disconnect(); } } } return false; } /** * Attempts to open the specified URL in the default browser of the system. * @param url The URL to open. * @return {@code true} if the URL has been opened in the system's default browser. * {@code false} otherwise. * @throws IOException * @throws URISyntaxException * @throws UnsupportedOperationException * @throws IllegalArgumentException */ public static boolean openWebPage(URL url) throws IOException, URISyntaxException, UnsupportedOperationException, IllegalArgumentException { if (url != null) { URI uri = url.toURI(); if (uri != null) { Desktop desktop = Desktop.isDesktopSupported() ? Desktop.getDesktop() : null; if (desktop != null && desktop.isSupported(Desktop.Action.BROWSE)) { desktop.browse(uri); return true; } } } return false; } /** * A convenience method for downloading textual data from a URL. * @param url The URL to download data from. * @param proxy An optional proxy definition. Can be {@code null}. * @param charset The character set of the text content (e.g. utf-8). * @return The text content on success or {@code null} on error. * @throws IOException * @throws FileNotFoundException * @throws ProtocolException * @throws UnknownServiceException * @throws ZipException */ public static String downloadText(URL url, Proxy proxy, String charset) throws IOException, FileNotFoundException, ProtocolException, UnknownServiceException, ZipException { String retVal = null; if (url != null) { ByteArrayOutputStream baos = new ByteArrayOutputStream(); if (Utils.downloadFromUrl(url, proxy, baos, UpdateInfo.FileType.ORIGINAL, null)) { if (charset == null || charset.isEmpty() || !Charset.isSupported(charset)) { charset = Charset.defaultCharset().name(); } try { retVal = baos.toString(charset); } catch (UnsupportedEncodingException e) { } } baos = null; } return retVal; } /** * Downloads data from the specified URL into the output stream. It is recommended to call this * method in a background task. * @param url The URL to download data from. * @param proxy An optional proxy definition. Can be {@code null}. * @param os The output stream to write the downloaded data into. * @param type The file type of the data. * FileType.Original does no further preprocessing. * FileType.ZIP unpacks the first available file found in the zip archive. * FileType.GZIP unpacks the data and writes it into the output stream. * @param listeners A list of event listeners to keep track of the current download progress. * @return true on success, false on error or if operation has been canceled. * @throws IOException * @throws ProtocolException * @throws UnknownServiceException * @throws ZipException * @throws FileNotFoundException */ public static boolean downloadFromUrl(URL url, Proxy proxy, OutputStream os, UpdateInfo.FileType type, List<ProgressListener> listeners) throws IOException, ProtocolException, UnknownServiceException, ZipException, FileNotFoundException { if (url != null && os != null) { URLConnection conn = null; if (proxy != null) { conn = url.openConnection(proxy); } else { conn = url.openConnection(); } if (conn != null) { int timeout = conn.getConnectTimeout(); conn.setConnectTimeout(6000); // wait max. 6 seconds InputStream is = conn.getInputStream(); conn.setConnectTimeout(timeout); try { switch (type) { case ORIGINAL: return downloadRaw(is, os, url, proxy, listeners); case ZIP: return downloadZip(is, os, url, proxy, listeners); case GZIP: return downloadGzip(is, os, url, proxy, listeners); case UNKNOWN: return false; } } finally { is.close(); is = null; } } } return false; } /** * Download data from the input stream into the output stream without special processing. * @return {@code true} if the download has finished successfully, * {@code false} on error or if the download has been cancelled. * @throws IOException * @throws ProtocolException * @throws UnknownServiceException */ static boolean downloadRaw(InputStream is, OutputStream os, URL url, Proxy proxy, List<ProgressListener> listeners) throws UnknownServiceException, ProtocolException, IOException { if (is != null && os != null) { byte[] buffer = new byte[4096]; try { int totalSize = getFileSizeUrl(url, proxy); int curSize = 0; int size; while ((size = is.read(buffer)) > 0) { os.write(buffer, 0, size); curSize += size; if (fireProgressEvent(listeners, url, curSize, totalSize, false)) { os.flush(); return false; } } os.flush(); fireProgressEvent(listeners, url, curSize, totalSize, true); return true; } finally { buffer = null; } } return false; } /** * Decompresses the first available file entry in the zipped data provided by the input stream. * @return {@code true} if the download has finished successfully, * {@code false} on error or if the download has been cancelled. * @throws IOException * @throws ZipException */ static boolean downloadZip(InputStream is, OutputStream os, URL url, Proxy proxy, List<ProgressListener> listeners) throws IOException, ZipException { if (is != null && os != null) { ZipInputStream zis = new ZipInputStream(is); byte[] buffer = new byte[4096]; try { ZipEntry entry = zis.getNextEntry(); if (entry != null) { int totalSize = (int)entry.getSize(); int curSize = 0; int size; while ((size = zis.read(buffer)) != -1) { os.write(buffer, 0, size); curSize += size; if (fireProgressEvent(listeners, url, curSize, totalSize, false)) { os.flush(); return false; } } os.flush(); fireProgressEvent(listeners, url, curSize, totalSize, true); return true; } } finally { zis.close(); zis = null; buffer = null; } } return false; } /** * Decompresses the GZIP compressed data provided by the input stream. * @return {@code true} if the download has finished successfully, * {@code false} on error or if the download has been cancelled. * @throws IOException */ static boolean downloadGzip(InputStream is, OutputStream os, URL url, Proxy proxy, List<ProgressListener> listeners) throws IOException { if (is != null && os != null) { GZIPInputStream gis = null; byte[] buffer = new byte[4096]; try { gis = new GZIPInputStream(is); int totalSize = -1; // impossible to determine the uncompressed file size int curSize = 0; int size; while ((size = gis.read(buffer)) != -1) { os.write(buffer, 0, size); curSize += size; if (fireProgressEvent(listeners, url, curSize, totalSize, false)) { os.flush(); return false; } } os.flush(); fireProgressEvent(listeners, url, curSize, totalSize, true); return true; } finally { if (gis != null) { gis.close(); gis = null; } buffer = null; } } return false; } // Informs about the progress of the current operation. Returns true if the operation can be cancelled. static boolean fireProgressEvent(List<ProgressListener> listeners, URL url, int curBytes, int totalBytes, boolean finished) { boolean bRet = false; if (listeners != null) { ProgressEvent event = null; for (Iterator<ProgressListener> iter = listeners.iterator(); iter.hasNext();) { if (event == null) { event = new ProgressEvent(url, curBytes, totalBytes, finished); } ProgressListener l = iter.next(); if (l != null) { l.dataProgressed(event); } } if (event != null) { bRet = event.isOperationCancelled(); } } return bRet; } // TODO: remove? // /** // * Executes the specified JAR file with optional parameters and working directory. // * @param jar The JAR file to execute. // * @param params Parameter list. Can be {@code null}. // * @param dir An optional working directory. Can be {@code null}. // * @return The Process instance of the executed JAR file or {@code null} on error. // */ // public static Process executeJar(String jar, String params, String dir) // { // if (jar != null && !jar.isEmpty()) { // StringBuilder sb = new StringBuilder(); // sb.append(JAVA_EXECUTABLE).append(" "); // sb.append("-jar "); // sb.append(jar).append(" "); // if (params != null && !params.isEmpty()) { // sb.append(params); // } // File file = null; // if (dir != null && !dir.isEmpty()) { // file = new File(dir); // if (!file.isDirectory()) { // file = null; // } // } // try { // return Runtime.getRuntime().exec(sb.toString(), null, file); // } catch (IOException e) { // } // } // return null; // } /** Convenience method for converting a String into an Integer. */ static int toNumber(String value, int defValue) { if (value != null) { try { return Integer.parseInt(value); } catch (NumberFormatException e) { } } return defValue; } private Utils() {} //-------------------------- INNER CLASSES -------------------------- public static interface ProgressListener extends EventListener { void dataProgressed(ProgressEvent event); } /** Is used to notify interested parties to document the progress in a download or upload operation. */ public static class ProgressEvent extends EventObject { private final boolean finished; private final int currentBytes, totalBytes; private boolean cancelOperation; /** * Constructs a new ProgressEvent object. * @param source should point to the URL object to download from or upload to. * @param currentBytes The cumulative amount of bytes processed in the current operation up until now. * @param totalBytes The total amount of bytes to process in the current operation. * Specify -1 if the total size of the data is unknown. */ public ProgressEvent(Object source, int currentBytes, int totalBytes, boolean finished) { super(source); this.currentBytes = currentBytes; this.totalBytes = totalBytes; this.finished = finished; this.cancelOperation = false; } /** Returns the cumulative amount of bytes processed in the current operation up until now. */ public int getCurrentBytes() { return currentBytes; } /** * Returns the total amount of bytes to procress in the current operation. * Can be -1 for operations where the total total size of the data is unknown. */ public int getTotalBytes() { return totalBytes; } /** Returns true if the process has been finished, false otherwise. */ public boolean isFinished() { return finished; } /** * Call this method to signal that the current operation can be canceled ({@code true}) * or resumed ({@code false}). */ public void cancelOperation(boolean cancel) { cancelOperation = cancel; } /** Returns whether the current operation can be cancelled. */ public boolean isOperationCancelled() { return cancelOperation; } } }