001// License: GPL. For details, see LICENSE file. 002package org.openstreetmap.josm.gui.io; 003 004import static org.openstreetmap.josm.tools.I18n.tr; 005 006import java.awt.Component; 007import java.io.File; 008import java.io.IOException; 009import java.io.InputStream; 010import java.io.OutputStream; 011import java.net.MalformedURLException; 012import java.net.URL; 013import java.nio.charset.StandardCharsets; 014import java.nio.file.Files; 015import java.nio.file.InvalidPathException; 016import java.nio.file.Path; 017import java.nio.file.Paths; 018import java.nio.file.StandardCopyOption; 019import java.util.Enumeration; 020import java.util.zip.ZipEntry; 021import java.util.zip.ZipFile; 022 023import org.openstreetmap.josm.gui.PleaseWaitDialog; 024import org.openstreetmap.josm.gui.PleaseWaitRunnable; 025import org.openstreetmap.josm.tools.HttpClient; 026import org.openstreetmap.josm.tools.Logging; 027import org.openstreetmap.josm.tools.Utils; 028import org.xml.sax.SAXException; 029 030/** 031 * Asynchronous task for downloading and unpacking arbitrary file lists 032 * Shows progress bar when downloading 033 */ 034public class DownloadFileTask extends PleaseWaitRunnable { 035 private final String address; 036 private final File file; 037 private final boolean mkdir; 038 private final boolean unpack; 039 040 /** 041 * Creates the download task 042 * 043 * @param parent the parent component relative to which the {@link PleaseWaitDialog} is displayed 044 * @param address the URL to download 045 * @param file The destination file 046 * @param mkdir {@code true} if the destination directory must be created, {@code false} otherwise 047 * @param unpack {@code true} if zip archives must be unpacked recursively, {@code false} otherwise 048 * @throws IllegalArgumentException if {@code parent} is null 049 */ 050 public DownloadFileTask(Component parent, String address, File file, boolean mkdir, boolean unpack) { 051 super(parent, tr("Downloading file"), false); 052 this.address = address; 053 this.file = file; 054 this.mkdir = mkdir; 055 this.unpack = unpack; 056 } 057 058 private static class DownloadException extends Exception { 059 /** 060 * Constructs a new {@code DownloadException}. 061 * @param message the detail message. The detail message is saved for 062 * later retrieval by the {@link #getMessage()} method. 063 * @param cause the cause (which is saved for later retrieval by the 064 * {@link #getCause()} method). (A <code>null</code> value is 065 * permitted, and indicates that the cause is nonexistent or unknown.) 066 */ 067 DownloadException(String message, Throwable cause) { 068 super(message, cause); 069 } 070 } 071 072 private boolean canceled; 073 private HttpClient downloadConnection; 074 075 private synchronized void closeConnectionIfNeeded() { 076 if (downloadConnection != null) { 077 downloadConnection.disconnect(); 078 } 079 downloadConnection = null; 080 } 081 082 @Override 083 protected void cancel() { 084 this.canceled = true; 085 closeConnectionIfNeeded(); 086 } 087 088 @Override 089 protected void finish() { 090 // Do nothing 091 } 092 093 /** 094 * Performs download. 095 * @throws DownloadException if the URL is invalid or if any I/O error occurs. 096 */ 097 public void download() throws DownloadException { 098 try { 099 if (mkdir) { 100 File newDir = file.getParentFile(); 101 if (!newDir.exists()) { 102 Utils.mkDirs(newDir); 103 } 104 } 105 106 URL url = new URL(address); 107 long size; 108 synchronized (this) { 109 downloadConnection = HttpClient.create(url).useCache(false); 110 downloadConnection.connect(); 111 size = downloadConnection.getResponse().getContentLength(); 112 } 113 114 progressMonitor.setTicksCount(100); 115 progressMonitor.subTask(tr("Downloading File {0}: {1} bytes...", file.getName(), size)); 116 117 try ( 118 InputStream in = downloadConnection.getResponse().getContent(); 119 OutputStream out = Files.newOutputStream(file.toPath()) 120 ) { 121 byte[] buffer = new byte[32_768]; 122 int count = 0; 123 long p1 = 0; 124 long p2; 125 for (int read = in.read(buffer); read != -1; read = in.read(buffer)) { 126 out.write(buffer, 0, read); 127 count += read; 128 if (canceled) break; 129 p2 = 100L * count / size; 130 if (p2 != p1) { 131 progressMonitor.setTicks((int) p2); 132 p1 = p2; 133 } 134 } 135 } 136 if (!canceled) { 137 Logging.info(tr("Download finished")); 138 if (unpack) { 139 Logging.info(tr("Unpacking {0} into {1}", file.getAbsolutePath(), file.getParent())); 140 unzipFileRecursively(file, file.getParent()); 141 Utils.deleteFile(file); 142 } 143 } 144 } catch (MalformedURLException e) { 145 String msg = tr("Cannot download file ''{0}''. Its download link ''{1}'' is not a valid URL. Skipping download.", 146 file.getName(), address); 147 Logging.warn(msg); 148 throw new DownloadException(msg, e); 149 } catch (IOException | InvalidPathException e) { 150 if (canceled) 151 return; 152 throw new DownloadException(e.getMessage(), e); 153 } finally { 154 closeConnectionIfNeeded(); 155 } 156 } 157 158 @Override 159 protected void realRun() throws SAXException, IOException { 160 if (canceled) return; 161 try { 162 download(); 163 } catch (DownloadException e) { 164 Logging.error(e); 165 } 166 } 167 168 /** 169 * Replies true if the task was canceled by the user 170 * 171 * @return {@code true} if the task was canceled by the user, {@code false} otherwise 172 */ 173 public boolean isCanceled() { 174 return canceled; 175 } 176 177 /** 178 * Recursive unzipping function 179 * TODO: May be placed somewhere else - Tools.Utils? 180 * @param file zip file 181 * @param dir output directory 182 * @throws IOException if any I/O error occurs 183 */ 184 public static void unzipFileRecursively(File file, String dir) throws IOException { 185 Path dirPath = Paths.get(dir); 186 try (ZipFile zf = new ZipFile(file, StandardCharsets.UTF_8)) { 187 Enumeration<? extends ZipEntry> es = zf.entries(); 188 while (es.hasMoreElements()) { 189 ZipEntry ze = es.nextElement(); 190 File newFile = new File(dir, ze.getName()); 191 // Checks for Zip Slip Vulnerability (CWE-22 / path traversal) 192 if (!newFile.toPath().normalize().startsWith(dirPath)) { 193 throw new IOException("Bad zip entry - Invalid or malicious file, potential CWE-22 attack"); 194 } 195 if (ze.isDirectory()) { 196 Utils.mkDirs(newFile); 197 } else try (InputStream is = zf.getInputStream(ze)) { 198 Files.copy(is, newFile.toPath(), StandardCopyOption.REPLACE_EXISTING); 199 } 200 } 201 } 202 } 203}