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}