Adds export method, fixes login for v2 accounts, rm get_contacts() method

master
Richard O'Dwyer 2019-10-22 22:39:42 +01:00
parent d9a77e5fd9
commit 93f3e6d376
6 changed files with 301 additions and 153 deletions

2
.gitignore vendored
View File

@ -5,7 +5,7 @@
################# #################
## Eclipse ## Eclipse
################# #################
.env.fish
*.pydevproject *.pydevproject
.project .project
.metadata .metadata

View File

@ -6,7 +6,9 @@ Release History
0.9.21 (unreleased) 0.9.21 (unreleased)
+++++++++++++++++++ +++++++++++++++++++
- Nothing changed yet. - Removes broken method ``get_contacts()``.
- Adds support for login with a v2 Mega user account.
- Adds ``export()`` method to share a file or folder, returning public share URL with key.
0.9.20 (2019-10-17) 0.9.20 (2019-10-17)

View File

@ -13,3 +13,4 @@ zest.releaser
setuptools setuptools
twine twine
wheel wheel
rope

View File

@ -0,0 +1,19 @@
[{
"a": "log",
"e": 99635
}, {
"a": "s2",
"n": "oUchHSzJ",
"s": [{
"u": "EXP",
"r": 0
}],
"i": "q6CpLxTDNV",
"ok": "uGuwXS80VfifU1hcLlKcrQ",
"ha": "n6dI3_qVste9XPma5fMIKQ",
"cr": [
["oUchHSzJ"],
["oUchHSzJ"],
[0, 0, "ff5m7sr6LZlYyRtiEE9EZA"]
]
}]

View File

