diff --git a/arduino-core/src/cc/arduino/utils/network/CacheControl.java b/arduino-core/src/cc/arduino/utils/network/CacheControl.java new file mode 100644 index 000000000..432624356 --- /dev/null +++ b/arduino-core/src/cc/arduino/utils/network/CacheControl.java @@ -0,0 +1,236 @@ +package cc.arduino.utils.network; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Represents a HTTP Cache-Control response header and parses it from string. + * + *

Note: This class ignores 1#field-name parameter for + * private and no-cache directive and cache extensions.

+ * + * @see HTTP/1.1 section 14.9 + */ +public class CacheControl { + + + // copied from org.apache.abdera.protocol.util.CacheControlUtil + private static final Pattern PATTERN + = Pattern.compile("\\s*([\\w\\-]+)\\s*(=)?\\s*(\\-?\\d+|\\\"([^\"\\\\]*(\\\\.[^\"\\\\]*)*)+\\\")?\\s*"); + + /** + * Corresponds to the max-age cache control directive. + * The default value is -1, i.e. not specified. + * + * @see HTTP/1.1 section 14.9.3 + */ + private int maxAge = -1; + + /** + * Corresponds to the s-maxage cache control directive. + * The default value is -1, i.e. not specified. + * + * @see HTTP/1.1 section 14.9.3 + */ + private int sMaxAge = -1; + + /** + * Whether the must-revalidate directive is specified. + * The default value is false. + * + * @see HTTP/1.1 section 14.9.4 + */ + private boolean isMustRevalidate = false; + + /** + * Whether the no-cache directive is specified. + * The default value is false. + * + * @see HTTP/1.1 section 14.9.1 + */ + private boolean isNoCache = false; + + /** + * Whether the no-store directive is specified. + * The default value is false. + * + * @see HTTP/1.1 section 14.9.2 + */ + private boolean isNoStore = false; + + /** + * Whether the no-transform directive is specified. + * The default value is false. + * + * @see HTTP/1.1 section 14.9.5 + */ + private boolean isNoTransform = false; + + /** + * Whether the private directive is specified. + * The default value is false. + * + * @see HTTP/1.1 section 14.9.1 + */ + private boolean isPrivate = false; + + /** + * Whether the public directive is specified. + * The default value is false. + * + * @see HTTP/1.1 section 14.9.1 + */ + private boolean isPublic = false; + + /** + * Whether the proxy-revalidate directive is specified. + * The default value is false. + * + * @see HTTP/1.1 section 14.9.4 + */ + private boolean isProxyRevalidate = false; + + + /** + * Creates a new instance of CacheControl by parsing the supplied string. + * + * @param value A value the Cache-Control header. + */ + public static CacheControl valueOf(String value) { + CacheControl cc = new CacheControl(); + + if (value != null) { + Matcher matcher = PATTERN.matcher(value); + while (matcher.find()) { + switch (matcher.group(1).toLowerCase()) { + case "max-age": + cc.setMaxAge(Integer.parseInt(matcher.group(3))); break; + case "s-maxage": + cc.setSMaxAge(Integer.parseInt(matcher.group(3))); break; + case "must-revalidate": + cc.setMustRevalidate(true); break; + case "no-cache": + cc.setNoCache(true); break; + case "no-store": + cc.setNoStore(true); break; + case "no-transform": + cc.setNoTransform(true); break; + case "private": + cc.setPrivate(true); break; + case "public": + cc.setPublic(true); break; + case "proxy-revalidate": + cc.setProxyRevalidate(true); break; + default: //ignore + } + } + } + return cc; + } + + /** + * Returns max-age, or s-maxage according to whether + * considering a shared cache, or a private cache. If shared cache and the + * s-maxage is negative (i.e. not set), then returns + * max-age instead. + * + * @param sharedCache true for a shared cache, + * or false for a private cache + * @return A {@link #maxAge}, or {@link #sMaxAge} according to the given + * sharedCache argument. + */ + public int getMaxAge(boolean sharedCache) { + if (sharedCache) { + return sMaxAge >= 0 ? sMaxAge : maxAge; + } else { + return maxAge; + } + } + + public void setMaxAge(int maxAge) { + this.maxAge = maxAge; + } + + public int getMaxAge() { + return maxAge; + } + + public int getSMaxAge() { + return sMaxAge; + } + + public void setSMaxAge(int sMaxAge) { + this.sMaxAge = sMaxAge; + } + + public boolean isMustRevalidate() { + return isMustRevalidate; + } + + public void setMustRevalidate(boolean mustRevalidate) { + isMustRevalidate = mustRevalidate; + } + + public boolean isNoCache() { + return isNoCache; + } + + public void setNoCache(boolean noCache) { + isNoCache = noCache; + } + + public boolean isNoStore() { + return isNoStore; + } + + public void setNoStore(boolean noStore) { + isNoStore = noStore; + } + + public boolean isNoTransform() { + return isNoTransform; + } + + public void setNoTransform(boolean noTransform) { + isNoTransform = noTransform; + } + + public boolean isPrivate() { + return isPrivate; + } + + public void setPrivate(boolean aPrivate) { + isPrivate = aPrivate; + } + + public boolean isPublic() { + return isPublic; + } + + public void setPublic(boolean aPublic) { + isPublic = aPublic; + } + + public boolean isProxyRevalidate() { + return isProxyRevalidate; + } + + public void setProxyRevalidate(boolean proxyRevalidate) { + isProxyRevalidate = proxyRevalidate; + } + + @Override + public String toString() { + return "CacheControl{" + + "maxAge=" + maxAge + + ", sMaxAge=" + sMaxAge + + ", isMustRevalidate=" + isMustRevalidate + + ", isNoCache=" + isNoCache + + ", isNoStore=" + isNoStore + + ", isNoTransform=" + isNoTransform + + ", isPrivate=" + isPrivate + + ", isPublic=" + isPublic + + ", isProxyRevalidate=" + isProxyRevalidate + + '}'; + } +} diff --git a/arduino-core/src/cc/arduino/utils/network/FileDownloader.java b/arduino-core/src/cc/arduino/utils/network/FileDownloader.java index ef671cdff..674277041 100644 --- a/arduino-core/src/cc/arduino/utils/network/FileDownloader.java +++ b/arduino-core/src/cc/arduino/utils/network/FileDownloader.java @@ -30,9 +30,8 @@ package cc.arduino.utils.network; import org.apache.commons.compress.utils.IOUtils; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import processing.app.BaseNoGui; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import processing.app.helpers.FileUtils; import java.io.File; @@ -48,7 +47,7 @@ import java.util.Observable; import java.util.Optional; public class FileDownloader extends Observable { - private static Logger log = LoggerFactory.getLogger(FileDownloader.class); + private static Logger log = LogManager.getLogger(FileDownloader.class); public enum Status { CONNECTING, // @@ -146,15 +145,14 @@ public class FileDownloader extends Observable { try { setStatus(Status.CONNECTING); - final File settingsFolder = BaseNoGui.getPlatform().getSettingsFolder(); - final String cacheFolder = Paths.get(settingsFolder.getPath(), "cache").toString(); - final FileDownloaderCache fileDownloaderCache = FileDownloaderCache.getFileCached(cacheFolder, downloadUrl); + final Optional fileCached = FileDownloaderCache.getFileCached(downloadUrl); - if (!fileDownloaderCache.isChange()) { + if (fileCached.isPresent() && !fileCached.get().isChange()) { try { final Optional fileFromCache = - fileDownloaderCache.getFileFromCache(); + fileCached.get().getFileFromCache(); if (fileFromCache.isPresent()) { + log.info("No need to download using cached file: {}", fileCached.get()); FileUtils.copyFile(fileFromCache.get(), outputFile); setStatus(Status.COMPLETE); return; @@ -195,7 +193,7 @@ public class FileDownloader extends Observable { synchronized (this) { stream = connection.getInputStream(); } - byte buffer[] = new byte[10240]; + byte[] buffer = new byte[10240]; while (status == Status.DOWNLOADING) { int read = stream.read(buffer); if (read == -1) @@ -216,7 +214,9 @@ public class FileDownloader extends Observable { } // Set the cache whe it finish to download the file IOUtils.closeQuietly(randomAccessOutputFile); - fileDownloaderCache.fillCache(outputFile); + if (fileCached.isPresent()) { + fileCached.get().updateCacheFile(outputFile); + } setStatus(Status.COMPLETE); } catch (InterruptedException e) { setStatus(Status.CANCELLED); diff --git a/arduino-core/src/cc/arduino/utils/network/FileDownloaderCache.java b/arduino-core/src/cc/arduino/utils/network/FileDownloaderCache.java index 0d11510c2..32e425abb 100644 --- a/arduino-core/src/cc/arduino/utils/network/FileDownloaderCache.java +++ b/arduino-core/src/cc/arduino/utils/network/FileDownloaderCache.java @@ -1,8 +1,16 @@ package cc.arduino.utils.network; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import processing.app.PreferencesData; +import cc.arduino.utils.FileHash; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import processing.app.BaseNoGui; import processing.app.helpers.FileUtils; import javax.script.ScriptException; @@ -15,91 +23,289 @@ import java.net.URL; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; -import java.util.Optional; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.*; +import java.util.function.BiFunction; +import java.util.function.Function; +import java.util.stream.Collectors; public class FileDownloaderCache { - private static Logger log = LoggerFactory.getLogger(FileDownloaderCache.class); - private final Path cacheFilePath; - private final String remoteETag; - private final String preferencesDataKey; + private final static Logger log = LogManager + .getLogger(FileDownloaderCache.class); + private static Map cachedFiles = Collections + .synchronizedMap(new HashMap<>()); + private static String cacheFolder; - // BaseNoGui.getSettingsFolder() - private FileDownloaderCache(Path cacheFilePath, String remoteETag, String preferencesDataKey) { - this.cacheFilePath = cacheFilePath; - this.remoteETag = remoteETag; - this.preferencesDataKey = preferencesDataKey; + static { + final File settingsFolder; + try { + settingsFolder = BaseNoGui.getPlatform().getSettingsFolder(); + cacheFolder = Paths.get(settingsFolder.getPath(), "cache") + .toString(); + final Path pathCacheInfo = getCachedInfoPath(); + if (Files.exists(pathCacheInfo)) { + ObjectMapper mapper = new ObjectMapper(); + final JsonNode jsonNode = mapper.readTree(pathCacheInfo.toFile()); + + // Read the files array + TypeReference> typeRef = new TypeReference>() { + }; + final List files = mapper + .readValue(mapper.treeAsTokens(jsonNode.get("files")), typeRef); + + // Create a map with the remote url as a key and the file cache info as a value + cachedFiles = Collections + .synchronizedMap(files.stream().collect( + Collectors.toMap(FileCached::getRemoteURL, Function.identity()))); + } + } catch (Exception e) { + log.error("Cannot initialized the cache", e); + } } - public static FileDownloaderCache getFileCached(String cacheFolder, URL remoteURL) - throws IOException, NoSuchMethodException, ScriptException, URISyntaxException { + public static Optional getFileCached(URL remoteURL) + throws URISyntaxException, NoSuchMethodException, ScriptException, + IOException { - final String[] splitPath = remoteURL.getPath().split("/"); - final String preferencesDataKey = "cache.file." + remoteURL.getPath(); - final Path cacheFilePath; - if (splitPath.length > 0) { - cacheFilePath = Paths.get(cacheFolder, splitPath); + final Optional fileCachedOpt; + + // The file info must exist in the cachedFiles map but also the real file must exist in the file system + if (cachedFiles.containsKey(remoteURL.toString()) && cachedFiles.get(remoteURL.toString()).exists()) { + fileCachedOpt = Optional.of(cachedFiles.get(remoteURL.toString())); } else { - cacheFilePath = null; - } - - final HttpURLConnection headRequest = new HttpConnectionManager(remoteURL) - .makeConnection((connection) -> { - try { - connection.setRequestMethod("HEAD"); - } catch (ProtocolException e) { - log.error("Invalid protocol", e); + // Update + fileCachedOpt = FileDownloaderCache.updateCacheInfo(remoteURL, (remoteETagClean, cacheControl) -> { + // Check cache control data + if (cacheControl.isNoCache() || cacheControl.isMustRevalidate()) { + log.warn("The file must not be cache due to cache control header {}", + cacheControl); + return Optional.empty(); } + + final String[] splitPath = remoteURL.getPath().split("/"); + final Path cacheFilePath; + if (splitPath.length > 0) { + Deque addFirstRemoteURL = new LinkedList<>(Arrays.asList(splitPath)); + addFirstRemoteURL.addFirst(remoteURL.getHost()); + cacheFilePath = Paths.get(cacheFolder, addFirstRemoteURL.toArray(new String[0])); + return Optional.of( + new FileCached(remoteETagClean, remoteURL.toString(), cacheFilePath.toString(), + cacheControl)); + } + log.warn("The remote path as no file name {}", remoteURL); + return Optional.empty(); }); + } + return fileCachedOpt; + } + + private static Optional updateCacheInfo(URL remoteURL, BiFunction> getNewFile) + throws URISyntaxException, NoSuchMethodException, ScriptException, + IOException { + // Update the headers of the cached file + final HttpURLConnection headRequest = new HttpConnectionManager( + remoteURL).makeConnection((connection) -> { + try { + connection.setRequestMethod("HEAD"); + } catch (ProtocolException e) { + log.error("Invalid protocol", e); + } + }); final int responseCode = headRequest.getResponseCode(); headRequest.disconnect(); // Something bad is happening return a conservative true to try to download the file if (responseCode < 200 || responseCode >= 300) { log.warn("The head request return a bad response code " + responseCode); // if something bad happend - return new FileDownloaderCache(cacheFilePath, null, preferencesDataKey); + return Optional.empty(); } - - final String remoteETag = headRequest.getHeaderField("ETag"); - String remoteETagClean = null; - if (remoteETag != null) { - remoteETagClean = remoteETag.trim().replace("\"", ""); + // Get all the useful headers + String remoteETag = headRequest.getHeaderField("ETag"); + String cacheControlHeader = headRequest.getHeaderField("Cache-Control"); + if (remoteETag != null && cacheControlHeader != null) { + final String remoteETagClean = remoteETag.trim().replace("\"", ""); + final CacheControl cacheControl = CacheControl.valueOf(cacheControlHeader); + return getNewFile.apply(remoteETagClean, cacheControl); } - - return new FileDownloaderCache(cacheFilePath, remoteETagClean, preferencesDataKey); + log.warn("The head request do not return the ETag {} or the Cache-Control {}", remoteETag, cacheControlHeader); + return Optional.empty(); } - public boolean isChange() { + private static void updateCacheFilesInfo() throws IOException { + if (cachedFiles != null) { + synchronized (cachedFiles) { + ObjectMapper mapper = new ObjectMapper(); + mapper.enable(SerializationFeature.INDENT_OUTPUT); + final ObjectNode objectNode = mapper.createObjectNode(); + objectNode.putArray("files").addAll( + cachedFiles.values().stream() + .map((v) -> mapper.convertValue(v, JsonNode.class)) + .collect(Collectors.toList())); + // Create the path Arduino15/cache + Path cachedFileInfo = getCachedInfoPath(); + if (Files.notExists(cachedFileInfo)) { + Files.createDirectories(cachedFileInfo.getParent()); + } + mapper.writeValue(cachedFileInfo.toFile(), objectNode); + } + } + } - final String localETag = PreferencesData.get(preferencesDataKey); + private static Path getCachedInfoPath() { + return Paths.get(cacheFolder, "cache.json"); + } - // If the header doesn't exist or the local cache doesn't exist you need to download the file - if (cacheFilePath == null || remoteETag == null || localETag == null) { + @JsonIgnoreProperties(ignoreUnknown = true) + static class FileCached { + private String eTag; + @JsonIgnore + private final String lastETag; + private final String remoteURL; + private final String localPath; + private String md5; + private String createdAt; + private final CacheControl cacheControl; + + FileCached() { + this.lastETag = null; + this.remoteURL = null; + this.localPath = null; + this.cacheControl = null; + } + + FileCached(String lastETag, String remoteURL, String localPath, CacheControl cacheControl) { + this.lastETag = lastETag; + this.remoteURL = remoteURL; + this.localPath = localPath; + this.cacheControl = cacheControl; + } + + @JsonIgnore + public boolean isChange() { + // Check if the file is expire + if (this.getExpiresTime().isAfter(LocalDateTime.now())) { + log.info("The file \"{}\" is no expire, the etag will not be checked. Expire time: {}", localPath, this.getExpiresTime().toString()); + return false; + } + + if (lastETag != null) { + // If are different means that the file is change + return !lastETag.equals(eTag); + } return true; } - // If are different means that the file is change - return !remoteETag.equals(localETag); - } - - public Optional getFileFromCache() { - if (Optional.ofNullable(cacheFilePath).isPresent() && Files.exists(cacheFilePath)) { - return Optional.of(new File(cacheFilePath.toUri())); - } - return Optional.empty(); - - } - - public void fillCache(File fileToCache) throws Exception { - if (Optional.ofNullable(remoteETag).isPresent() && - Optional.ofNullable(cacheFilePath).isPresent()) { - - PreferencesData.set(preferencesDataKey, remoteETag); - // If the cache directory does not exist create it - if (!Files.exists(cacheFilePath.getParent())) { - Files.createDirectories(cacheFilePath.getParent()); + @JsonIgnore + public boolean exists() throws IOException { + if (localPath != null && Files.exists(Paths.get(localPath))) { + try { + final String md5Local = FileHash.hash(Paths.get(localPath).toFile(), "MD5"); + if (md5Local.equals(md5)) { + return true; + } + } catch (NoSuchAlgorithmException e) { + log.error("MD5 algorithm is not supported", e); + } } - FileUtils.copyFile(fileToCache, cacheFilePath.toFile()); + return false; + } + + @JsonIgnore + public Optional getFileFromCache() { + if (localPath != null && Files.exists(Paths.get(localPath))) { + return Optional.of(new File(localPath)); + } + return Optional.empty(); + + } + + public void updateCacheFile(File fileToCache) throws Exception { + if (Optional.ofNullable(lastETag).isPresent() && Optional + .ofNullable(localPath).isPresent()) { + Path cacheFilePath = Paths.get(localPath); + + // If the cache directory does not exist create it + if (!Files.exists(cacheFilePath.getParent())) { + Files.createDirectories(cacheFilePath.getParent()); + } + final File cacheFile = cacheFilePath.toFile(); + FileUtils.copyFile(fileToCache, cacheFile); + eTag = lastETag; + createdAt = LocalDateTime.now().format(DateTimeFormatter.ISO_DATE_TIME); + updateMD5(); + } + log.info("Update cache file: {}", this); + cachedFiles.put(remoteURL, this); + updateCacheFilesInfo(); + } + + private void updateMD5() throws IOException, NoSuchAlgorithmException { + if (localPath != null) { + md5 = FileHash.hash(Paths.get(localPath).toFile(), "MD5"); + } + } + + @JsonIgnore + public LocalDateTime getExpiresTime() { + final int maxAge; + if (cacheControl != null) { + maxAge = cacheControl.getMaxAge(); + } else { + maxAge = 0; + } + if (createdAt != null) { + return LocalDateTime.parse(createdAt, DateTimeFormatter.ISO_DATE_TIME) + .plusSeconds(maxAge); + } + return LocalDateTime.now(); + + } + + public String getExpires() { + return getExpiresTime().toString(); + } + + public String getMD5() { + return md5; + } + + public String geteTag() { + return eTag; + } + + public String getRemoteURL() { + return remoteURL; + } + + public String getLocalPath() { + return localPath; + } + + public void setMd5(String md5) { + this.md5 = md5; + } + + public String getCreatedAt() { + return createdAt; + } + + public CacheControl getCacheControl() { + return cacheControl; + } + + @Override + public String toString() { + return "FileCached{" + + "eTag='" + eTag + '\'' + + ", lastETag='" + lastETag + '\'' + + ", remoteURL='" + remoteURL + '\'' + + ", localPath='" + localPath + '\'' + + ", md5='" + md5 + '\'' + + ", createdAt='" + createdAt + '\'' + + ", cacheControl=" + cacheControl + + '}'; } } - }