1# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4; encoding:utf8 -*- 2# 3# Copyright 2020 Jose L. Domingo Lopez <github@24x7linux.com> 4# 5# This file is part of duplicity. 6# 7# Duplicity is free software; you can redistribute it and/or modify it 8# under the terms of the GNU General Public License as published by the 9# Free Software Foundation; either version 2 of the License, or (at your 10# option) any later version. 11# 12# Duplicity is distributed in the hope that it will be useful, but 13# WITHOUT ANY WARRANTY; without even the implied warranty of 14# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 15# General Public License for more details. 16# 17# You should have received a copy of the GNU General Public License 18# along with duplicity; if not, write to the Free Software Foundation, 19# Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA 20 21from __future__ import print_function 22 23import os 24import re 25import subprocess 26 27import duplicity.backend 28from duplicity import util 29from duplicity.errors import BackendException 30from future import standard_library 31 32standard_library.install_aliases() 33 34 35class Megav3Backend(duplicity.backend.Backend): 36 u"""Backend for MEGA.nz cloud storage, only one that works for accounts created since Nov. 2018 37 See https://github.com/megous/megatools/issues/411 for more details 38 39 This MEGA backend resorts to official tools (MEGAcmd) as available at https://mega.nz/cmd 40 MEGAcmd works through a single binary called "mega-cmd", which keeps state (for example, 41 persisting a session). Multiple "mega-*" shell wrappers (ie. "mega-ls") exist as the user 42 interface to "mega-cmd" and MEGA API 43 The full MEGAcmd User Guide can be found in the software's GitHub page below : 44 https://github.com/meganz/MEGAcmd/blob/master/UserGuide.md""" 45 46 def __init__(self, parsed_url): 47 duplicity.backend.Backend.__init__(self, parsed_url) 48 49 # Sanity check : ensure all the necessary "MEGAcmd" binaries exist 50 self._check_binary_exists(u'mega-cmd') 51 self._check_binary_exists(u'mega-exec') 52 self._check_binary_exists(u'mega-help') 53 self._check_binary_exists(u'mega-get') 54 self._check_binary_exists(u'mega-login') 55 self._check_binary_exists(u'mega-logout') 56 self._check_binary_exists(u'mega-ls') 57 self._check_binary_exists(u'mega-mkdir') 58 self._check_binary_exists(u'mega-put') 59 self._check_binary_exists(u'mega-rm') 60 self._check_binary_exists(u'mega-whoami') 61 62 # "MEGAcmd" does not use a config file, however it is handy to keep one (with the old ".megarc" format) to 63 # securely store the username and password 64 self._hostname = parsed_url.hostname 65 if parsed_url.username is None: 66 self._megarc = os.getenv(u'HOME') + u'/.megav3rc' 67 try: 68 conf_file = open(self._megarc, u"r") 69 except Exception as e: 70 raise BackendException( 71 u"No password provided in URL and MEGA configuration " 72 u"file for duplicity does not exist as '%s'" 73 % (self._megarc,) 74 ) 75 76 myvars = {} 77 for line in conf_file: 78 name, var = line.partition(u"=")[::2] 79 myvars[name.strip()] = str(var.strip()) 80 conf_file.close() 81 self._username = myvars[u"Username"] 82 self._password = myvars[u"Password"] 83 84 else: 85 self._username = parsed_url.username 86 self._password = parsed_url.password 87 88 no_logout_option = parsed_url.query_args.get(u'no_logout', []) 89 self._no_logout = (len(no_logout_option) > 0) and ( 90 no_logout_option[0].lower() in [u'1', u'yes', u'true'] 91 ) 92 93 self.ensure_mega_cmd_running() 94 95 # Remote folder ("MEGAcmd" no longer shows "Root/" at the top of the hierarchy) 96 self._folder = u'/' + parsed_url.path[1:] 97 98 # Only create the remote folder if it doesn't exist yet 99 self.mega_login() 100 cmd = [u'mega-ls', self._folder] 101 try: 102 self.subprocess_popen(cmd) 103 except Exception as e: 104 self._makedir(self._folder) 105 106 def _check_binary_exists(self, cmd): 107 u'Checks that a specified command exists in the running user command path' 108 109 try: 110 # Ignore the output, as we only need the return code 111 subprocess.check_output([u'which', cmd]) 112 except Exception as e: 113 raise BackendException( 114 u"Command '%s' not found, make sure 'MEGAcmd' tools (https://mega.nz/cmd) is " 115 u"properly installed and in the running user command path" 116 % (cmd,) 117 ) 118 119 def ensure_mega_cmd_running(self): 120 u'Trigger any mega command to ensure mega-cmd server is running' 121 try: 122 subprocess.run( 123 u"mega-help", 124 stdout=subprocess.DEVNULL, 125 stderr=subprocess.DEVNULL, 126 ).check_returncode() 127 except Exception: 128 raise BackendException(u'Cannot execute mega command') 129 130 def _makedir(self, path): 131 u'Creates a remote directory (recursively if necessary)' 132 133 self.mega_login() 134 cmd = [u'mega-mkdir', u'-p', path] 135 try: 136 self.subprocess_popen(cmd) 137 except Exception as e: 138 error_str = str(e) 139 if u"Folder already exists" in error_str: 140 raise BackendException( 141 u"Folder '%s' could not be created on MEGA because it already exists. " 142 u"Use another path or remove the folder in MEGA manually" 143 % (path,) 144 ) 145 else: 146 raise BackendException( 147 u"Folder '%s' could not be created, reason : '%s'" 148 % (path, e) 149 ) 150 151 def _put(self, source_path, remote_filename): 152 u"""Uploads file to the specified remote folder (tries to delete it first to make 153 sure the new one can be uploaded)""" 154 155 try: 156 self.delete(remote_filename.decode()) 157 except Exception: 158 pass 159 self.upload( 160 local_file=source_path.get_canonical().decode(), 161 remote_file=remote_filename.decode(), 162 ) 163 164 def _get(self, remote_filename, local_path): 165 u'Downloads file from the specified remote path' 166 167 self.download( 168 remote_file=remote_filename.decode(), 169 local_file=local_path.name.decode(), 170 ) 171 172 def _list(self): 173 u'Lists files in the specified remote path' 174 175 return self.folder_contents(files_only=True) 176 177 def _delete(self, filename): 178 u'Deletes file from the specified remote path' 179 180 self.delete(remote_file=filename.decode()) 181 182 def _close(self): 183 u'Function called when backend is done being used' 184 185 if not self._no_logout: 186 cmd = [u'mega-logout'] 187 self.subprocess_popen(cmd) 188 189 cmd = [u'mega-exec', u'exit'] 190 self.subprocess_popen(cmd) 191 192 def mega_login(self): 193 u"""Helper function to check existing session exists""" 194 195 # Abort if command doesn't return in a reasonable time (somehow "mega-session" sometimes 196 # doesn't return), and create session if one doesn't exist yet 197 try: 198 result = subprocess.run( 199 u'mega-whoami', 200 timeout=30, 201 capture_output=True, 202 ) 203 result.check_returncode() 204 current_username = result.stdout.decode().split(u':')[-1].strip() 205 if current_username != self._username: 206 raise Exception(u"Username is not match") 207 except subprocess.TimeoutExpired: 208 self._close() 209 raise BackendException( 210 u"Timed out while trying to determine if a MEGA session exists" 211 ) 212 except Exception as e: 213 if self._password is None: 214 self._password = self.get_password() 215 216 cmd = [u'mega-login', self._username, self._password] 217 try: 218 subprocess.run( 219 cmd, 220 stderr=subprocess.DEVNULL, 221 ).check_returncode() 222 except Exception as e: 223 self._close() 224 raise BackendException( 225 u"Could not log in to MEGA, error : '%s'" % (e,) 226 ) 227 228 def folder_contents(self, files_only=False): 229 u'Lists contents of a remote MEGA path, optionally ignoring subdirectories' 230 231 cmd = [u'mega-ls', u'-l', self._folder] 232 233 self.mega_login() 234 files = subprocess.check_output(cmd) 235 files = files.decode().split(u'\n') 236 237 # Optionally ignore directories 238 if files_only: 239 files = [f.split()[5] for f in files if re.search(u'^-', f)] 240 241 return files 242 243 def download(self, remote_file, local_file): 244 u'Downloads a file from a remote MEGA path' 245 246 cmd = [u'mega-get', self._folder + u'/' + remote_file, local_file] 247 self.mega_login() 248 self.subprocess_popen(cmd) 249 250 def upload(self, local_file, remote_file): 251 u'Uploads a file to a remote MEGA path' 252 253 cmd = [u'mega-put', local_file, self._folder + u'/' + remote_file] 254 self.mega_login() 255 try: 256 self.subprocess_popen(cmd) 257 except Exception as e: 258 error_str = str(e) 259 if u"Reached storage quota" in error_str: 260 raise BackendException( 261 u"MEGA account over quota, could not write file : '%s' . " 262 u"Upgrade your storage at https://mega.nz/pro or remove some data." 263 % (remote_file,) 264 ) 265 else: 266 raise BackendException( 267 u"Failed writing file '%s' to MEGA, reason : '%s'" 268 % (remote_file, e) 269 ) 270 271 def delete(self, remote_file): 272 u'Deletes a file from a remote MEGA path' 273 274 cmd = [u'mega-rm', u'-f', self._folder + u'/' + remote_file] 275 self.mega_login() 276 self.subprocess_popen(cmd) 277 278 279duplicity.backend.register_backend(u'megav3', Megav3Backend) 280duplicity.backend.uses_netloc.extend([u'megav3']) 281