/*
* Kontalk Java client
* Copyright (C) 2016 Kontalk Devteam <devteam@kontalk.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.kontalk.system;
import static org.kontalk.util.MediaUtils.isImage;
import java.awt.Dimension;
import java.awt.Image;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.net.URI;
import java.net.URLEncoder;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.logging.Level;
import java.util.logging.Logger;
import org.apache.commons.io.FileUtils;
import org.apache.commons.lang.StringUtils;
import org.kontalk.client.Client;
import org.kontalk.client.HTTPFileClient;
import org.kontalk.crypto.Coder;
import org.kontalk.crypto.Coder.Encryption;
import org.kontalk.crypto.PGPUtils;
import org.kontalk.crypto.PersonalKey;
import org.kontalk.misc.KonException;
import org.kontalk.model.message.InMessage;
import org.kontalk.model.message.KonMessage;
import org.kontalk.model.message.MessageContent.Attachment;
import org.kontalk.model.message.MessageContent.InAttachment;
import org.kontalk.model.message.MessageContent.OutAttachment;
import org.kontalk.model.message.MessageContent.Preview;
import org.kontalk.model.message.OutMessage;
import org.kontalk.persistence.Config;
import org.kontalk.util.MediaUtils;
/**
* Up- and download service for attachment files.
*
* Also takes care of de- and encrypting attachments.
*
* @author Alexander Bikadorov {@literal <bikaejkb@mail.tu-berlin.de>}
*/
public class AttachmentManager implements Runnable {
private static final Logger LOGGER = Logger.getLogger(AttachmentManager.class.getName());
public static final String ATT_DIRNAME = "attachments";
public static final String PREVIEW_DIRNAME = "preview";
private static final String RESIZED_IMG_MIME = "image/jpeg";
private static final String THUMBNAIL_MIME = "image/jpeg";
public static final Dimension THUMBNAIL_DIM = new Dimension(300, 200);
public static final String ENCRYPT_PREFIX = "encrypted_";
public static final int MAX_ATT_SIZE = 20 * 1024 * 1024;
public static class Slot {
final URI uploadURL;
final URI downloadURL;
public Slot() {
this(URI.create(""), URI.create(""));
}
public Slot(URI uploadURI, URI downloadURL) {
this.uploadURL = uploadURI;
this.downloadURL = downloadURL;
}
}
//private static final String ENCRYPT_MIME = "application/octet-stream";
private final Control mControl;
private final Client mClient;
private final LinkedBlockingQueue<Task> mQueue = new LinkedBlockingQueue<>();
private final Path mAttachmentDir;
private final Path mPreviewDir;
private static class Task {
private Task() {}
static final class UploadTask extends Task {
final OutMessage message;
public UploadTask(OutMessage message) {
this.message = message;
}
}
static final class DownloadTask extends Task {
final InMessage message;
public DownloadTask(InMessage message) {
this.message = message;
}
}
}
private AttachmentManager(Control control, Client client, Path baseDir) {
mControl = control;
mClient = client;
mAttachmentDir = baseDir.resolve(ATT_DIRNAME);
if (mAttachmentDir.toFile().mkdir())
LOGGER.info("created attachment directory");
mPreviewDir = baseDir.resolve(PREVIEW_DIRNAME);
if (mPreviewDir.toFile().mkdir())
LOGGER.info("created preview directory");
}
static AttachmentManager create(Control control, Client client, Path appDir) {
AttachmentManager manager = new AttachmentManager(control, client, appDir);
Thread thread = new Thread(manager, "Attachment Transfer");
thread.setDaemon(true);
thread.start();
return manager;
}
void queueUpload(OutMessage message) {
boolean added = mQueue.offer(new Task.UploadTask(message));
if (!added) {
LOGGER.warning("can't add upload message to queue");
}
}
void queueDownload(InMessage message) {
boolean added = mQueue.offer(new Task.DownloadTask(message));
if (!added) {
LOGGER.warning("can't add download message to queue");
}
}
private void uploadAsync(OutMessage message) {
OutAttachment attachment = message.getContent().getOutAttachment().orElse(null);
if (attachment == null) {
LOGGER.warning("no attachment in message to upload");
return;
}
if (!mClient.isConnected()) {
LOGGER.info("can't upload, not connected");
return;
}
File original;
File file = original = attachment.getFilePath().toFile();
String uploadName;
try {
uploadName = URLEncoder.encode(file.getName(), "UTF-8");
} catch (UnsupportedEncodingException ex) {
LOGGER.log(Level.WARNING, "can't encode file name", ex);
return;
}
String mime = attachment.getMimeType();
// maybe resize image for smaller payload
if(isImage(mime)) {
int maxImgSize = Config.getInstance().getInt(Config.NET_MAX_IMG_SIZE);
if (maxImgSize > 0) {
BufferedImage img = MediaUtils.readImage(file).orElse(null);
if (img == null) {
LOGGER.warning("can't load image");
return;
}
if (img.getWidth() * img.getHeight() > maxImgSize) {
// image needs to be resized
BufferedImage resized = MediaUtils.scale(img, maxImgSize);
try {
file = File.createTempFile("kontalk_resized_img_att", ".dat");
} catch (IOException ex) {
LOGGER.log(Level.WARNING, "can't create temporary file", ex);
return;
}
mime = RESIZED_IMG_MIME;
boolean succ = MediaUtils.writeImage(resized,
MediaUtils.extensionForMIME(mime),
file);
if (!succ)
return;
}
}
}
// if text will be encrypted, always encrypt attachment too
boolean encrypt = message.getCoderStatus().getEncryption() == Encryption.DECRYPTED;
if (encrypt) {
PersonalKey myKey = mControl.myKey().orElse(null);
File encryptFile = myKey == null ?
null :
Coder.encryptAttachment(myKey, message, file).orElse(null);
if (!file.equals(original))
delete(file);
if (encryptFile == null)
return;
file = encryptFile;
// Note: continue using original MIME type, Android client needs it
//mime = ENCRYPT_MIME;
}
HTTPFileClient client = this.clientOrNull();
if (client == null)
return;
long length = file.length();
Slot uploadSlot = mClient.getUploadSlot(uploadName, length, mime);
if (uploadSlot.uploadURL.toString().isEmpty() ||
uploadSlot.downloadURL.toString().isEmpty()) {
LOGGER.warning("empty slot: "+attachment);
return;
}
try {
client.upload(file, uploadSlot.uploadURL, mime, encrypt);
} catch (KonException ex) {
LOGGER.warning("upload failed, attachment: "+attachment);
message.setStatus(KonMessage.Status.ERROR);
mControl.onException(ex);
return;
}
if (!file.equals(original))
delete(file);
attachment.setUploaded(uploadSlot.downloadURL, mime, length);
LOGGER.info("upload successful, URL="+uploadSlot.downloadURL);
// make sure not to loop
if (attachment.hasURL())
mControl.sendMessage(message);
}
private void downloadAsync(final InMessage message) {
InAttachment attachment = message.getContent().getInAttachment().orElse(null);
if (attachment == null) {
LOGGER.warning("no attachment in message to download");
return;
}
HTTPFileClient client = this.clientOrNull();
if (client == null)
return;
HTTPFileClient.ProgressListener listener = new HTTPFileClient.ProgressListener() {
@Override
public void updateProgress(int p) {
attachment.setDownloadProgress(p);
}
};
Path path;
try {
path = client.download(attachment.getURL(), mAttachmentDir, listener);
} catch (KonException ex) {
LOGGER.warning("download failed, URL="+attachment.getURL());
mControl.onException(ex);
return;
}
if (path.toString().isEmpty()) {
LOGGER.warning("file path is empty");
return;
}
boolean encrypted = PGPUtils.isEncryptedFile(path);
if (encrypted) {
path = MediaUtils.renameFile(path,
AttachmentManager.ENCRYPT_PREFIX + path.getFileName().toString());
}
LOGGER.info("successful, saved to file: "+path);
attachment.setFile(path.getFileName().toString(), encrypted);
if (encrypted) {
// decrypt file
mControl.myKey().ifPresent(mk ->
Coder.decryptAttachment(mk, attachment, message.getContact()));
}
// create preview if not in message
if (!message.getContent().getPreview().isPresent())
this.mayCreateImagePreview(message);
}
void savePreview(Preview preview, int messageID) {
this.writePreview(preview.getData(), messageID, preview.getMimeType());
}
void mayCreateImagePreview(KonMessage message) {
Attachment att = message.getContent().getAttachment().orElse(null);
if (att == null) {
LOGGER.warning("no attachment in message: "+message);
return;
}
Path path = att.getFilePath();
String mime = StringUtils.defaultIfEmpty(att.getMimeType(),
// guess from file
MediaUtils.mimeForFile(path));
if (!isImage(mime))
return;
BufferedImage image = MediaUtils.readImage(path);
// the attachment image could be smaller than the thumbnail - nobody cares
// if (image.getWidth() <= THUMBNAIL_DIM.width && image.getHeight() <= THUMBNAIL_DIM.height)
// return;
Image thumb = MediaUtils.scaleAsync(image,
THUMBNAIL_DIM.width ,
THUMBNAIL_DIM.height);
String format = MediaUtils.extensionForMIME(THUMBNAIL_MIME);
byte[] bytes = MediaUtils.imageToByteArray(thumb, format);
if (bytes.length <= 0)
return;
this.writePreview(bytes, message.getID(), THUMBNAIL_MIME);
Preview preview = new Preview(bytes, THUMBNAIL_MIME);
LOGGER.info("created: "+preview);
message.setPreview(preview);
}
Path getAttachmentDir() {
return mAttachmentDir;
}
private void writePreview(byte[] data, int messageID, String mimeType) {
String filename = previewFilename(messageID, mimeType);
File newFile = mPreviewDir.resolve(filename).toFile();
try {
FileUtils.writeByteArrayToFile(newFile, data);
} catch (IOException ex) {
LOGGER.log(Level.WARNING, "can't save preview file", ex);
return;
}
LOGGER.config("to file: "+newFile);
}
public static String previewFilename(int messageID, String mimeType) {
return Integer.toString(messageID) + "_bob." + MediaUtils.extensionForMIME(mimeType);
}
private HTTPFileClient clientOrNull(){
PersonalKey key = mControl.myKey().orElse(null);
if (key == null)
return null;
return new HTTPFileClient(key.getServerLoginKey(),
key.getBridgeCertificate(),
Config.getInstance().getBoolean(Config.SERV_CERT_VALIDATION));
}
@Override
public void run() {
while (true) {
Task t;
try {
// blocking
t = mQueue.take();
} catch (InterruptedException ex) {
LOGGER.log(Level.WARNING, "interrupted while waiting ", ex);
return;
}
if (t instanceof Task.UploadTask) {
this.uploadAsync(((Task.UploadTask) t).message);
} else if (t instanceof Task.DownloadTask) {
this.downloadAsync(((Task.DownloadTask) t).message);
}
}
}
/**
* Create a new attachment for a given file denoted by its path.
*/
static OutAttachment createAttachmentOrNull(Path path) {
if (!Files.isReadable(path)) {
LOGGER.warning("file not readable: "+path);
return null;
}
String mimeType = MediaUtils.mimeForFile(path);
if (mimeType.isEmpty()) {
LOGGER.warning("no mime type for file: "+path);
return null;
}
return new OutAttachment(path, mimeType);
}
private static void delete(File f) {
if (!f.delete()) {
LOGGER.warning("can not delete file: " + f);
}
}
}