@ -1,5 +1,7 @@
import re import re
import json import json
import secrets
import hashlib
from Crypto.Cipher import AES from Crypto.Cipher import AES
from Crypto.PublicKey import RSA from Crypto.PublicKey import RSA
from Crypto.Util import Counter from Crypto.Util import Counter
@ -41,10 +43,27 @@ class Mega(object):
return self return self
def _login_user(self, email, password): def _login_user(self, email, password):
password_aes = prepare_key(str_to_a32(password)) email = email.lower()
uh = stringhash(email, password_aes) get_user_salt_resp = self._api_request({'a': 'us0', 'user': email})
resp = self._api_request({'a': 'us', 'user': email, 'uh': uh}) user_salt = None
# if numeric error code response try:
user_salt = base64_to_a32(get_user_salt_resp['s'])
except KeyError:
# v1 user account
password_aes = prepare_key(str_to_a32(password))
user_hash = stringhash(email, password_aes)
else:
# v2 user account
pbkdf2_key = hashlib.pbkdf2_hmac(
hash_name='sha512',
password=password.encode(),
salt=a32_to_str(user_salt),
iterations=100000,
dklen=32
)
password_aes = str_to_a32(pbkdf2_key[:16])
user_hash = base64_url_encode(pbkdf2_key[-16:])
resp = self._api_request({'a': 'us', 'user': email, 'uh': user_hash})
if isinstance(resp, int): if isinstance(resp, int):
raise RequestError(resp) raise RequestError(resp)
self._login_process(resp, password_aes) self._login_process(resp, password_aes)
@ -70,7 +89,6 @@ class Mega(object):
) )
resp = self._api_request({'a': 'us', 'user': user}) resp = self._api_request({'a': 'us', 'user': user})
# if numeric error code response
if isinstance(resp, int): if isinstance(resp, int):
raise RequestError(resp) raise RequestError(resp)
self._login_process(resp, password_key) self._login_process(resp, password_key)
@ -113,7 +131,6 @@ class Mega(object):
self.rsa_private_key[1] self.rsa_private_key[1]
) )
) )
sid = '%x' % rsa_decrypter.key._decrypt(encrypted_sid) sid = '%x' % rsa_decrypter.key._decrypt(encrypted_sid)
sid = binascii.unhexlify('0' + sid if len(sid) % 2 else sid) sid = binascii.unhexlify('0' + sid if len(sid) % 2 else sid)
self.sid = base64_url_encode(sid[:43]) self.sid = base64_url_encode(sid[:43])
@ -135,20 +152,8 @@ class Mega(object):
params=params, params=params,
data=json.dumps(data), data=json.dumps(data),
timeout=self.timeout, timeout=self.timeout,
headers={
'Origin':
'https://mega.nz',
'Referer':
'https://mega.nz/login',
'User-Agent': (
'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:69.0) '
'Gecko/20100101 Firefox/69.0'
),
}
) )
json_resp = json.loads(req.text) json_resp = json.loads(req.text)
# if numeric error code response
if isinstance(json_resp, int): if isinstance(json_resp, int):
raise RequestError(json_resp) raise RequestError(json_resp)
return json_resp[0] return json_resp[0]
@ -163,9 +168,6 @@ class Mega(object):
raise RequestError('Url key missing') raise RequestError('Url key missing')
def _process_file(self, file, shared_keys): def _process_file(self, file, shared_keys):
"""
Process a file
"""
if file['t'] == 0 or file['t'] == 1: if file['t'] == 0 or file['t'] == 1:
keys = dict( keys = dict(
keypart.split(':', 1) keypart.split(':', 1)
@ -194,6 +196,13 @@ class Mega(object):
key = keys[hkey] key = keys[hkey]
key = decrypt_key(base64_to_a32(key), shared_key) key = decrypt_key(base64_to_a32(key), shared_key)
break break
if file['h'] and file['h'] in shared_keys.get('EXP', ()):
shared_key = shared_keys['EXP'][file['h']]
encrypted_key = str_to_a32(
base64_url_decode(file['k'].split(':')[-1])
)
key = decrypt_key(encrypted_key, shared_key)
file['shared_folder_key'] = shared_key
if key is not None: if key is not None:
# file # file
if file['t'] == 0: if file['t'] == 0:
@ -244,9 +253,7 @@ class Mega(object):
shared_keys[s_item['u']] = {} shared_keys[s_item['u']] = {}
if s_item['h'] in ok_dict: if s_item['h'] in ok_dict:
shared_keys[s_item['u']][s_item['h']] = ok_dict[s_item['h']] shared_keys[s_item['u']][s_item['h']] = ok_dict[s_item['h']]
self.shared_keys = shared_keys
##########################################################################
# GET
def find_path_descriptor(self, path): def find_path_descriptor(self, path):
""" """
@ -264,8 +271,11 @@ class Mega(object):
for foldername in paths: for foldername in paths:
if foldername != '': if foldername != '':
for file in files.items(): for file in files.items():
if file[1]['a'] and file[1]['t'] and \ if (
file[1]['a']['n'] == foldername: file[1]['a'] and
file[1]['t'] and
file[1]['a']['n'] == foldername
):
if parent_desc == file[1]['p']: if parent_desc == file[1]['p']:
parent_desc = file[0] parent_desc = file[0]
found = True found = True
@ -275,22 +285,40 @@ class Mega(object):
return None return None
return parent_desc return parent_desc
def find(self, filename): def find(self, filename=None, handle=None):
""" """
Return file object from given filename Return file object from given filename
""" """
from pathlib import Path
path = Path(filename)
filename = path.name
files = self.get_files() files = self.get_files()
parent_dir_name = path.parent.name
for file in list(files.items()): for file in list(files.items()):
if not isinstance(file[1]['a'], dict): parent_node_id = None
continue if parent_dir_name:
if file[1]['a'] and file[1]['a']['n'] == filename: parent_node_id = self.find_path_descriptor(parent_dir_name)
if (
filename and parent_node_id and
file[1]['a'] and file[1]['a']['n'] == filename
and parent_node_id == file[1]['p']
):
return file
# if not isinstance(file[1]['a'], dict):
# continue
if (
filename and
file[1]['a'] and file[1]['a']['n'] == filename
):
return file
if handle and file[1]['h'] == handle:
return file return file
def get_files(self): def get_files(self):
""" """
Get all files in account Get all files in account
""" """
files = self._api_request({'a': 'f', 'c': 1}) files = self._api_request({'a': 'f', 'c': 1, 'r': 1})
files_dict = {} files_dict = {}
shared_keys = {} shared_keys = {}
self._init_shared_keys(files, shared_keys) self._init_shared_keys(files, shared_keys)
@ -341,6 +369,31 @@ class Mega(object):
else: else:
raise ValidationError('File id and key must be present') raise ValidationError('File id and key must be present')
def _node_data(self, node):
try:
return node[1]
except (IndexError, KeyError):
return node
def get_folder_link(self, file):
try:
file = file[1]
except (IndexError, KeyError):
pass
if 'h' in file and 'k' in file:
public_handle = self._api_request({'a': 'l', 'n': file['h']})
if public_handle == -11:
raise RequestError(
"Can't get a public link from that file "
"(is this a shared file?)"
)
decrypted_key = a32_to_base64(file['shared_folder_key'])
return '{0}://{1}/#F!{2}!{3}'.format(
self.schema, self.domain, public_handle, decrypted_key
)
else:
raise ValidationError('File id and key must be present')
def get_user(self): def get_user(self):
user_data = self._api_request({'a': 'ug'}) user_data = self._api_request({'a': 'ug'})
return user_data return user_data
@ -442,8 +495,6 @@ class Mega(object):
if 'balance' in user_data: if 'balance' in user_data:
return user_data['balance'] return user_data['balance']
##########################################################################
# DELETE
def delete(self, public_handle): def delete(self, public_handle):
""" """
Delete a file by its public handle Delete a file by its public handle
@ -491,8 +542,6 @@ class Mega(object):
post_list.append({"a": "d", "n": file, "i": self.request_id}) post_list.append({"a": "d", "n": file, "i": self.request_id})
return self._api_request(post_list) return self._api_request(post_list)
##########################################################################
# DOWNLOAD
def download(self, file, dest_path=None, dest_filename=None): def download(self, file, dest_path=None, dest_filename=None):
""" """
Download a file by it's file object Download a file by it's file object
@ -506,6 +555,68 @@ class Mega(object):
is_public=False is_public=False
) )
def _export_file(self, node):
self._api_request([
{
'a': 'l',
'n': node[1]['h'],
'i': self.request_id
}
])
return self.get_link(node)
def export(self, path):
self.get_files()
folder = self.find(path)
if folder[1]['t'] == 0:
return self._export_file(folder)
if folder:
try:
# If already exported
return self.get_folder_link(folder)
except (RequestError, KeyError):
pass
user_id = folder[1]['u']
user_pub_key = self._api_request({'a': 'uk', 'u': user_id})['pubk']
node_key = folder[1]['k']
master_key_cipher = AES.new(a32_to_str(self.master_key), AES.MODE_ECB)
ha = base64_url_encode(
master_key_cipher.encrypt(folder[1]['h'] + folder[1]['h'])
)
share_key = secrets.token_bytes(16)
ok = base64_url_encode(master_key_cipher.encrypt(share_key))
share_key_cipher = AES.new(share_key, AES.MODE_ECB)
encrypted_node_key = base64_url_encode(
share_key_cipher.encrypt(a32_to_str(node_key))
)
node_id = folder[1]['h']
request_body = [
{
'a': 's2',
'n': node_id,
's': [{
'u': 'EXP',
'r': 0
}],
'i': self.request_id,
'ok': ok,
'ha': ha,
'cr': [
[node_id],
[node_id],
[0, 0, encrypted_node_key]
]
}]
self._api_request(request_body)
nodes = self.get_files()
link = self.get_folder_link(nodes[node_id])
return link
def download_url(self, url, dest_path=None, dest_filename=None): def download_url(self, url, dest_path=None, dest_filename=None):
""" """
Download a file by it's public url Download a file by it's public url
@ -631,8 +742,6 @@ class Mega(object):
shutil.move(temp_output_file.name, dest_path + file_name) shutil.move(temp_output_file.name, dest_path + file_name)
##########################################################################
# UPLOAD
def upload(self, filename, dest=None, dest_filename=None): def upload(self, filename, dest=None, dest_filename=None):
# determine storage node # determine storage node
if dest is None: if dest is None:
@ -767,10 +876,8 @@ class Mega(object):
# update attributes # update attributes
data = self._api_request( data = self._api_request(
{ {
'a': 'a':'p',
'p', 't': dest,
't':
dest,
'n': [ 'n': [
{ {
'h': 'xxxxxxxx', 'h': 'xxxxxxxx',
@ -779,8 +886,7 @@ class Mega(object):
'k': encrypted_key 'k': encrypted_key
} }
], ],
'i': 'i': self.request_id
self.request_id
} }
) )
return data return data
@ -881,21 +987,6 @@ class Mega(object):
} }
) )
def get_contacts(self):
raise NotImplementedError()
# TODO implement this
# sn param below = maxaction var with function getsc() in mega.co.nz js
# seens to be the 'sn' attrib of the previous request response...
# requests goto /sc rather than
# req = requests.post(
# '{0}://g.api.{1}/sc'.format(self.schema, self.domain),
# params={'sn': 'ZMxcQ_DmHnM', 'ssl': '1'},
# data=json.dumps(None),
# timeout=self.timeout)
# json_resp = json.loads(req.text)
# print json_resp
def get_public_url_info(self, url): def get_public_url_info(self, url):
""" """
Get size and name from a public url, dict returned Get size and name from a public url, dict returned
@ -917,8 +1008,6 @@ class Mega(object):
Get size and name of a public file Get size and name of a public file
""" """
data = self._api_request({'a': 'g', 'p': file_handle, 'ssm': 1}) data = self._api_request({'a': 'g', 'p': file_handle, 'ssm': 1})
# if numeric error code response
if isinstance(data, int): if isinstance(data, int):
raise RequestError(data) raise RequestError(data)

