/* * Syncany, www.syncany.org * Copyright (C) 2011-2014 Philipp C. Heckel <philipp.heckel@gmail.com> * * 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.syncany.plugins.dropbox; import java.io.ByteArrayInputStream; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.net.URI; import java.nio.file.Paths; import java.util.Map; import java.util.logging.Level; import java.util.logging.Logger; import org.apache.commons.io.FileUtils; import org.syncany.config.Config; import org.syncany.plugins.transfer.AbstractTransferManager; import org.syncany.plugins.transfer.FileType; import org.syncany.plugins.transfer.StorageException; import org.syncany.plugins.transfer.StorageMoveException; import org.syncany.plugins.transfer.TransferManager; import org.syncany.plugins.transfer.features.PathAware; import org.syncany.plugins.transfer.features.PathAwareFeatureExtension; import org.syncany.plugins.transfer.features.PathAwareFeatureTransferManager.PathAwareRemoteFileAttributes; import org.syncany.plugins.transfer.files.ActionRemoteFile; import org.syncany.plugins.transfer.files.CleanupRemoteFile; import org.syncany.plugins.transfer.files.DatabaseRemoteFile; import org.syncany.plugins.transfer.files.MultichunkRemoteFile; import org.syncany.plugins.transfer.files.RemoteFile; import org.syncany.plugins.transfer.files.SyncanyRemoteFile; import org.syncany.plugins.transfer.files.TempRemoteFile; import org.syncany.plugins.transfer.files.TransactionRemoteFile; import org.syncany.util.FileUtil; import com.dropbox.core.DbxClient; import com.dropbox.core.DbxEntry; import com.dropbox.core.DbxException; import com.dropbox.core.DbxException.BadResponseCode; import com.dropbox.core.DbxWriteMode; import com.google.common.collect.Maps; /** * Implements a {@link TransferManager} based on an Dropbox storage backend for the * {@link DropboxTransferPlugin}. * * <p>Using an {@link DropboxTransferSettings}, the transfer manager is configured and uses * a well defined Samba share and folder to store the Syncany repository data. While repo and * master file are stored in the given folder, databases and multichunks are stored * in special sub-folders: * * <ul> * <li>The <tt>databases</tt> folder keeps all the {@link DatabaseRemoteFile}s</li> * <li>The <tt>multichunks</tt> folder keeps the actual data within the {@link MultiChunkRemoteFile}s</li> * </ul> * * <p>All operations are auto-connected, i.e. a connection is automatically * established. * * @author Christian Roth <christian.roth@port17.de> */ @PathAware(extension = DropboxTransferManager.DropboxTransferManagerFeatureExtension.class) public class DropboxTransferManager extends AbstractTransferManager { private static final Logger logger = Logger.getLogger(DropboxTransferManager.class.getSimpleName()); private final DbxClient client; private final URI path; private final URI multichunksPath; private final URI databasesPath; private final URI actionsPath; private final URI transactionsPath; private final URI tempPath; public DropboxTransferManager(DropboxTransferSettings settings, Config config) { super(settings, config); this.path = UriBuilder.fromRoot("/").toChild(settings.getPath()).build(); this.multichunksPath = UriBuilder.fromRoot("/").toChild(settings.getPath()).toChild("multichunks").build(); this.databasesPath = UriBuilder.fromRoot("/").toChild(settings.getPath()).toChild("databases").build(); this.actionsPath = UriBuilder.fromRoot("/").toChild(settings.getPath()).toChild("actions").build(); this.transactionsPath = UriBuilder.fromRoot("/").toChild(settings.getPath()).toChild("transactions").build(); this.tempPath = UriBuilder.fromRoot("/").toChild(settings.getPath()).toChild("temporary").build(); this.client = new DbxClient(DropboxTransferPlugin.DROPBOX_REQ_CONFIG, settings.getAccessToken()); } @Override public void connect() throws StorageException { // make a connect try { logger.log(Level.INFO, "Using dropbox account from {0}", new Object[]{client.getAccountInfo().displayName}); } catch (DbxException.InvalidAccessToken e) { throw new StorageException("The accessToken in use is invalid", e); } catch (Exception e) { throw new StorageException("Unable to connect to dropbox", e); } } @Override public void disconnect() { // Nothing } @Override public void init(boolean createIfRequired) throws StorageException { connect(); try { if (!testTargetExists() && createIfRequired) { client.createFolder(path.toString()); } client.createFolder(multichunksPath.toString()); client.createFolder(databasesPath.toString()); client.createFolder(actionsPath.toString()); client.createFolder(transactionsPath.toString()); client.createFolder(tempPath.toString()); } catch (DbxException e) { throw new StorageException("init: Cannot create required directories", e); } finally { disconnect(); } } @Override public void download(RemoteFile remoteFile, File localFile) throws StorageException { String remotePath = getRemoteFile(remoteFile); if (!remoteFile.getName().equals(".") && !remoteFile.getName().equals("..")) { try { // Download file File tempFile = createTempFile(localFile.getName()); OutputStream tempFOS = new FileOutputStream(tempFile); if (logger.isLoggable(Level.INFO)) { logger.log(Level.INFO, "Dropbox: Downloading {0} to temp file {1}", new Object[]{remotePath, tempFile}); } client.getFile(remotePath, null, tempFOS); tempFOS.close(); // Move file if (logger.isLoggable(Level.INFO)) { logger.log(Level.INFO, "Dropbox: Renaming temp file {0} to file {1}", new Object[]{tempFile, localFile}); } localFile.delete(); FileUtils.moveFile(tempFile, localFile); tempFile.delete(); } catch (DbxException | IOException ex) { logger.log(Level.SEVERE, "Error while downloading file " + remoteFile.getName(), ex); throw new StorageException(ex); } } } @Override public void upload(File localFile, RemoteFile remoteFile) throws StorageException { String remotePath = getRemoteFile(remoteFile); String tempRemotePath = path + "/temp-" + remoteFile.getName(); try { // Upload to temp file InputStream fileFIS = new FileInputStream(localFile); if (logger.isLoggable(Level.INFO)) { logger.log(Level.INFO, "Dropbox: Uploading {0} to temp file {1}", new Object[]{localFile, tempRemotePath}); } client.uploadFile(tempRemotePath, DbxWriteMode.add(), localFile.length(), fileFIS); fileFIS.close(); // Move if (logger.isLoggable(Level.INFO)) { logger.log(Level.INFO, "Dropbox: Renaming temp file {0} to file {1}", new Object[]{tempRemotePath, remotePath}); } client.move(tempRemotePath, remotePath); } catch (DbxException | IOException ex) { logger.log(Level.SEVERE, "Could not upload file " + localFile + " to " + remoteFile.getName(), ex); throw new StorageException(ex); } } @Override public boolean delete(RemoteFile remoteFile) throws StorageException { String remotePath = getRemoteFile(remoteFile); try { client.delete(remotePath); return true; } catch (BadResponseCode e) { if (e.statusCode == 404) { logger.log(Level.INFO, "File does not exist. Doing nothing: " + remoteFile.getName(), e); return true; } else { logger.log(Level.SEVERE, "Could not delete file " + remoteFile.getName(), e); throw new StorageException(e); } } catch (DbxException e) { logger.log(Level.SEVERE, "Could not delete file " + remoteFile.getName(), e); throw new StorageException(e); } } @Override public void move(RemoteFile sourceFile, RemoteFile targetFile) throws StorageException { String sourceRemotePath = getRemoteFile(sourceFile); String targetRemotePath = getRemoteFile(targetFile); try { client.move(sourceRemotePath, targetRemotePath); } catch (DbxException e) { logger.log(Level.SEVERE, "Could not rename file " + sourceRemotePath + " to " + targetRemotePath, e); throw new StorageMoveException("Could not rename file " + sourceRemotePath + " to " + targetRemotePath, e); } } @Override public <T extends RemoteFile> Map<String, T> list(Class<T> remoteFileClass) throws StorageException { // TransferManager.list(Class<T> remoteFileClass) has been superseded by PathAwareFeatureExtension.list(String path) throw new UnsupportedOperationException("Extension is path aware! Hence, TransferManager.list(Class<T> remoteFileClass) has been superseded by PathAwareFeatureExtension.list(String path)"); } @Override public String getRemoteFilePath(Class<? extends RemoteFile> remoteFile) { if (remoteFile.equals(MultichunkRemoteFile.class)) { return multichunksPath.toString(); } else if (remoteFile.equals(DatabaseRemoteFile.class) || remoteFile.equals(CleanupRemoteFile.class)) { return databasesPath.toString(); } else if (remoteFile.equals(ActionRemoteFile.class)) { return actionsPath.toString(); } else if (remoteFile.equals(TransactionRemoteFile.class)) { return transactionsPath.toString(); } else if (remoteFile.equals(TempRemoteFile.class)) { return tempPath.toString(); } else { return path.toString(); } } private String getRemoteFile(RemoteFile remoteFile) { String rootPath = getRemoteFilePath(remoteFile.getClass()); String subfolder = ""; PathAwareRemoteFileAttributes attributes = remoteFile.getAttributes(PathAwareRemoteFileAttributes.class); boolean hasSubPath = attributes != null && attributes.hasPath(); if (hasSubPath) { subfolder = attributes.getPath(); } else { logger.log(Level.WARNING, "TransferManager is annotated with @PathAware but files do not possess path aware attributes"); } return Paths.get(rootPath, subfolder, remoteFile.getName()).toString(); } @Override public boolean testTargetCanWrite() { try { if (testTargetExists()) { String tempRemoteFile = path + "/syncany-write-test"; File tempFile = File.createTempFile("syncany-write-test", "tmp"); client.uploadFile(tempRemoteFile, DbxWriteMode.add(), 0, new ByteArrayInputStream(new byte[0])); client.delete(tempRemoteFile); tempFile.delete(); logger.log(Level.INFO, "testTargetCanWrite: Can write, test file created/deleted successfully."); return true; } else { logger.log(Level.INFO, "testTargetCanWrite: Can NOT write, target does not exist."); return false; } } catch (DbxException | IOException e) { logger.log(Level.INFO, "testTargetCanWrite: Can NOT write to target.", e); return false; } } @Override public boolean testTargetExists() { try { DbxEntry metadata = client.getMetadata(path.toString()); if (metadata != null && metadata.isFolder()) { logger.log(Level.INFO, "testTargetExists: Target does exist."); return true; } else { logger.log(Level.INFO, "testTargetExists: Target does NOT exist."); return false; } } catch (DbxException e) { logger.log(Level.WARNING, "testTargetExists: Target does NOT exist, error occurred.", e); return false; } } @Override public boolean testTargetCanCreate() { // Find parent path String repoPathNoSlash = FileUtil.removeTrailingSlash(path.toString()); int repoPathLastSlash = repoPathNoSlash.lastIndexOf("/"); String parentPath = (repoPathLastSlash > 0) ? repoPathNoSlash.substring(0, repoPathLastSlash) : "/"; // Test parent path permissions try { DbxEntry metadata = client.getMetadata(parentPath); // our app has read/write for EVERY folder inside a dropbox. as long as it exists, we can write in it if (metadata.isFolder()) { logger.log(Level.INFO, "testTargetCanCreate: Can create target at " + parentPath); return true; } else { logger.log(Level.INFO, "testTargetCanCreate: Can NOT create target (parent does not exist)"); return false; } } catch (DbxException e) { logger.log(Level.INFO, "testTargetCanCreate: Can NOT create target at " + parentPath, e); return false; } } @Override public boolean testRepoFileExists() { try { String repoFilePath = getRemoteFile(new SyncanyRemoteFile()); DbxEntry metadata = client.getMetadata(repoFilePath); if (metadata != null && metadata.isFile()) { logger.log(Level.INFO, "testRepoFileExists: Repo file exists at " + repoFilePath); return true; } else { logger.log(Level.INFO, "testRepoFileExists: Repo file DOES NOT exist at " + repoFilePath); return false; } } catch (Exception e) { logger.log(Level.INFO, "testRepoFileExists: Exception when trying to check repo file existence.", e); return false; } } public static class DropboxTransferManagerFeatureExtension implements PathAwareFeatureExtension { private final DropboxTransferManager transferManager; public DropboxTransferManagerFeatureExtension(DropboxTransferManager transferManager) { this.transferManager = transferManager; } @Override public boolean createPath(String path) throws StorageException { // Dropbox always creates a path structure implicitly. return true; } @Override public boolean removeFolder(String path) throws StorageException { logger.log(Level.FINE, "Deleting folder " + path); try { transferManager.client.delete(path); return true; } catch (DbxException e) { logger.log(Level.SEVERE, "Unable to delete remote path", e); return false; } } @Override public Map<String, FileType> listFolder(String path) throws StorageException { logger.log(Level.FINE, "Listing folder " + path); Map<String, FileType> contents = Maps.newHashMap(); try { DbxEntry.WithChildren listing = transferManager.client.getMetadataWithChildren(path); for (DbxEntry child : listing.children) { if (child.isFile()) { contents.put(child.name, FileType.FILE); } else if (child.isFolder()) { contents.put(child.name, FileType.FOLDER); } } } catch (DbxException e) { logger.log(Level.SEVERE, "Unable to list folder", e); throw new StorageException("Unable to list folder", e); } return contents; } } }