// 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.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; import java.io.UnsupportedEncodingException; import java.net.MalformedURLException; import java.net.URL; import java.nio.file.Path; import java.util.ArrayList; import java.util.Calendar; import java.util.Collections; import java.util.EnumMap; import java.util.Iterator; import java.util.List; import javax.xml.parsers.DocumentBuilder; import javax.xml.parsers.DocumentBuilderFactory; import org.infinity.util.Pair; import org.infinity.util.io.StreamUtils; import org.w3c.dom.Document; import org.w3c.dom.DocumentType; import org.w3c.dom.Element; import org.w3c.dom.Node; import org.w3c.dom.NodeList; import org.xml.sax.ErrorHandler; import org.xml.sax.SAXException; import org.xml.sax.SAXParseException; /** * Provides access to update information stored in "update.xml". */ public class UpdateInfo { /** Available release types. */ public enum ReleaseType { /** Information about the latest Near Infinity release (stable or unstable). */ LATEST, /** Information about the latest stable Near Infinity release. */ STABLE, /** Information about the updater helper tool. */ UPDATER } /** File type of data to be downloaded. */ public enum FileType { /** Indicates an unknown or unsupported file type. */ UNKNOWN, /** No further processing necessary. */ ORIGINAL, /** Unpack first available file from the zip archive. */ ZIP, /** Uncompress gzip file. */ GZIP } // Supported node and attribute names private static final String NODE_UPDATE = "update"; private static final String NODE_GENERAL = "general"; private static final String NODE_RELEASE = "release"; private static final String NODE_SERVER = "server"; private static final String NODE_INFO = "info"; private static final String NODE_LINK = "link"; private static final String NODE_FILE = "file"; private static final String NODE_NAME = "name"; private static final String NODE_URL = "url"; private static final String NODE_VERSION = "version"; private static final String NODE_TIMESTAMP = "timestamp"; private static final String NODE_HASH = "hash"; private static final String NODE_CHANGELOG = "changelog"; private static final String NODE_ENTRY = "entry"; private static final String ATTR_VERSION = "version"; private static final String ATTR_TYPE = "type"; private final EnumMap<ReleaseType, Release> releases = new EnumMap<ReleaseType, Release>(ReleaseType.class); private General general; private int version; /** * Checks if the specified string contains valid update.xml data. * <b>Note:</b> This is an expensive operation. * @param s The string to check. * @param systemId Base path for relative URIs (required for Doctype reference). * @return {@code true} if the string conforms to the update.xml specification, * {@code false} otherwise. */ public static boolean isValidXml(String s, String systemId) { try { return isValidXml(new ByteArrayInputStream(s.getBytes("UTF-8")), systemId); } catch (UnsupportedEncodingException e) { } return false; } /** * Checks if the specified file contains valid update.xml data. * <b>Note:</b> This is an expensive operation. * @param f The file to read data from. * @return {@code true} if the file content conforms to the update.xml specification, * {@code false} otherwise. */ public static boolean isValidXml(Path f) { try (InputStream is = StreamUtils.getInputStream(f)) { return isValidXml(is, f.getParent().toAbsolutePath().toString()); } catch (IOException e) { } return false; } /** * Checks if the specified input stream points to valid update.xml data. * <b>Note:</b> This is an expensive operation. * @param is The input stream to read data from. * @param systemId Base path for relative URIs (required for Doctype reference). * @return {@code true} if the data from the stream conforms to the update.xml specification, * {@code false} otherwise. */ public static boolean isValidXml(InputStream is, String systemId) { try { DocumentBuilderFactory dbFactory = DocumentBuilderFactory.newInstance(); DocumentBuilder dBuilder = dbFactory.newDocumentBuilder(); dBuilder.setErrorHandler(new XmlErrorHandler()); Document doc = dBuilder.parse(is, systemId); return NODE_UPDATE.equals(doc.getDocumentElement().getNodeName()); } catch (Exception e) { } return false; } /** * Read update information from the given InputStream object. * @param is InputStream object pointing to the update data in XML format. * @param systemId Base path for relative URIs (required for Doctype reference). */ public UpdateInfo(InputStream is, String systemId) throws Exception { parseXml(is, systemId); } /** * Read update information from the specified file. * @param f The file containing update information in XML format. */ public UpdateInfo(Path f) throws Exception { try (InputStream is = StreamUtils.getInputStream(f)) { parseXml(is, f.getParent().toAbsolutePath().toString()); } } /** * Read update information from the specified string. * @param s The text string containing update information in XML format. * @param systemId Base path for relative URIs (required for Doctype reference). */ public UpdateInfo(String s, String systemId) throws Exception { parseXml(new ByteArrayInputStream(s.getBytes("UTF-8")), systemId); } /** * Provides access to the General section of the update.xml. */ public General getGeneral() { return general; } /** * Provides access to the the Release section specified by the user in the Server Settings * or returns {@code null} if not available. */ public Release getRelease() { return releases.get(Updater.getInstance().isStableOnly() ? ReleaseType.STABLE : ReleaseType.LATEST); } /** * Provides access to the specified Release section or returns {@code null} if not available. * @param type The release type to access. */ public Release getRelease(ReleaseType type) { return releases.get(type); } // Returns whether all mandatory fields have been initialized correctly. public boolean isValid() { boolean retVal = true; if (getGeneral() != null) { retVal &= getGeneral().isValid(); } if (getRelease() == null) { retVal = false; } if (retVal) { for (Iterator<Release> iter = releases.values().iterator(); iter.hasNext();) { retVal &= iter.next().isValid(); } } return retVal; } /** * Returns the specification version of the current update.xml. Any version > 0 is valid. * Newer versions are supposed to be backwards compatible. * @return The specification version of the update.xml. */ public int getUpdateInfoVersion() { return version; } private void parseXml(InputStream is, String systemId) throws Exception { if (is != null) { // reading XML data DocumentBuilderFactory dbFactory = DocumentBuilderFactory.newInstance(); DocumentBuilder dBuilder = dbFactory.newDocumentBuilder(); dBuilder.setErrorHandler(new XmlErrorHandler()); Document doc = dBuilder.parse(is, systemId); doc.getDocumentElement().normalize(); DocumentType docType = doc.getDoctype(); if (docType == null) { throw new Exception("Update.xml: No DTD specified"); } // initializing data Element elemRoot = doc.getDocumentElement(); if (!elemRoot.getNodeName().equals(NODE_UPDATE)) { throw new Exception("Update.xml: Unsupported root node name: " + elemRoot.getNodeName()); } version = Utils.toNumber(elemRoot.getAttribute(ATTR_VERSION), 0); if (getUpdateInfoVersion() < 1) { throw new Exception("Update.xml: Unsupported or missing specification version"); } // TODO: parse elements based on update version // initializing general section NodeList generalList = elemRoot.getElementsByTagName(NODE_GENERAL); for (int idx = 0, size = generalList.getLength(); idx < size; idx++) { Node n = generalList.item(idx); if (n.getNodeType() == Node.ELEMENT_NODE) { // General section is optional parseGeneral((Element)n); break; } } // initializing release sections NodeList releaseList = elemRoot.getElementsByTagName(NODE_RELEASE); for (int idx = 0, size = releaseList.getLength(); idx < size; idx++) { Node n = releaseList.item(idx); if (n.getNodeType() == Node.ELEMENT_NODE) { parseRelease((Element)n); } } } } private void parseGeneral(Element elemGeneral) throws Exception { if (elemGeneral == null || !elemGeneral.getNodeName().equals(NODE_GENERAL)) { throw new Exception("Update.xml: Node \"" + NODE_GENERAL + "\" expected"); } List<String> serverList = new ArrayList<String>(); List<Pair<String>> infoList = new ArrayList<Pair<String>>(); NodeList children = elemGeneral.getChildNodes(); for (int idx = 0, size = children.getLength(); idx < size; idx++) { Node node = children.item(idx); if (node.getNodeType() != Node.ELEMENT_NODE) { continue; } if (node.getNodeName().equals(NODE_SERVER)) { NodeList list = node.getChildNodes(); for (int j = 0, listSize = list.getLength(); j < listSize; j++) { node = list.item(j); if (node.getNodeType() != Node.ELEMENT_NODE) { continue; } if (node.getNodeName().equals(NODE_LINK)) { try { URL url = new URL(node.getTextContent().trim()); serverList.add(url.toExternalForm()); } catch (MalformedURLException e) { // don't add invalid URLs } } } } else if (node.getNodeName().equals(NODE_INFO)) { NodeList list = node.getChildNodes(); Node n1 = null, n2 = null; for (int j = 0, listSize = list.getLength(); j < listSize; j++) { Node n = list.item(j); if (n.getNodeType() != Node.ELEMENT_NODE) { continue; } if (n.getNodeName().equals(NODE_NAME)) { n1 = n; } else if (n.getNodeName().equals(NODE_LINK)) { n2 = n; } } if (n1 != null && n2 != null) { try { String name = n1.getTextContent().trim(); URL url = new URL(n2.getTextContent().trim()); infoList.add(new Pair<String>(name, url.toExternalForm())); } catch (MalformedURLException e) { // don't add invalid URLs } } } } general = new General(serverList, infoList); } private void parseRelease(Element elemRelease) throws Exception { if (elemRelease == null || !elemRelease.getNodeName().equals(NODE_RELEASE)) { throw new Exception("Update.xml: Node \"" + NODE_RELEASE + "\" expected"); } ReleaseType type = null; String fileName = null; String link = null; String linkType = null; String version = null; String timeStamp = null; String hash = null; String linkManual = null; List<String> changelog = null; try { type = Enum.valueOf(ReleaseType.class, elemRelease.getAttribute(ATTR_TYPE).toUpperCase()); } catch (IllegalArgumentException e) { // skipping unsupported entries } if (type == null) { type = ReleaseType.LATEST; } // preprocessing available child elements in "release" section NodeList children = elemRelease.getChildNodes(); Element elemFile = null, elemChangelog = null; for (int i = 0, size = children.getLength(); i < size; i++) { Node node = children.item(i); if (node.getNodeType() == Node.ELEMENT_NODE) { if (node.getNodeName().equals(NODE_FILE)) { elemFile = (Element)node; } else if (node.getNodeName().equals(NODE_CHANGELOG)) { elemChangelog = (Element)node; } } } // processing required element "file" if (elemFile == null || !elemFile.getNodeName().equals(NODE_FILE)) { throw new Exception("Update.xml: Missing \"" + NODE_FILE + "\" node"); } children = elemFile.getChildNodes(); for (int idx = 0, size = children.getLength(); idx < size; idx++) { Element elem = null; if (children.item(idx).getNodeType() == Node.ELEMENT_NODE) { elem = (Element)children.item(idx); } else { continue; } if (elem.getNodeName().equals(NODE_NAME)) { fileName = elem.getTextContent().trim(); } else if (elem.getNodeName().equals(NODE_URL)) { linkType = elem.getAttribute(ATTR_TYPE); link = elem.getTextContent().trim(); } else if (elem.getNodeName().equals(NODE_VERSION)) { version = elem.getTextContent().trim(); } else if (elem.getNodeName().equals(NODE_TIMESTAMP)) { timeStamp = elem.getTextContent().trim(); } else if (elem.getNodeName().equals(NODE_HASH)) { hash = elem.getTextContent().trim(); } else if (elem.getNodeName().equals(NODE_LINK)) { linkManual = elem.getTextContent().trim(); } } // processing optional element "changelog" if (elemChangelog != null) { changelog = new ArrayList<String>(); children = elemChangelog.getElementsByTagName(NODE_ENTRY); for (int idx = 0, size = children.getLength(); idx < size; idx++) { Element elem = (Element)children.item(idx); String s = elem.getTextContent().trim(); if (!s.isEmpty()) { changelog.add(s); } } if (changelog.isEmpty()) { changelog = null; } } Release release = new Release(type, fileName, link, linkType, version, hash, timeStamp, linkManual, changelog); releases.put(type, release); } //-------------------------- INNER CLASSES -------------------------- // Manages "General" information public static class General { private final List<String> servers = new ArrayList<String>(); private final List<Pair<String>> information = new ArrayList<Pair<String>>(); private General(List<String> servers, List<Pair<String>> information) throws Exception { if (servers != null) { this.servers.addAll(servers); } if (information != null) { this.information.addAll(information); } } /** Returns number of available alternate update servers. */ public int getServerCount() { return servers.size(); } /** Returns the URL of the specified alternate server or {@code null} if not available. */ public String getServer(int index) { if (index >= 0 && index < getServerCount()) { return servers.get(index); } else { return null; } } /** Returns number of available links to related websites. */ public int getInformationCount() { return information.size(); } /** Returns name of the specified related website or {@code null} if not available. */ public String getInformationName(int index) { if (index >= 0 && index < getInformationCount()) { return information.get(index).getFirst(); } else { return null; } } /** Returns URL of the specified related website or {@code null} if not available. */ public String getInformationLink(int index) { if (index >= 0 && index < getInformationCount()) { return information.get(index).getSecond(); } else { return null; } } /** Checks whether data has been initialized correctly. */ public boolean isValid() { for (int i = 0, size = getServerCount(); i < size; i++) { String url = getServer(i); if (!Utils.isUrlValid(url)) { return false; } } for (int i = 0, size = getInformationCount(); i < size; i++) { String name = getInformationName(i); String url = getInformationLink(i); if (name == null || name.isEmpty() || !Utils.isUrlValid(url)) { return false; } } return true; } } // Manages "Release" information public static class Release { private final List<String> changelog = new ArrayList<String>(); private final ReleaseType type; private String fileName, link, linkManual, version, hash; private FileType linkType; private Calendar timeStamp; private Release(ReleaseType type, String fileName, String link, String linkType, String version, String hash, String timeStamp, String linkManual, List<String> changelog) throws Exception { // checking mandatory fields if (fileName == null) { throw new Exception(String.format("Update.xml: Missing \"%1$s\" node in %2$s section", NODE_NAME, NODE_RELEASE)); } if (link == null) { throw new Exception(String.format("Update.xml: Missing \"%1$s\" node in %2$s section", NODE_LINK, NODE_RELEASE)); } if (version == null) { throw new Exception(String.format("Update.xml: Missing \"%1$s\" node in %2$s section", NODE_VERSION, NODE_RELEASE)); } if (timeStamp == null) { throw new Exception(String.format("Update.xml: Missing \"%1$s\" node in %2$s section", NODE_TIMESTAMP, NODE_RELEASE)); } if (hash == null) { throw new Exception(String.format("Update.xml: Missing \"%1$s\" node in %2$s section", NODE_HASH, NODE_RELEASE)); } this.type = type; this.fileName = fileName; this.link = link; this.linkType = validateLinkType(linkType); this.version = version; this.hash = hash; this.timeStamp = Utils.toCalendar(timeStamp); this.linkManual = linkManual; if (changelog != null) { this.changelog.addAll(changelog); } } /** Returns the type of this file entry. */ public ReleaseType getReleaseType() { return type; } /** Returns the actual filename without path. */ public String getFileName() { return fileName; } /** Returns the link to the file. Use {@link #getLinkType()} to determine archive format. */ public String getLink() { return link; } /** Returns the archive format of the file to download. */ public FileType getLinkType() { return linkType; } /** Returns the file version. */ public String getVersion() { return version; } /** Returns the md5 hash string for the file to download. */ public String getHash() { return hash; } /** Returns the date and time of the file. */ public Calendar getTimeStamp() { return timeStamp; } /** Returns a String version of the timestamp in ISO 8601 format. */ public String getTimeStampString() { return Utils.toTimeStamp(timeStamp); } /** Returns a link to the file for manual download or {@code null} if not available. */ public String getDownloadLink() { return linkManual; } /** Returns whether a ChangeLog is available. */ public boolean hasChangeLog() { return !changelog.isEmpty(); } /** Returns a read-only list of changelog entries. */ public List<String> getChangelog() { return Collections.unmodifiableList(changelog); } /** Checks whether data has been initialized correctly. */ public boolean isValid() { if (getReleaseType() == null) { return false; } if (!Utils.isUrlValid(getLink())) { return false; } if (getDownloadLink() != null && !Utils.isUrlValid(getDownloadLink())) { return false; } return true; } // Returns only supported archive formats for the linked file private static FileType validateLinkType(String linkType) throws Exception { if ("jar".equalsIgnoreCase(linkType)) { return FileType.ORIGINAL; } else if ("zip".equalsIgnoreCase(linkType)) { return FileType.ZIP; } else if ("gzip".equalsIgnoreCase(linkType)) { return FileType.GZIP; } throw new Exception("Invalid link type: " + linkType); } } private static class XmlErrorHandler implements ErrorHandler { public XmlErrorHandler() {} @Override public void warning(SAXParseException exception) throws SAXException { throw exception; } @Override public void error(SAXParseException exception) throws SAXException { throw exception; } @Override public void fatalError(SAXParseException exception) throws SAXException { throw exception; } } }