View File

@ -1,21 +1,10 @@
"""
These unit tests will upload a test file,a test folder and a test contact,
Perform api operations on them,
And them remove them from your account.
"""
import unittest
import random import random
import os import os
import pytest
from mega import Mega from mega import Mega
mega = Mega()
# anonymous login
m = mega.login()
# normal login
# m = mega.login(email, password)
FIND_RESP = None
TEST_CONTACT = 'test@mega.co.nz' TEST_CONTACT = 'test@mega.co.nz'
TEST_PUBLIC_URL = ( TEST_PUBLIC_URL = (
'https://mega.nz/#!hYVmXKqL!r0d0-WRnFwulR_shhuEDwrY1Vo103-am1MyUy8oV6Ps' 'https://mega.nz/#!hYVmXKqL!r0d0-WRnFwulR_shhuEDwrY1Vo103-am1MyUy8oV6Ps'
@ -24,83 +13,131 @@ TEST_FILE = os.path.basename(__file__)
TEST_FOLDER = 'mega.py_testfolder_{0}'.format(random.random()) TEST_FOLDER = 'mega.py_testfolder_{0}'.format(random.random())
class TestMega(unittest.TestCase): @pytest.fixture
def mega():
def test_mega(self): mega_ = Mega()
self.assertIsInstance(mega, Mega) mega_.login(email=os.environ['EMAIL'], password=os.environ['PASS'])
node = mega_.create_folder(TEST_FOLDER)
def test_login(self): yield mega_
self.assertIsInstance(mega, Mega) node_id = node['f'][0]['h']
mega_.destroy(node_id)
def test_get_user(self):
resp = m.get_user()
self.assertIsInstance(resp, dict)
def test_get_quota(self):
resp = m.get_quota()
self.assertIsInstance(int(resp), int)
def test_get_storage_space(self):
resp = m.get_storage_space(mega=True)
self.assertIsInstance(resp, dict)
def test_get_files(self):
files = m.get_files()
self.assertIsInstance(files, dict)
def test_get_link(self):
file = m.find(TEST_FILE)
if file:
link = m.get_link(file)
self.assertIsInstance(link, str)
def test_import_public_url(self):
resp = m.import_public_url(TEST_PUBLIC_URL)
file_handle = m.get_id_from_obj(resp)
resp = m.destroy(file_handle)
self.assertIsInstance(resp, int)
def test_create_folder(self):
resp = m.create_folder(TEST_FOLDER)
self.assertIsInstance(resp, dict)
def test_rename(self):
file = m.find(TEST_FOLDER)
if file:
resp = m.rename(file, TEST_FOLDER)
self.assertIsInstance(resp, int)
def test_delete_folder(self):
folder_node = m.find(TEST_FOLDER)[0]
resp = m.delete(folder_node)
self.assertIsInstance(resp, int)
def test_delete(self):
file = m.find(TEST_FILE)
if file:
resp = m.delete(file[0])
self.assertIsInstance(resp, int)
def test_destroy(self):
file = m.find(TEST_FILE)
if file:
resp = m.destroy(file[0])
self.assertIsInstance(resp, int)
def test_empty_trash(self):
# resp None if already empty, else int
resp = m.empty_trash()
if resp is not None:
self.assertIsInstance(resp, int)
def test_add_contact(self):
resp = m.add_contact(TEST_CONTACT)
self.assertIsInstance(resp, int)
def test_remove_contact(self):
resp = m.remove_contact(TEST_CONTACT)
self.assertIsInstance(resp, int)
if __name__ == '__main__': def test_mega(mega):
unittest.main() assert isinstance(mega, Mega)
def test_login(mega):
assert isinstance(mega, Mega)
def test_get_user(mega):
resp = mega.get_user()
assert isinstance(resp, dict)
def test_get_quota(mega):
resp = mega.get_quota()
assert isinstance(int(resp), int)
def test_get_storage_space(mega):
resp = mega.get_storage_space(mega=True)
assert isinstance(resp, dict)
def test_get_files(mega):
files = mega.get_files()
assert isinstance(files, dict)
def test_get_link(mega):
file = mega.find(TEST_FILE)
if file:
link = mega.get_link(file)
assert isinstance(link, str)
class TestExport:
def test_export_folder(self, mega):
public_url = None
for _ in range(2):
result_public_share_url = mega.export(TEST_FOLDER)
if not public_url:
public_url = result_public_share_url
assert result_public_share_url.startswith('https://mega.co.nz/#F!')
assert result_public_share_url == public_url
def test_export_single_file(self, mega):
# Upload a single file into a folder
folder = mega.find(TEST_FOLDER)
dest_node_id = folder[1]['h']
result = mega.upload(
__file__, dest=dest_node_id, dest_filename='test.py'
)
path = f'{TEST_FOLDER}/test.py'
assert mega.find(path)
for _ in range(2):
result_public_share_url = mega.export(path)
assert result_public_share_url.startswith('https://mega.co.nz/#!')
def test_import_public_url(mega):
resp = mega.import_public_url(TEST_PUBLIC_URL)
file_handle = mega.get_id_from_obj(resp)
resp = mega.destroy(file_handle)
assert isinstance(resp, int)
def test_create_folder(mega):
resp = mega.create_folder(TEST_FOLDER)
assert isinstance(resp, dict)
def test_rename(mega):
file = mega.find(TEST_FOLDER)
if file:
resp = mega.rename(file, TEST_FOLDER)
assert isinstance(resp, int)
def test_delete_folder(mega):
folder_node = mega.find(TEST_FOLDER)[0]
resp = mega.delete(folder_node)
assert isinstance(resp, int)
def test_delete(mega):
file = mega.find(TEST_FILE)
if file:
resp = mega.delete(file[0])
assert isinstance(resp, int)
def test_destroy(mega):
file = mega.find(TEST_FILE)
if file:
resp = mega.destroy(file[0])
assert isinstance(resp, int)
def test_empty_trash(mega):
# resp None if already empty, else int
resp = mega.empty_trash()
if resp is not None:
assert isinstance(resp, int)
def test_add_contact(mega):
resp = mega.add_contact(TEST_CONTACT)
assert isinstance(resp, int)
def test_remove_contact(mega):
resp = mega.remove_contact(TEST_CONTACT)
assert isinstance(resp, int)