Rewritten atclient, some methods moved to ataccount, corrected docs
This commit is contained in:
parent
134a27b43d
commit
55ce48819e
9 changed files with 378 additions and 439 deletions
229
python_aternos/ataccount.py
Normal file
229
python_aternos/ataccount.py
Normal file
|
@ -0,0 +1,229 @@
|
||||||
|
"""Methods related to an Aternos account
|
||||||
|
including servers page parsing"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import base64
|
||||||
|
|
||||||
|
from typing import List, Dict
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import lxml.html
|
||||||
|
|
||||||
|
from .atlog import log
|
||||||
|
from .atmd5 import md5encode
|
||||||
|
|
||||||
|
from .atconnect import AternosConnect
|
||||||
|
from .atconnect import BASE_URL, AJAX_URL
|
||||||
|
|
||||||
|
from .atserver import AternosServer
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .atclient import Client
|
||||||
|
|
||||||
|
|
||||||
|
email_re = re.compile(
|
||||||
|
r'^[A-Za-z0-9\-_+.]+@[A-Za-z0-9\-_+.]+\.[A-Za-z0-9\-]+$|^$'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AternosAccount:
|
||||||
|
"""Methods related to an Aternos account
|
||||||
|
including servers page parsing"""
|
||||||
|
|
||||||
|
def __init__(self, atclient: 'Client') -> None:
|
||||||
|
"""Should not be instantiated manually,
|
||||||
|
the entrypoint is `atclient.Client`
|
||||||
|
|
||||||
|
Args:
|
||||||
|
atconn (AternosConnect): AternosConnect object
|
||||||
|
"""
|
||||||
|
|
||||||
|
self.atclient = atclient
|
||||||
|
self.atconn: AternosConnect = atclient.atconn
|
||||||
|
|
||||||
|
self.parsed = False
|
||||||
|
self.servers: List[AternosServer] = []
|
||||||
|
|
||||||
|
def list_servers(self, cache: bool = True) -> List[AternosServer]:
|
||||||
|
"""Parses a servers list
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cache (bool, optional): If the function should use
|
||||||
|
cached servers list (recommended)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of AternosServer objects
|
||||||
|
"""
|
||||||
|
|
||||||
|
if cache and self.parsed:
|
||||||
|
return self.servers
|
||||||
|
|
||||||
|
serverspage = self.atconn.request_cloudflare(
|
||||||
|
f'{BASE_URL}/servers/', 'GET'
|
||||||
|
)
|
||||||
|
serverstree = lxml.html.fromstring(serverspage.content)
|
||||||
|
|
||||||
|
servers = serverstree.xpath(
|
||||||
|
'//div[@class="server-body"]/@data-id'
|
||||||
|
)
|
||||||
|
self.refresh_servers(servers)
|
||||||
|
|
||||||
|
# Update session file (add servers)
|
||||||
|
try:
|
||||||
|
self.atclient.save_session(self.atclient.saved_session)
|
||||||
|
except OSError as err:
|
||||||
|
log.warning('Unable to save servers list to file: %s', err)
|
||||||
|
|
||||||
|
return self.servers
|
||||||
|
|
||||||
|
def refresh_servers(self, ids: List[str]) -> None:
|
||||||
|
"""Replaces the cached servers list
|
||||||
|
creating AternosServer objects by given IDs
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ids (List[str]): Servers unique identifiers
|
||||||
|
"""
|
||||||
|
|
||||||
|
self.servers = []
|
||||||
|
for s in ids:
|
||||||
|
|
||||||
|
servid = s.strip()
|
||||||
|
if servid == '':
|
||||||
|
continue
|
||||||
|
|
||||||
|
log.debug('Adding server %s', servid)
|
||||||
|
srv = AternosServer(servid, self.atconn)
|
||||||
|
self.servers.append(srv)
|
||||||
|
|
||||||
|
self.parsed = True
|
||||||
|
|
||||||
|
def get_server(self, servid: str) -> AternosServer:
|
||||||
|
"""Creates a server object from the server ID.
|
||||||
|
Use this instead of `list_servers` if you know
|
||||||
|
the server IDentifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
AternosServer object
|
||||||
|
"""
|
||||||
|
|
||||||
|
return AternosServer(servid, self.atconn)
|
||||||
|
|
||||||
|
def change_username(self, value: str) -> None:
|
||||||
|
"""Changes a username in your Aternos account
|
||||||
|
|
||||||
|
Args:
|
||||||
|
value (str): New username
|
||||||
|
"""
|
||||||
|
|
||||||
|
self.atconn.request_cloudflare(
|
||||||
|
f'{AJAX_URL}/account/username.php',
|
||||||
|
'POST', data={'username': value},
|
||||||
|
sendtoken=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def change_email(self, value: str) -> None:
|
||||||
|
"""Changes an e-mail in your Aternos account
|
||||||
|
|
||||||
|
Args:
|
||||||
|
value (str): New e-mail
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If an invalid e-mail address
|
||||||
|
was passed to the function
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not email_re.match(value):
|
||||||
|
raise ValueError('Invalid e-mail')
|
||||||
|
|
||||||
|
self.atconn.request_cloudflare(
|
||||||
|
f'{AJAX_URL}/account/email.php',
|
||||||
|
'POST', data={'email': value},
|
||||||
|
sendtoken=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def change_password(self, old: str, new: str) -> None:
|
||||||
|
"""Changes a password in your Aternos account
|
||||||
|
|
||||||
|
Args:
|
||||||
|
old (str): Old password
|
||||||
|
new (str): New password
|
||||||
|
"""
|
||||||
|
|
||||||
|
self.change_password_hashed(
|
||||||
|
md5encode(old),
|
||||||
|
md5encode(new),
|
||||||
|
)
|
||||||
|
|
||||||
|
def change_password_hashed(self, old: str, new: str) -> None:
|
||||||
|
"""Changes a password in your Aternos account.
|
||||||
|
Unlike `change_password`, this function
|
||||||
|
takes hashed passwords as the arguments
|
||||||
|
|
||||||
|
Args:
|
||||||
|
old (str): Old password hashed with MD5
|
||||||
|
new (str): New password hashed with MD5
|
||||||
|
"""
|
||||||
|
|
||||||
|
self.atconn.request_cloudflare(
|
||||||
|
f'{AJAX_URL}/account/password.php',
|
||||||
|
'POST', data={
|
||||||
|
'oldpassword': old,
|
||||||
|
'newpassword': new,
|
||||||
|
},
|
||||||
|
sendtoken=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def qrcode_2fa(self) -> Dict[str, str]:
|
||||||
|
"""Requests a secret code and
|
||||||
|
a QR code for enabling 2FA"""
|
||||||
|
|
||||||
|
return self.atconn.request_cloudflare(
|
||||||
|
f'{AJAX_URL}/account/secret.php',
|
||||||
|
'GET', sendtoken=True,
|
||||||
|
).json()
|
||||||
|
|
||||||
|
def save_qr(self, qrcode: str, filename: str) -> None:
|
||||||
|
"""Writes a 2FA QR code into a png-file
|
||||||
|
|
||||||
|
Args:
|
||||||
|
qrcode (str): Base64 encoded png image from `qrcode_2fa()`
|
||||||
|
filename (str): Where the QR code image must be saved.
|
||||||
|
Existing file will be rewritten.
|
||||||
|
"""
|
||||||
|
|
||||||
|
data = qrcode.removeprefix('data:image/png;base64,')
|
||||||
|
png = base64.b64decode(data)
|
||||||
|
|
||||||
|
with open(filename, 'wb') as f:
|
||||||
|
f.write(png)
|
||||||
|
|
||||||
|
def enable_2fa(self, code: int) -> None:
|
||||||
|
"""Enables Two-Factor Authentication
|
||||||
|
|
||||||
|
Args:
|
||||||
|
code (int): 2FA code
|
||||||
|
"""
|
||||||
|
|
||||||
|
self.atconn.request_cloudflare(
|
||||||
|
f'{AJAX_URL}/account/twofactor.php',
|
||||||
|
'POST', data={'code': code},
|
||||||
|
sendtoken=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def disable_2fa(self, code: int) -> None:
|
||||||
|
"""Disables Two-Factor Authentication
|
||||||
|
|
||||||
|
Args:
|
||||||
|
code (int): 2FA code
|
||||||
|
"""
|
||||||
|
|
||||||
|
self.atconn.request_cloudflare(
|
||||||
|
f'{AJAX_URL}/account/disbaleTwofactor.php',
|
||||||
|
'POST', data={'code': code},
|
||||||
|
sendtoken=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def logout(self) -> None:
|
||||||
|
"""The same as `atclient.Client.logout`"""
|
||||||
|
|
||||||
|
self.atclient.logout()
|
|
@ -3,21 +3,16 @@ and allows to manage your account"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import hashlib
|
|
||||||
|
|
||||||
import base64
|
|
||||||
|
|
||||||
from typing import List, Dict
|
|
||||||
from typing import Optional, Type
|
from typing import Optional, Type
|
||||||
|
|
||||||
import lxml.html
|
|
||||||
|
|
||||||
from .atlog import log
|
from .atlog import log
|
||||||
|
from .atmd5 import md5encode
|
||||||
|
|
||||||
|
from .ataccount import AternosAccount
|
||||||
|
|
||||||
from .atconnect import AternosConnect
|
from .atconnect import AternosConnect
|
||||||
from .atconnect import BASE_URL, AJAX_URL
|
from .atconnect import AJAX_URL
|
||||||
|
|
||||||
from .atserver import AternosServer
|
|
||||||
from .aterrors import CredentialsError
|
from .aterrors import CredentialsError
|
||||||
from .aterrors import TwoFactorAuthError
|
from .aterrors import TwoFactorAuthError
|
||||||
|
|
||||||
|
@ -27,82 +22,75 @@ from .atjsparse import Js2PyInterpreter
|
||||||
|
|
||||||
|
|
||||||
class Client:
|
class Client:
|
||||||
|
|
||||||
"""Aternos API Client class, object
|
"""Aternos API Client class, object
|
||||||
of which contains user's auth data"""
|
of which contains user's auth data"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(self) -> None:
|
||||||
self,
|
|
||||||
atconn: AternosConnect,
|
|
||||||
servers: Optional[List[str]] = None) -> None:
|
|
||||||
"""Aternos API Client class, object
|
|
||||||
of which contains user's auth data
|
|
||||||
|
|
||||||
Args:
|
# Config
|
||||||
atconn (AternosConnect):
|
self.debug = False
|
||||||
AternosConnect instance with initialized Aternos session
|
self.sessions_dir = '~'
|
||||||
servers (Optional[List[str]], optional):
|
self.js: Type[Interpreter] = Js2PyInterpreter
|
||||||
List with servers IDs
|
# ###
|
||||||
"""
|
|
||||||
|
|
||||||
self.atconn = atconn
|
|
||||||
|
|
||||||
self.saved_session = ''
|
self.saved_session = ''
|
||||||
|
self.atconn = AternosConnect()
|
||||||
|
self.account = AternosAccount(self)
|
||||||
|
|
||||||
self.parsed = False
|
def login(
|
||||||
self.servers: List[AternosServer] = []
|
self,
|
||||||
|
|
||||||
if servers:
|
|
||||||
self.refresh_servers(servers)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_hashed(
|
|
||||||
cls,
|
|
||||||
username: str,
|
username: str,
|
||||||
md5: str,
|
password: str,
|
||||||
code: Optional[int] = None,
|
code: Optional[int] = None) -> None:
|
||||||
sessions_dir: str = '~',
|
"""Log in to your Aternos account
|
||||||
js: Type[Interpreter] = Js2PyInterpreter,
|
with a username and a plain password
|
||||||
**custom_args):
|
|
||||||
"""Log in to an Aternos account with
|
|
||||||
a username and a hashed password
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
username (str): Your username
|
username (str): Username
|
||||||
md5 (str): Your password hashed with MD5
|
password (str): Plain-text password
|
||||||
code (Optional[int]): 2FA code
|
code (Optional[int], optional): 2FA code
|
||||||
sessions_dir (str): Path to the directory
|
|
||||||
where session will be automatically saved
|
|
||||||
js (Type[Interpreter]): Preferred JS interpreter,
|
|
||||||
any class from `atjsparse`
|
|
||||||
inheriting `Interpreter` class
|
|
||||||
**custom_args (tuple, optional): Keyword arguments
|
|
||||||
which will be passed to CloudScraper `__init__`
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
CredentialsError: If the API didn't
|
|
||||||
return a valid session cookie
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
filename = cls.session_file(
|
self.login_hashed(
|
||||||
username, sessions_dir
|
username,
|
||||||
|
md5encode(password),
|
||||||
|
code,
|
||||||
|
)
|
||||||
|
|
||||||
|
def login_hashed(
|
||||||
|
self,
|
||||||
|
username: str,
|
||||||
|
md5: str,
|
||||||
|
code: Optional[int] = None) -> None:
|
||||||
|
"""Log in to your Aternos account
|
||||||
|
with a username and a hashed password
|
||||||
|
|
||||||
|
Args:
|
||||||
|
username (str): Username
|
||||||
|
md5 (str): Password hashed with MD5
|
||||||
|
code (int): 2FA code
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
TwoFactorAuthError: If the 2FA is enabled,
|
||||||
|
but `code` argument was not passed or is incorrect
|
||||||
|
CredentialsError: If the Aternos backend
|
||||||
|
returned empty session cookie
|
||||||
|
(usually because of incorrect credentials)
|
||||||
|
ValueError: _description_
|
||||||
|
"""
|
||||||
|
|
||||||
|
filename = self.session_filename(
|
||||||
|
username, self.sessions_dir
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return cls.restore_session(
|
self.restore_session(filename)
|
||||||
filename, **custom_args
|
|
||||||
)
|
|
||||||
except (OSError, CredentialsError):
|
except (OSError, CredentialsError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
atjsparse.get_interpreter(create=js)
|
atjsparse.get_interpreter(create=self.js)
|
||||||
atconn = AternosConnect()
|
self.atconn.parse_token()
|
||||||
|
self.atconn.generate_sec()
|
||||||
if len(custom_args) > 0:
|
|
||||||
atconn.add_args(**custom_args)
|
|
||||||
|
|
||||||
atconn.parse_token()
|
|
||||||
atconn.generate_sec()
|
|
||||||
|
|
||||||
credentials = {
|
credentials = {
|
||||||
'user': username,
|
'user': username,
|
||||||
|
@ -112,9 +100,9 @@ class Client:
|
||||||
if code is not None:
|
if code is not None:
|
||||||
credentials['code'] = str(code)
|
credentials['code'] = str(code)
|
||||||
|
|
||||||
loginreq = atconn.request_cloudflare(
|
loginreq = self.atconn.request_cloudflare(
|
||||||
f'{AJAX_URL}/account/login.php',
|
f'{AJAX_URL}/account/login.php',
|
||||||
'POST', data=credentials, sendtoken=True
|
'POST', data=credentials, sendtoken=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
if b'"show2FA":true' in loginreq.content:
|
if b'"show2FA":true' in loginreq.content:
|
||||||
|
@ -125,169 +113,58 @@ class Client:
|
||||||
'Check your username and password'
|
'Check your username and password'
|
||||||
)
|
)
|
||||||
|
|
||||||
obj = cls(atconn)
|
self.saved_session = filename
|
||||||
obj.saved_session = filename
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
obj.save_session(filename)
|
self.save_session(filename)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return obj
|
def logout(self) -> None:
|
||||||
|
"""Log out from the Aternos account"""
|
||||||
|
|
||||||
@classmethod
|
self.atconn.request_cloudflare(
|
||||||
def from_credentials(
|
f'{AJAX_URL}/account/logout.php',
|
||||||
cls,
|
'GET', sendtoken=True,
|
||||||
username: str,
|
|
||||||
password: str,
|
|
||||||
code: Optional[int] = None,
|
|
||||||
sessions_dir: str = '~',
|
|
||||||
js: Type[Interpreter] = Js2PyInterpreter,
|
|
||||||
**custom_args):
|
|
||||||
"""Log in to Aternos with a username and a plain password
|
|
||||||
|
|
||||||
Args:
|
|
||||||
username (str): Your username
|
|
||||||
password (str): Your password without any encryption
|
|
||||||
code (Optional[int]): 2FA code
|
|
||||||
sessions_dir (str): Path to the directory
|
|
||||||
where session will be automatically saved
|
|
||||||
js (Type[Interpreter]): Preferred JS interpreter,
|
|
||||||
any class from `atjsparse`
|
|
||||||
inheriting `Interpreter` class
|
|
||||||
**custom_args (tuple, optional): Keyword arguments
|
|
||||||
which will be passed to CloudScraper `__init__`
|
|
||||||
"""
|
|
||||||
|
|
||||||
md5 = Client.md5encode(password)
|
|
||||||
return cls.from_hashed(
|
|
||||||
username, md5, code,
|
|
||||||
sessions_dir, js,
|
|
||||||
**custom_args
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
self.remove_session(self.saved_session)
|
||||||
def from_session(
|
|
||||||
cls,
|
def restore_session(self, filename: str = '~/.aternos') -> None:
|
||||||
session: str,
|
"""Restores ATERNOS_SESSION cookie and,
|
||||||
servers: Optional[List[str]] = None,
|
if included, servers list, from a session file
|
||||||
js: Type[Interpreter] = Js2PyInterpreter,
|
|
||||||
**custom_args):
|
|
||||||
"""Log in to Aternos using a session cookie value
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
session (str): Value of ATERNOS_SESSION cookie
|
filename (str, optional): Filename
|
||||||
servers (Optional[List[str]]): List of cached servers IDs.
|
|
||||||
js (Type[Interpreter]): Preferred JS interpreter,
|
Raises:
|
||||||
any class from `atjsparse`
|
FileNotFoundError: If the file cannot be found
|
||||||
inheriting `Interpreter` class
|
CredentialsError: If the session cookie
|
||||||
**custom_args (tuple, optional): Keyword arguments
|
(or the file at all) has incorrect format
|
||||||
which will be passed to CloudScraper `__init__`
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
atjsparse.get_interpreter(create=js)
|
filename = os.path.expanduser(filename)
|
||||||
atconn = AternosConnect()
|
log.debug('Restoring session from %s', filename)
|
||||||
|
|
||||||
atconn.add_args(**custom_args)
|
if not os.path.exists(filename):
|
||||||
atconn.session.cookies['ATERNOS_SESSION'] = session
|
|
||||||
|
|
||||||
atconn.parse_token()
|
|
||||||
atconn.generate_sec()
|
|
||||||
|
|
||||||
return cls(atconn, servers)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def restore_session(
|
|
||||||
cls,
|
|
||||||
file: str = '~/.aternos',
|
|
||||||
js: Type[Interpreter] = Js2PyInterpreter,
|
|
||||||
**custom_args):
|
|
||||||
"""Log in to Aternos using
|
|
||||||
a saved ATERNOS_SESSION cookie
|
|
||||||
|
|
||||||
Args:
|
|
||||||
file (str, optional): File where a session cookie was saved
|
|
||||||
js (Type[Interpreter]): Preferred JS interpreter,
|
|
||||||
any class from `atjsparse`
|
|
||||||
inheriting `Interpreter` class
|
|
||||||
**custom_args (tuple, optional): Keyword arguments
|
|
||||||
which will be passed to CloudScraper `__init__`
|
|
||||||
"""
|
|
||||||
|
|
||||||
file = os.path.expanduser(file)
|
|
||||||
log.debug('Restoring session from %s', file)
|
|
||||||
|
|
||||||
if not os.path.exists(file):
|
|
||||||
raise FileNotFoundError()
|
raise FileNotFoundError()
|
||||||
|
|
||||||
with open(file, 'rt', encoding='utf-8') as f:
|
with open(filename, 'rt', encoding='utf-8') as f:
|
||||||
saved = f.read() \
|
saved = f.read() \
|
||||||
.strip() \
|
.strip() \
|
||||||
.replace('\r\n', '\n') \
|
.replace('\r\n', '\n') \
|
||||||
.split('\n')
|
.split('\n')
|
||||||
|
|
||||||
session = saved[0].strip()
|
session = saved[0].strip()
|
||||||
if session == '':
|
if session == '' or not session.isalnum():
|
||||||
raise CredentialsError(
|
raise CredentialsError(
|
||||||
'Unable to read session cookie, '
|
'Session cookie is invalid or the file is empty'
|
||||||
'the first line is empty'
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if len(saved) > 1:
|
if len(saved) > 1:
|
||||||
obj = cls.from_session(
|
self.account.refresh_servers(saved[1:])
|
||||||
session=session,
|
|
||||||
servers=saved[1:],
|
|
||||||
js=js,
|
|
||||||
**custom_args
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
obj = cls.from_session(
|
|
||||||
session,
|
|
||||||
js=js,
|
|
||||||
**custom_args
|
|
||||||
)
|
|
||||||
|
|
||||||
obj.saved_session = file
|
self.atconn.session.cookies['ATERNOS_SESSION'] = session
|
||||||
|
self.saved_session = filename
|
||||||
return obj
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def md5encode(passwd: str) -> str:
|
|
||||||
"""Encodes the given string with MD5
|
|
||||||
|
|
||||||
Args:
|
|
||||||
passwd (str): String to encode
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Hexdigest hash of the string in lowercase
|
|
||||||
"""
|
|
||||||
|
|
||||||
encoded = hashlib.md5(passwd.encode('utf-8'))
|
|
||||||
return encoded.hexdigest().lower()
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def session_file(username: str, sessions_dir: str = '~') -> str:
|
|
||||||
"""Generates session file name
|
|
||||||
for authenticated user
|
|
||||||
|
|
||||||
Args:
|
|
||||||
username (str): Authenticated user
|
|
||||||
sessions_dir (str, optional): Path to directory
|
|
||||||
with automatically saved sessions
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Filename
|
|
||||||
"""
|
|
||||||
|
|
||||||
# unsafe symbols replacement
|
|
||||||
repl = '_'
|
|
||||||
|
|
||||||
secure = re.sub(
|
|
||||||
r'[^A-Za-z0-9_-]',
|
|
||||||
repl, username
|
|
||||||
)
|
|
||||||
|
|
||||||
return f'{sessions_dir}/.at_{secure}'
|
|
||||||
|
|
||||||
def save_session(
|
def save_session(
|
||||||
self,
|
self,
|
||||||
|
@ -298,8 +175,9 @@ class Client:
|
||||||
Args:
|
Args:
|
||||||
file (str, optional): File where a session cookie must be saved
|
file (str, optional): File where a session cookie must be saved
|
||||||
incl_servers (bool, optional): If the function
|
incl_servers (bool, optional): If the function
|
||||||
should include the servers IDs to
|
should include the servers IDs in this file
|
||||||
reduce API requests count (recommended)
|
to reduce API requests count on the next restoration
|
||||||
|
(recommended)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
file = os.path.expanduser(file)
|
file = os.path.expanduser(file)
|
||||||
|
@ -311,7 +189,7 @@ class Client:
|
||||||
if not incl_servers:
|
if not incl_servers:
|
||||||
return
|
return
|
||||||
|
|
||||||
for s in self.servers:
|
for s in self.account.servers:
|
||||||
f.write(s.servid + '\n')
|
f.write(s.servid + '\n')
|
||||||
|
|
||||||
def remove_session(self, file: str = '~/.aternos') -> None:
|
def remove_session(self, file: str = '~/.aternos') -> None:
|
||||||
|
@ -331,193 +209,25 @@ class Client:
|
||||||
except OSError as err:
|
except OSError as err:
|
||||||
log.warning('Unable to delete session file: %s', err)
|
log.warning('Unable to delete session file: %s', err)
|
||||||
|
|
||||||
def list_servers(self, cache: bool = True) -> List[AternosServer]:
|
@staticmethod
|
||||||
"""Parses a list of your servers from Aternos website
|
def session_filename(username: str, sessions_dir: str = '~') -> str:
|
||||||
|
"""Generates a session file name
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
cache (bool, optional): If the function should use
|
username (str): Authenticated user
|
||||||
cached servers list (recommended)
|
sessions_dir (str, optional): Path to directory
|
||||||
|
with automatically saved sessions
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of AternosServer objects
|
Filename
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if cache and self.parsed:
|
# unsafe symbols replacement
|
||||||
return self.servers
|
repl = '_'
|
||||||
|
|
||||||
serverspage = self.atconn.request_cloudflare(
|
secure = re.sub(
|
||||||
f'{BASE_URL}/servers/', 'GET'
|
r'[^A-Za-z0-9_-]',
|
||||||
)
|
repl, username,
|
||||||
serverstree = lxml.html.fromstring(serverspage.content)
|
|
||||||
|
|
||||||
servers = serverstree.xpath(
|
|
||||||
'//div[@class="server-body"]/@data-id'
|
|
||||||
)
|
|
||||||
self.refresh_servers(servers)
|
|
||||||
|
|
||||||
# Update session file (add servers)
|
|
||||||
try:
|
|
||||||
self.save_session(self.saved_session)
|
|
||||||
except OSError as err:
|
|
||||||
log.warning('Unable to save servers list to file: %s', err)
|
|
||||||
|
|
||||||
return self.servers
|
|
||||||
|
|
||||||
def refresh_servers(self, ids: List[str]) -> None:
|
|
||||||
"""Replaces cached servers list creating
|
|
||||||
AternosServer objects by given IDs
|
|
||||||
|
|
||||||
Args:
|
|
||||||
ids (List[str]): Servers unique identifiers
|
|
||||||
"""
|
|
||||||
|
|
||||||
self.servers = []
|
|
||||||
for s in ids:
|
|
||||||
|
|
||||||
servid = s.strip()
|
|
||||||
if servid == '':
|
|
||||||
continue
|
|
||||||
|
|
||||||
log.debug('Adding server %s', servid)
|
|
||||||
srv = AternosServer(servid, self.atconn)
|
|
||||||
self.servers.append(srv)
|
|
||||||
|
|
||||||
self.parsed = True
|
|
||||||
|
|
||||||
def get_server(self, servid: str) -> AternosServer:
|
|
||||||
"""Creates a server object from the server ID.
|
|
||||||
Use this instead of list_servers
|
|
||||||
if you know the ID to save some time.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
AternosServer object
|
|
||||||
"""
|
|
||||||
|
|
||||||
return AternosServer(servid, self.atconn)
|
|
||||||
|
|
||||||
def logout(self) -> None:
|
|
||||||
"""Log out from Aternos account"""
|
|
||||||
|
|
||||||
self.atconn.request_cloudflare(
|
|
||||||
f'{AJAX_URL}/account/logout.php',
|
|
||||||
'GET', sendtoken=True
|
|
||||||
)
|
)
|
||||||
|
|
||||||
self.remove_session(self.saved_session)
|
return f'{sessions_dir}/.at_{secure}'
|
||||||
|
|
||||||
def change_username(self, value: str) -> None:
|
|
||||||
"""Changes a username in your Aternos account
|
|
||||||
|
|
||||||
Args:
|
|
||||||
value (str): New username
|
|
||||||
"""
|
|
||||||
|
|
||||||
self.atconn.request_cloudflare(
|
|
||||||
f'{AJAX_URL}/account/username.php',
|
|
||||||
'POST', data={'username': value}, sendtoken=True
|
|
||||||
)
|
|
||||||
|
|
||||||
def change_email(self, value: str) -> None:
|
|
||||||
"""Changes an e-mail in your Aternos account
|
|
||||||
|
|
||||||
Args:
|
|
||||||
value (str): New e-mail
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: If an invalid e-mail address
|
|
||||||
was passed to the function
|
|
||||||
"""
|
|
||||||
|
|
||||||
email = re.compile(
|
|
||||||
r'^[A-Za-z0-9\-_+.]+@[A-Za-z0-9\-_+.]+\.[A-Za-z0-9\-]+$|^$'
|
|
||||||
)
|
|
||||||
if not email.match(value):
|
|
||||||
raise ValueError('Invalid e-mail!')
|
|
||||||
|
|
||||||
self.atconn.request_cloudflare(
|
|
||||||
f'{AJAX_URL}/account/email.php',
|
|
||||||
'POST', data={'email': value}, sendtoken=True
|
|
||||||
)
|
|
||||||
|
|
||||||
def change_password(self, old: str, new: str) -> None:
|
|
||||||
"""Changes a password in your Aternos account
|
|
||||||
|
|
||||||
Args:
|
|
||||||
old (str): Old password
|
|
||||||
new (str): New password
|
|
||||||
"""
|
|
||||||
|
|
||||||
self.change_password_hashed(
|
|
||||||
Client.md5encode(old),
|
|
||||||
Client.md5encode(new),
|
|
||||||
)
|
|
||||||
|
|
||||||
def change_password_hashed(self, old: str, new: str) -> None:
|
|
||||||
"""Changes a password in your Aternos account.
|
|
||||||
Unlike `change_password`, this function
|
|
||||||
takes hashed passwords as arguments
|
|
||||||
|
|
||||||
Args:
|
|
||||||
old (str): Old password hashed with MD5
|
|
||||||
new (str): New password hashed with MD5
|
|
||||||
"""
|
|
||||||
|
|
||||||
self.atconn.request_cloudflare(
|
|
||||||
f'{AJAX_URL}/account/password.php',
|
|
||||||
'POST', data={
|
|
||||||
'oldpassword': old,
|
|
||||||
'newpassword': new,
|
|
||||||
}, sendtoken=True
|
|
||||||
)
|
|
||||||
|
|
||||||
def qrcode_2fa(self) -> Dict[str, str]:
|
|
||||||
"""Requests a secret code and
|
|
||||||
a QR code for enabling 2FA"""
|
|
||||||
|
|
||||||
return self.atconn.request_cloudflare(
|
|
||||||
f'{AJAX_URL}/account/secret.php',
|
|
||||||
'GET', sendtoken=True
|
|
||||||
).json()
|
|
||||||
|
|
||||||
def save_qr(self, qrcode: str, filename: str) -> None:
|
|
||||||
"""Writes a 2FA QR code into a png-file
|
|
||||||
|
|
||||||
Args:
|
|
||||||
qrcode (str): Base64 encoded png image from `qrcode_2fa()`
|
|
||||||
filename (str): Where the QR code image must be saved.
|
|
||||||
Existing file will be rewritten.
|
|
||||||
"""
|
|
||||||
|
|
||||||
data = qrcode.removeprefix('data:image/png;base64,')
|
|
||||||
png = base64.b64decode(data)
|
|
||||||
|
|
||||||
with open(filename, 'wb') as f:
|
|
||||||
f.write(png)
|
|
||||||
|
|
||||||
def enable_2fa(self, code: int) -> None:
|
|
||||||
"""Enables Two-Factor Authentication
|
|
||||||
|
|
||||||
Args:
|
|
||||||
code (int): 2FA code
|
|
||||||
"""
|
|
||||||
|
|
||||||
self.atconn.request_cloudflare(
|
|
||||||
f'{AJAX_URL}/account/twofactor.php',
|
|
||||||
'POST', data={
|
|
||||||
'code': code
|
|
||||||
}, sendtoken=True
|
|
||||||
)
|
|
||||||
|
|
||||||
def disable_2fa(self, code: int) -> None:
|
|
||||||
"""Disables Two-Factor Authentication
|
|
||||||
|
|
||||||
Args:
|
|
||||||
code (int): 2FA code
|
|
||||||
"""
|
|
||||||
|
|
||||||
self.atconn.request_cloudflare(
|
|
||||||
f'{AJAX_URL}/account/disbaleTwofactor.php',
|
|
||||||
'POST', data={
|
|
||||||
'code': code
|
|
||||||
}, sendtoken=True
|
|
||||||
)
|
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
"""Stores API session and sends requests"""
|
"""Stores API session and sends requests"""
|
||||||
|
|
||||||
|
import logging
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
@ -39,7 +40,6 @@ SEC_ALPHABET = string.ascii_lowercase + string.digits
|
||||||
|
|
||||||
|
|
||||||
class AternosConnect:
|
class AternosConnect:
|
||||||
|
|
||||||
"""Class for sending API requests,
|
"""Class for sending API requests,
|
||||||
bypassing Cloudflare and parsing responses"""
|
bypassing Cloudflare and parsing responses"""
|
||||||
|
|
||||||
|
@ -51,28 +51,6 @@ class AternosConnect:
|
||||||
self.token = ''
|
self.token = ''
|
||||||
self.atcookie = ''
|
self.atcookie = ''
|
||||||
|
|
||||||
def add_args(self, **kwargs) -> None:
|
|
||||||
"""Pass arguments to CloudScraper
|
|
||||||
session object __init__
|
|
||||||
if kwargs is not empty
|
|
||||||
"""
|
|
||||||
|
|
||||||
if len(kwargs) < 1:
|
|
||||||
log.debug('**kwargs is empty')
|
|
||||||
return
|
|
||||||
|
|
||||||
log.debug('New args for CloudScraper: %s', kwargs)
|
|
||||||
self.cf_init = partial(CloudScraper, **kwargs)
|
|
||||||
self.refresh_session()
|
|
||||||
|
|
||||||
def clear_args(self) -> None:
|
|
||||||
"""Clear CloudScarper object __init__ arguments
|
|
||||||
which was set using add_args method"""
|
|
||||||
|
|
||||||
log.debug('Creating session object with no keywords')
|
|
||||||
self.cf_init = partial(CloudScraper)
|
|
||||||
self.refresh_session()
|
|
||||||
|
|
||||||
def refresh_session(self) -> None:
|
def refresh_session(self) -> None:
|
||||||
"""Creates a new CloudScraper
|
"""Creates a new CloudScraper
|
||||||
session object and copies all cookies.
|
session object and copies all cookies.
|
||||||
|
@ -88,8 +66,6 @@ class AternosConnect:
|
||||||
is needed for most requests
|
is needed for most requests
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
RuntimeWarning: If the parser can not
|
|
||||||
find `<head>` tag in HTML response
|
|
||||||
TokenError: If the parser is unable
|
TokenError: If the parser is unable
|
||||||
to extract ajax token from HTML
|
to extract ajax token from HTML
|
||||||
|
|
||||||
|
@ -120,6 +96,7 @@ class AternosConnect:
|
||||||
pagehead = loginpage[headtag:headend]
|
pagehead = loginpage[headtag:headend]
|
||||||
|
|
||||||
js_code: Optional[List[Any]] = None
|
js_code: Optional[List[Any]] = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
text = pagehead.decode('utf-8', 'replace')
|
text = pagehead.decode('utf-8', 'replace')
|
||||||
js_code = re.findall(ARROW_FN_REGEX, text)
|
js_code = re.findall(ARROW_FN_REGEX, text)
|
||||||
|
@ -170,11 +147,14 @@ class AternosConnect:
|
||||||
)
|
)
|
||||||
|
|
||||||
return self.sec
|
return self.sec
|
||||||
|
|
||||||
def generate_sec_part(self) -> str:
|
def generate_sec_part(self) -> str:
|
||||||
"""Generates a part for SEC token"""
|
"""Generates a part for SEC token"""
|
||||||
|
|
||||||
return ''.join(secrets.choice(SEC_ALPHABET) for _ in range(11)) + ('0' * 5)
|
return ''.join(
|
||||||
|
secrets.choice(SEC_ALPHABET)
|
||||||
|
for _ in range(11)
|
||||||
|
) + ('0' * 5)
|
||||||
|
|
||||||
def request_cloudflare(
|
def request_cloudflare(
|
||||||
self, url: str, method: str,
|
self, url: str, method: str,
|
||||||
|
@ -237,22 +217,24 @@ class AternosConnect:
|
||||||
reqcookies['ATERNOS_SESSION'] = self.atcookie
|
reqcookies['ATERNOS_SESSION'] = self.atcookie
|
||||||
del self.session.cookies['ATERNOS_SESSION']
|
del self.session.cookies['ATERNOS_SESSION']
|
||||||
|
|
||||||
reqcookies_dbg = {
|
if log.level == logging.DEBUG:
|
||||||
k: str(v or '')[:3]
|
|
||||||
for k, v in reqcookies.items()
|
|
||||||
}
|
|
||||||
|
|
||||||
session_cookies_dbg = {
|
reqcookies_dbg = {
|
||||||
k: str(v or '')[:3]
|
k: str(v or '')[:3]
|
||||||
for k, v in self.session.cookies.items()
|
for k, v in reqcookies.items()
|
||||||
}
|
}
|
||||||
|
|
||||||
log.debug('Requesting(%s)%s', method, url)
|
session_cookies_dbg = {
|
||||||
log.debug('headers=%s', headers)
|
k: str(v or '')[:3]
|
||||||
log.debug('params=%s', params)
|
for k, v in self.session.cookies.items()
|
||||||
log.debug('data=%s', data)
|
}
|
||||||
log.debug('req-cookies=%s', reqcookies_dbg)
|
|
||||||
log.debug('session-cookies=%s', session_cookies_dbg)
|
log.debug('Requesting(%s)%s', method, url)
|
||||||
|
log.debug('headers=%s', headers)
|
||||||
|
log.debug('params=%s', params)
|
||||||
|
log.debug('data=%s', data)
|
||||||
|
log.debug('req-cookies=%s', reqcookies_dbg)
|
||||||
|
log.debug('session-cookies=%s', session_cookies_dbg)
|
||||||
|
|
||||||
if method == 'POST':
|
if method == 'POST':
|
||||||
sendreq = partial(
|
sendreq = partial(
|
||||||
|
|
|
@ -115,7 +115,8 @@ class NodeInterpreter(Interpreter):
|
||||||
self.proc.communicate()
|
self.proc.communicate()
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
log.warning(
|
log.warning(
|
||||||
'NodeJS process was not initialized'
|
'NodeJS process was not initialized, '
|
||||||
|
'but __del__ was called'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1,2 +1,4 @@
|
||||||
|
"""Creates a logger"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
log = logging.getLogger('aternos')
|
log = logging.getLogger('aternos')
|
||||||
|
|
17
python_aternos/atmd5.py
Normal file
17
python_aternos/atmd5.py
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
"""Contains a function for hashing"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
|
||||||
|
def md5encode(passwd: str) -> str:
|
||||||
|
"""Encodes the given string with MD5
|
||||||
|
|
||||||
|
Args:
|
||||||
|
passwd (str): String to encode
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Hexdigest hash of the string in lowercase
|
||||||
|
"""
|
||||||
|
|
||||||
|
encoded = hashlib.md5(passwd.encode('utf-8'))
|
||||||
|
return encoded.hexdigest().lower()
|
|
@ -50,8 +50,8 @@ class PlayersList:
|
||||||
# whl_je = whitelist for java
|
# whl_je = whitelist for java
|
||||||
# whl_be = whitelist for bedrock
|
# whl_be = whitelist for bedrock
|
||||||
# whl = common whitelist
|
# whl = common whitelist
|
||||||
common_whl = (self.lst == Lists.whl)
|
common_whl = self.lst == Lists.whl
|
||||||
bedrock = (atserv.is_bedrock)
|
bedrock = atserv.is_bedrock
|
||||||
|
|
||||||
if common_whl and bedrock:
|
if common_whl and bedrock:
|
||||||
self.lst = Lists.whl_be
|
self.lst = Lists.whl_be
|
||||||
|
|
|
@ -87,8 +87,6 @@ class AternosServer:
|
||||||
page = self.atserver_request(
|
page = self.atserver_request(
|
||||||
f'{BASE_URL}/server', 'GET'
|
f'{BASE_URL}/server', 'GET'
|
||||||
)
|
)
|
||||||
with open('server.html', 'wt') as f:
|
|
||||||
f.write(page.text)
|
|
||||||
match = status_re.search(page.text)
|
match = status_re.search(page.text)
|
||||||
|
|
||||||
if match is None:
|
if match is None:
|
||||||
|
|
|
@ -157,8 +157,8 @@ class AternosWss:
|
||||||
if not self.autoconfirm:
|
if not self.autoconfirm:
|
||||||
return
|
return
|
||||||
|
|
||||||
in_queue = (msg['class'] == 'queueing')
|
in_queue = msg['class'] == 'queueing'
|
||||||
pending = (msg['queue']['pending'] == 'pending')
|
pending = msg['queue']['pending'] == 'pending'
|
||||||
confirmation = in_queue and pending
|
confirmation = in_queue and pending
|
||||||
|
|
||||||
if confirmation and not self.confirmed:
|
if confirmation and not self.confirmed:
|
||||||
|
|
Reference in a new issue