1package eu.siacs.conversations.http;
  2
  3import static eu.siacs.conversations.http.HttpConnectionManager.EXECUTOR;
  4
  5import android.util.Log;
  6import androidx.annotation.Nullable;
  7import androidx.core.util.Consumer;
  8
  9import com.google.common.base.Strings;
 10import com.google.common.io.ByteStreams;
 11import com.google.common.primitives.Longs;
 12import eu.siacs.conversations.Config;
 13import eu.siacs.conversations.R;
 14import eu.siacs.conversations.entities.DownloadableFile;
 15import eu.siacs.conversations.entities.Message;
 16import eu.siacs.conversations.entities.Transferable;
 17import eu.siacs.conversations.persistance.FileBackend;
 18import eu.siacs.conversations.services.AbstractConnectionManager;
 19import eu.siacs.conversations.services.XmppConnectionService;
 20import eu.siacs.conversations.utils.CryptoHelper;
 21import eu.siacs.conversations.utils.FileWriterException;
 22import eu.siacs.conversations.utils.MimeUtils;
 23import java.io.FileInputStream;
 24import java.io.FileOutputStream;
 25import java.io.IOException;
 26import java.io.InputStream;
 27import java.io.OutputStream;
 28import java.util.Locale;
 29import javax.net.ssl.SSLHandshakeException;
 30import okhttp3.Call;
 31import okhttp3.HttpUrl;
 32import okhttp3.OkHttpClient;
 33import okhttp3.Request;
 34import okhttp3.Response;
 35import org.bouncycastle.crypto.engines.AESEngine;
 36import org.bouncycastle.crypto.io.CipherOutputStream;
 37import org.bouncycastle.crypto.modes.GCMBlockCipher;
 38import org.bouncycastle.crypto.params.AEADParameters;
 39import org.bouncycastle.crypto.params.KeyParameter;
 40
 41public class HttpDownloadConnection implements Transferable {
 42
 43    private final Message message;
 44    private final HttpConnectionManager mHttpConnectionManager;
 45    private final XmppConnectionService mXmppConnectionService;
 46    private HttpUrl mUrl;
 47    private DownloadableFile file;
 48    private int mStatus = Transferable.STATUS_UNKNOWN;
 49    private boolean acceptedAutomatically = false;
 50    private int mProgress = 0;
 51    private Call mostRecentCall;
 52    final private Consumer<DownloadableFile> cb;
 53
 54    HttpDownloadConnection(Message message, HttpConnectionManager manager, Consumer<DownloadableFile> cb) {
 55        this.message = message;
 56        this.mHttpConnectionManager = manager;
 57        this.mXmppConnectionService = manager.getXmppConnectionService();
 58        this.cb = cb;
 59    }
 60
 61    @Override
 62    public boolean start() {
 63        if (mXmppConnectionService.hasInternetConnection()) {
 64            if (this.mStatus == STATUS_OFFER_CHECK_FILESIZE) {
 65                checkFileSize(true);
 66            } else {
 67                download(true);
 68            }
 69            return true;
 70        } else {
 71            return false;
 72        }
 73    }
 74
 75    public void init(boolean interactive) {
 76        final Message.FileParams fileParams = message.getFileParams();
 77        if (message.isDeleted()) {
 78            if (message.getType() == Message.TYPE_PRIVATE_FILE) {
 79                message.setType(Message.TYPE_PRIVATE);
 80            } else if (message.isFileOrImage()) {
 81                message.setType(Message.TYPE_TEXT);
 82            }
 83            message.setDeleted(false);
 84            mXmppConnectionService.updateMessage(message);
 85        }
 86        this.message.setTransferable(this);
 87        try {
 88            if (message.hasFileOnRemoteHost()) {
 89                mUrl = AesGcmURL.of(fileParams.url);
 90            } else if (message.isOOb() && fileParams.url != null) {
 91                mUrl = AesGcmURL.of(fileParams.url);
 92            } else {
 93                mUrl = AesGcmURL.of(message.getRawBody().split("\n")[0]);
 94            }
 95            final AbstractConnectionManager.Extension extension =
 96                    AbstractConnectionManager.Extension.of(mUrl.encodedPath());
 97            if (VALID_CRYPTO_EXTENSIONS.contains(extension.main)) {
 98                this.message.setEncryption(Message.ENCRYPTION_PGP);
 99            } else if (message.getEncryption() != Message.ENCRYPTION_AXOLOTL) {
100                this.message.setEncryption(Message.ENCRYPTION_NONE);
101            }
102            String ext = extension.getExtension();
103            if (ext == null && fileParams.getMediaType() != null) {
104                ext = MimeUtils.guessExtensionFromMimeType(fileParams.getMediaType());
105            }
106            final String filename =
107                    Strings.isNullOrEmpty(ext)
108                            ? message.getUuid()
109                            : String.format("%s.%s", message.getUuid(), ext);
110            mXmppConnectionService.getFileBackend().setupRelativeFilePath(message, filename);
111            setupFile();
112            if (this.message.getEncryption() == Message.ENCRYPTION_AXOLOTL
113                    && this.file.getKey() == null) {
114                this.message.setEncryption(Message.ENCRYPTION_NONE);
115            }
116            final Long knownFileSize;
117            if (message.getEncryption() == Message.ENCRYPTION_PGP
118                    || message.getEncryption() == Message.ENCRYPTION_DECRYPTED) {
119                knownFileSize = null;
120            } else {
121                knownFileSize = message.getFileParams().size;
122            }
123            if (knownFileSize != null && interactive) {
124                if (message.getEncryption() == Message.ENCRYPTION_AXOLOTL
125                        && this.file.getKey() != null) {
126                    this.file.setExpectedSize(knownFileSize + GCM_AUTHENTICATION_TAG_LENGTH);
127                } else {
128                    this.file.setExpectedSize(knownFileSize);
129                }
130                download(true);
131            } else {
132                checkFileSize(interactive);
133            }
134        } catch (final IllegalArgumentException e) {
135            this.cancel();
136        }
137    }
138
139    private void setupFile() {
140        final String reference = mUrl.fragment();
141        if (reference != null && AesGcmURL.IV_KEY.matcher(reference).matches()) {
142            this.file =
143                    new DownloadableFile(mXmppConnectionService.getCacheDir(), message.getUuid());
144            this.file.setKeyAndIv(CryptoHelper.hexToBytes(reference));
145            Log.d(
146                    Config.LOGTAG,
147                    "create temporary OMEMO encrypted file: "
148                            + this.file.getAbsolutePath()
149                            + "("
150                            + message.getMimeType()
151                            + ")");
152        } else {
153            this.file = mXmppConnectionService.getFileBackend().getFile(message, false);
154        }
155    }
156
157    private void download(final boolean interactive) {
158        EXECUTOR.execute(new FileDownloader(interactive));
159    }
160
161    private void checkFileSize(final boolean interactive) {
162        EXECUTOR.execute(new FileSizeChecker(interactive));
163    }
164
165    @Override
166    public void cancel() {
167        final Call call = this.mostRecentCall;
168        if (call != null && !call.isCanceled()) {
169            call.cancel();
170        }
171        mHttpConnectionManager.finishConnection(this);
172        message.setTransferable(null);
173        if (message.isFileOrImage()) {
174            message.setDeleted(true);
175        }
176        mHttpConnectionManager.updateConversationUi(true);
177    }
178
179    private void decryptFile() throws IOException {
180        final DownloadableFile outputFile =
181                mXmppConnectionService.getFileBackend().getFile(message, true);
182
183        final var directory = outputFile.getParentFile();
184        if (directory != null && directory.mkdirs()) {
185            Log.d(Config.LOGTAG, "created parent directories for " + outputFile.getAbsolutePath());
186        }
187
188        if (!outputFile.createNewFile()) {
189            Log.w(Config.LOGTAG, "unable to create output file " + outputFile.getAbsolutePath());
190        }
191        final var cipher = GCMBlockCipher.newInstance(AESEngine.newInstance());
192        cipher.init(
193                false, new AEADParameters(new KeyParameter(this.file.getKey()), 128, file.getIv()));
194        try (final InputStream is = new FileInputStream(this.file);
195                final CipherOutputStream outputStream =
196                        new CipherOutputStream(new FileOutputStream(outputFile), cipher)) {
197            ByteStreams.copy(is, outputStream);
198        }
199
200        if (file.delete()) {
201            Log.w(
202                    Config.LOGTAG,
203                    "deleted temporary OMEMO encrypted file " + file.getAbsolutePath());
204        }
205
206        file = outputFile;
207    }
208
209    private void finish() {
210        boolean notify = acceptedAutomatically && !message.isRead() && cb == null;
211        if (message.getEncryption() == Message.ENCRYPTION_PGP) {
212            notify =
213                    message.getConversation()
214                            .getAccount()
215                            .getPgpDecryptionService()
216                            .decrypt(message, notify);
217        }
218        final DownloadableFile tmp = file;
219        final String extension = MimeUtils.extractRelevantExtension(tmp.getName());
220        try {
221            mXmppConnectionService.getFileBackend().setupRelativeFilePath(message, new FileInputStream(tmp), extension);
222            file = mXmppConnectionService.getFileBackend().getFile(message);
223            boolean didRename = tmp.renameTo(file);
224            if (!didRename) throw new IOException("rename failed");
225        } catch (final IOException e) {
226            Log.w(Config.LOGTAG, "Failed to rename downloaded file: " + e);
227            file = tmp;
228            message.setRelativeFilePath(file.getAbsolutePath());
229        } catch (final XmppConnectionService.BlockedMediaException e) {
230            file = tmp;
231            tmp.delete();
232            message.setDeleted(true);
233        }
234        message.setTransferable(null);
235        mXmppConnectionService.updateMessage(message);
236        mHttpConnectionManager.finishConnection(this);
237        final boolean notifyAfterScan = notify;
238        final DownloadableFile file =
239                mXmppConnectionService.getFileBackend().getFile(message, true);
240        mXmppConnectionService
241                .getFileBackend()
242                .updateMediaScanner(
243                        file,
244                        () -> {
245                            if (notifyAfterScan) {
246                                mXmppConnectionService.getNotificationService().push(message);
247                            }
248                        });
249    }
250
251    private void decryptIfNeeded() throws IOException {
252        if (file.getKey() != null && file.getIv() != null) {
253            decryptFile();
254        }
255    }
256
257    private void changeStatus(int status) {
258        this.mStatus = status;
259        mHttpConnectionManager.updateConversationUi(true);
260    }
261
262    private void showToastForException(final Exception e) {
263        final Call call = mostRecentCall;
264        final boolean cancelled = call != null && call.isCanceled();
265        if (e == null || cancelled) {
266            return;
267        }
268        if (e instanceof java.net.UnknownHostException) {
269            mXmppConnectionService.showErrorToastInUi(R.string.download_failed_server_not_found);
270        } else if (e instanceof java.net.ConnectException) {
271            mXmppConnectionService.showErrorToastInUi(R.string.download_failed_could_not_connect);
272        } else if (e instanceof FileWriterException) {
273            mXmppConnectionService.showErrorToastInUi(
274                    R.string.download_failed_could_not_write_file);
275        } else if (e instanceof InvalidFileException) {
276            mXmppConnectionService.showErrorToastInUi(R.string.download_failed_invalid_file);
277        } else {
278            mXmppConnectionService.showErrorToastInUi(R.string.download_failed_file_not_found);
279        }
280    }
281
282    private void updateProgress(long i) {
283        this.mProgress = (int) i;
284        mHttpConnectionManager.updateConversationUi(false);
285    }
286
287    @Override
288    public int getStatus() {
289        return this.mStatus;
290    }
291
292    @Override
293    public Long getFileSize() {
294        if (this.file != null) {
295            return this.file.getExpectedSize();
296        } else {
297            return null;
298        }
299    }
300
301    @Override
302    public int getProgress() {
303        return this.mProgress;
304    }
305
306    public Message getMessage() {
307        return message;
308    }
309
310    private class FileSizeChecker implements Runnable {
311
312        private final boolean interactive;
313
314        FileSizeChecker(boolean interactive) {
315            this.interactive = interactive;
316        }
317
318        @Override
319        public void run() {
320            check();
321        }
322
323        private void retrieveFailed(@Nullable final Exception e) {
324            changeStatus(STATUS_OFFER_CHECK_FILESIZE);
325            if (interactive) {
326                showToastForException(e);
327            } else {
328                HttpDownloadConnection.this.acceptedAutomatically = false;
329                HttpDownloadConnection.this
330                        .mXmppConnectionService
331                        .getNotificationService()
332                        .push(message);
333            }
334            cancel();
335        }
336
337        private void check() {
338            long size;
339            try {
340                size = retrieveFileSize();
341            } catch (final Exception e) {
342                Log.d(Config.LOGTAG, "could not retrieve file size", e);
343                retrieveFailed(e);
344                return;
345            }
346            final Message.FileParams fileParams = message.getFileParams();
347            FileBackend.updateFileParams(message, fileParams.url, size);
348            mXmppConnectionService.databaseBackend.updateMessage(message, true);
349            file.setExpectedSize(size);
350            message.setFileParams(null);
351            if (mHttpConnectionManager.hasStoragePermission()
352                    && size <= mHttpConnectionManager.getAutoAcceptFileSize()
353                    && mXmppConnectionService.isDataSaverDisabled()) {
354                HttpDownloadConnection.this.acceptedAutomatically = true;
355                download(interactive);
356            } else {
357                changeStatus(STATUS_OFFER);
358                HttpDownloadConnection.this.acceptedAutomatically = false;
359                if (cb == null) {
360                    HttpDownloadConnection.this.mXmppConnectionService.getNotificationService().push(message);
361                } else {
362                    cb.accept(null);
363                }
364            }
365        }
366
367        private long retrieveFileSize() throws IOException {
368            Log.d(Config.LOGTAG, "retrieve file size. interactive:" + interactive);
369            changeStatus(STATUS_CHECKING);
370            final OkHttpClient client =
371                    mHttpConnectionManager.buildHttpClient(
372                            mUrl, message.getConversation().getAccount(), interactive);
373            final Request request =
374                    new Request.Builder()
375                            .url(URL.stripFragment(mUrl))
376                            .addHeader("Accept-Encoding", "identity")
377                            .head()
378                            .build();
379            mostRecentCall = client.newCall(request);
380            try (final Response response = mostRecentCall.execute()) {
381                throwOnInvalidCode(response);
382                final String contentLength = response.header("Content-Length");
383                final String contentType = response.header("Content-Type");
384                final AbstractConnectionManager.Extension extension =
385                        AbstractConnectionManager.Extension.of(mUrl.encodedPath());
386                if (Strings.isNullOrEmpty(extension.getExtension()) && contentType != null) {
387                    final String fileExtension = MimeUtils.guessExtensionFromMimeType(contentType);
388                    if (fileExtension != null) {
389                        mXmppConnectionService
390                                .getFileBackend()
391                                .setupRelativeFilePath(
392                                        message,
393                                        String.format("%s.%s", message.getUuid(), fileExtension),
394                                        contentType);
395                        Log.d(
396                                Config.LOGTAG,
397                                "rewriting name after not finding extension in url but in content"
398                                        + " type");
399                        setupFile();
400                    }
401                }
402                final Long size = Longs.tryParse(Strings.nullToEmpty(contentLength));
403                if (size == null || size < 0) {
404                    throw new IOException("no content-length found in HEAD response");
405                }
406                return size;
407            }
408        }
409    }
410
411    private void persistFileSize(final long size) {
412        final Message.FileParams fileParams = message.getFileParams();
413        if (message.getEncryption() == Message.ENCRYPTION_AXOLOTL && file.getKey() != null) {
414            // store the file size of the clear text file. If we resume the download we will add the
415            // auth tag size again
416            // this is equivalent to use updating file params *after* download (which would take the
417            // clear text size as well)
418            FileBackend.updateFileParams(
419                    message, fileParams.url, size - GCM_AUTHENTICATION_TAG_LENGTH);
420        } else {
421            FileBackend.updateFileParams(message, fileParams.url, size);
422        }
423    }
424
425    private class FileDownloader implements Runnable {
426
427        private final boolean interactive;
428
429        public FileDownloader(boolean interactive) {
430            this.interactive = interactive;
431        }
432
433        @Override
434        public void run() {
435            try {
436                changeStatus(STATUS_DOWNLOADING);
437                download();
438                decryptIfNeeded();
439                finish();
440                updateImageBounds();
441            } catch (final SSLHandshakeException e) {
442                changeStatus(STATUS_OFFER);
443            } catch (final Exception e) {
444                Log.d(
445                        Config.LOGTAG,
446                        message.getConversation().getAccount().getJid().asBareJid()
447                                + ": unable to download file",
448                        e);
449                if (interactive) {
450                    showToastForException(e);
451                } else {
452                    HttpDownloadConnection.this.acceptedAutomatically = false;
453                    HttpDownloadConnection.this
454                            .mXmppConnectionService
455                            .getNotificationService()
456                            .push(message);
457                }
458                cancel();
459            } finally {
460                if (cb != null) cb.accept(file);
461            }
462        }
463
464        private void download() throws Exception {
465            final long expected = file.getExpectedSize();
466            final var fileExists = file.exists();
467            final var existingFileSize = fileExists ? file.length() : -1L;
468
469            if (fileExists) {
470                if (expected > 0 && existingFileSize == expected) {
471                    Log.d(Config.LOGTAG, "file already exits (presumably decryption failure)");
472                    return;
473                }
474            }
475            final OkHttpClient client =
476                    mHttpConnectionManager.buildHttpClient(
477                            mUrl, message.getConversation().getAccount(), interactive);
478
479            final Request.Builder requestBuilder =
480                    new Request.Builder().url(URL.stripFragment(mUrl));
481
482            final boolean tryResume =
483                    fileExists && existingFileSize > 0 && existingFileSize < expected;
484            final long resumeSize;
485            if (tryResume) {
486                resumeSize = existingFileSize;
487                Log.d(
488                        Config.LOGTAG,
489                        "http download trying resume after " + resumeSize + " of " + expected);
490                requestBuilder.addHeader(
491                        "Range", String.format(Locale.ENGLISH, "bytes=%d-", resumeSize));
492            } else {
493                resumeSize = 0;
494            }
495            final Request request = requestBuilder.build();
496            mostRecentCall = client.newCall(request);
497            try (final Response response = mostRecentCall.execute()) {
498                throwOnInvalidCode(response);
499                final String contentRange = response.header("Content-Range");
500                final boolean serverResumed =
501                        tryResume
502                                && contentRange != null
503                                && contentRange.startsWith("bytes " + resumeSize + "-");
504                final var body = response.body();
505                if (body == null) {
506                    throw new IOException("response body was null");
507                }
508                final InputStream inputStream = body.byteStream();
509                if (tryResume && serverResumed) {
510                    Log.d(Config.LOGTAG, "server resumed");
511                    final var offset = file.getSize();
512                    try (final OutputStream os = new FileOutputStream(file, true)) {
513                        copy(inputStream, os, offset, expected);
514                    }
515                } else {
516                    final String contentLength = response.header("Content-Length");
517                    final Long size = Longs.tryParse(Strings.nullToEmpty(contentLength));
518                    if (size == null) {
519                        Log.d(Config.LOGTAG, "no content-length in GET response (probably gzip)");
520                    } else {
521                        if (expected != size) {
522                            if (expected == 0) {
523                                // this means we got 0 (unknown) on HEAD. We won't download the file
524                                // but we update the file size so the user can try it again now that
525                                // the actual file size is known
526                                persistFileSize(size);
527                            }
528                            throw new IOException(
529                                    "Content-Length in GET response did not match expected size");
530                        }
531                    }
532                    final var directory = file.getParentFile();
533                    if (directory != null && directory.mkdirs()) {
534                        Log.d(Config.LOGTAG, "create directory " + directory.getAbsolutePath());
535                    }
536                    Log.d(Config.LOGTAG, "creating file: " + file.getAbsolutePath());
537                    if (!file.exists() && !file.createNewFile()) {
538                        throw new FileWriterException(file);
539                    }
540                    try (final OutputStream os = new FileOutputStream(file)) {
541                        copy(inputStream, os, 0, expected);
542                    }
543                }
544            }
545        }
546
547        private void copy(
548                final InputStream inputStream,
549                final OutputStream outputStream,
550                final long offset,
551                final long expected)
552                throws IOException, FileWriterException {
553            long transmitted = offset;
554            int count;
555            final byte[] buffer = new byte[4096];
556            updateProgress(Math.round(((double) transmitted / expected) * 100));
557            while ((count = inputStream.read(buffer)) != -1) {
558                transmitted += count;
559                try {
560                    outputStream.write(buffer, 0, count);
561                } catch (final IOException e) {
562                    throw new FileWriterException(file);
563                }
564                if (transmitted > expected) {
565                    throw new InvalidFileException(
566                            String.format("File exceeds expected size of %d", expected));
567                }
568                updateProgress(Math.round(((double) transmitted / expected) * 100));
569            }
570        }
571
572        private void updateImageBounds() {
573            final boolean privateMessage = message.isPrivateMessage();
574            message.setType(privateMessage ? Message.TYPE_PRIVATE_FILE : Message.TYPE_FILE);
575            final String url;
576            final String ref = mUrl.fragment();
577            if (ref != null && AesGcmURL.IV_KEY.matcher(ref).matches()) {
578                url = AesGcmURL.toAesGcmUrl(mUrl);
579            } else {
580                url = mUrl.toString();
581            }
582            mXmppConnectionService.getFileBackend().updateFileParams(message, url);
583            mXmppConnectionService.updateMessage(message);
584        }
585    }
586
587    private static void throwOnInvalidCode(final Response response) throws IOException {
588        final int code = response.code();
589        if (code < 200 || code >= 300) {
590            throw new IOException(String.format(Locale.ENGLISH, "HTTP Status code was %d", code));
591        }
592    }
593
594    private static class InvalidFileException extends IOException {
595
596        private InvalidFileException(final String message) {
597            super(message);
598        }
599    }
600}