diff --git a/docs/html/.buildinfo b/docs/html/.buildinfo new file mode 100644 index 0000000..45b0f78 --- /dev/null +++ b/docs/html/.buildinfo @@ -0,0 +1,4 @@ +# Sphinx build info version 1 +# This file hashes the configuration used when building these files. When it is not found, a full rebuild will be done. +config: 4ffd140bdbd0ea4e43c820baf9c3971f +tags: d77d1c0d9ca2f4c8421862c7c5a0d620 diff --git a/docs/html/.nojekyll b/docs/html/.nojekyll new file mode 100644 index 0000000..e69de29 diff --git a/docs/html/_modules/index.html b/docs/html/_modules/index.html new file mode 100644 index 0000000..88036e6 --- /dev/null +++ b/docs/html/_modules/index.html @@ -0,0 +1,102 @@ + + + + +
+ + +
+from bs4 import BeautifulSoup, Tag
+import re
+from datetime import datetime
+import math
+
+from .server import MCServer
+from .session import Session, InvalidSessionException
+
+# The changing of the password and profile info, are intentionally not implemented.
+# It's just too much power
+
+datetime_format = "%Y-%m-%d %H:%M:%S"
+
+[docs]class Account:
+ """Used to get servers, history, balance and other details associated to a specific account.
+ """
+
+[docs] def __init__(self, email: str = None, password: str = None, session: Session = None):
+ """Initializes an account
+
+ :param email: email used to login, defaults to None
+ :type email: str, optional
+ :param password: password used to login, defaults to None
+ :type password: str, optional
+ :param session: an already created session can be provided, defaults to None
+ :type session: :class:`Session <Session>`, optional
+ :raises InvalidSessionException: Raised when the given custom session is invalid or when nothing was provided
+ """
+
+ self.email = email
+ self.session = None
+ if email is not None and password is not None:
+ self.session = Session(email, password)
+ elif session is not None:
+ self.session = session
+
+ if self.session is None or not self.session.isValid():
+ raise InvalidSessionException()
+
+
+
+[docs] def getServers(self) -> list:
+ """Returns a list of minecraft server objects.
+
+ :return: List of MCServer
+ """
+ control_res = self.session.get("/mano-serveriai")
+ servers = []
+ for server_id in re.findall("/minecraft-serverio-valdymas/(\d*)/", control_res.text):
+ servers.append(MCServer(server_id, self.session))
+ return servers
+
+[docs] def getServer(self, i: int = 0) -> MCServer:
+ """Helper method get a server quickly.
+ Most people will only want to interact with 1 server.
+
+ :param i: Index of server (0 indexed), defaults to 0
+ :type i: int, optional
+ :return: A MCServer
+ """
+ return self.getServers()[i]
+
+[docs] def getBalance(self) -> float:
+ """Current balance in account.
+
+ :return: A float representing the current balance.
+ """
+ balance_res = self.session.get("/balanso-pildymas")
+ balance_match = re.search(r"balanse yra (\d+\.?\d*)", balance_res.text)
+ return float(balance_match.group(1))
+
+[docs] def getDetails(self) -> dict:
+ """Returns a dictionary containing general details about account.
+ Available details: email, name, surname, phone, skype.
+
+ :return: A dictionary with account details.
+ """
+ profile_res = self.session.get("/profilio-nustatymai")
+ soup = BeautifulSoup(profile_res.text, "lxml")
+ return {
+ "email": re.search("Vartotojas: ([\w\.-]+@[\w\.-]+)", profile_res.text).group(1),
+ "name": soup.find("input", id="v1")["value"],
+ "surname": soup.find("input", id="v2")["value"],
+ "phone": soup.find("input", id="v4")["value"],
+ "skype": soup.find("input", id="v5")["value"]
+ }
+
+[docs] def getLoginHistory(self, limit: int = math.inf) -> list:
+ """Returns a list of entries, where each entry holds the date and ip of who logged in.
+ Entry keys: date, ip.
+
+ :param limit: The maximum number of entries it should try getting, defaults to math.inf
+ :type limit: int, optional
+ :return: A list of entries where each entry is a dictionary.
+ """
+ history_res = self.session.get("/istorija/prisijungimu-istorija")
+ soup = BeautifulSoup(history_res.text, "lxml")
+ history = []
+
+ for entry in soup.find("table").children:
+ if type(entry) != Tag: continue
+
+ fields = entry.findAll("td", align="center")
+ if len(fields) != 3: continue
+
+ history.append({
+ "date": datetime.strptime(fields[1].text, datetime_format),
+ "ip": fields[2].text
+ })
+ if len(history) >= limit: break
+
+ return history
+
+[docs] def getFinanceHistory(self, limit: int = math.inf) -> list:
+ """Returns a list of entries where each entry describes a transaction.
+ Entry keys: date, action, balance_change, balance_remainder
+
+ :param limit: The maximum number of entries it should try getting, defaults to math.inf
+ :type limit: int, optional
+ :return: A list of entries where each entry is a dictionary.
+ """
+ history_res = self.session.get("/balanso-pildymas/ataskaita")
+ soup = BeautifulSoup(history_res.text, "lxml")
+ history = []
+
+ for entry in soup.find("table").children:
+ if type(entry) != Tag: continue
+
+ fields = entry.findAll("td")
+ if len(fields) != 4: continue
+
+ history.append({
+ "date": datetime.strptime(fields[0].text, datetime_format),
+ "action": fields[1].text,
+ "balance_change": float(fields[2].text[:-4]),
+ "balance_remainder": float(fields[3].text[:-3]),
+ })
+ if len(history) >= limit: break
+
+ return history
+
+[docs] def getProfileDetailsHistory(self, limit: int = math.inf) -> list:
+ """Returns a list of entries where each entry describes what was changed and by who.
+ Entry keys: date, name, surname, phone, skype, ip.
+
+ :param limit: The maximum number of entries it should try getting, defaults to math.inf
+ :type limit: int, optional
+ :return: A list of entries where each entry is a dictionary.
+ """
+ history_res = self.session.get("/profilio-nustatymai")
+ soup = BeautifulSoup(history_res.text, "lxml")
+ history = []
+
+ for entry in soup.find("table").children:
+ if type(entry) != Tag: continue
+
+ fields = entry.findAll("td")
+ if len(fields) != 6: continue
+
+ history.append({
+ "date": datetime.strptime(fields[0].text, datetime_format),
+ "name": fields[1].text,
+ "surname": fields[2].text,
+ "phone": fields[3].text,
+ "skype": fields[4].text,
+ "ip": fields[5].text
+ })
+ if len(history) >= limit: break
+
+ return history
+
+from bs4 import BeautifulSoup, Tag
+import re
+import ftplib
+import datetime
+import paramiko
+import time
+
+from .session import Session
+
+[docs]class InvalidDomainException(Exception):
+ """Raised when trying to change a server address with an unsupported domain.
+ """
+ pass
+
+
+# TODO: Make the sending of commands not need to sleep inbetween, If It dosen't sleep then it dosen'y always send the commands to the server
+[docs]class CommandSender:
+ """Used to directly send commands to a minecraft server.
+ Rather than loggin into ssh, entering console manually.
+ """
+
+ def __init__(self, host: str, password: str, port: int = 22):
+ """Initializes ssh client and tries connecting to given host
+
+ :param host: Host address used while connecting to ssh
+ :type host: str
+ :param password: Password used for authentication
+ :type password: str
+ :param port: Port used while connecting to ssh, defaults to 22
+ :type port: int, optional
+ """
+ self.ssh = paramiko.SSHClient()
+ self.ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
+ self.ssh.connect(host, port=port, username="console", password=password)
+ self.channel = None
+
+ def __enter__(self):
+ self.open()
+ return self
+
+ def __exit__(self, _1, _2, _3):
+ self.close()
+
+ def __del__(self):
+ self.ssh.close()
+
+[docs] def send(self, *args):
+ """Send commands to the server. You are able to send to multiple commands by giving multiple arguments.
+
+ :param \*args: Commands you would like the server to execute.
+ :type \*args: str
+
+ :raises Exception: Raised if channel is not open
+ """
+ if self.channel is None:
+ raise Exception("Channel is not open")
+
+ self.channel.sendall("\n".join(args)+"\n")
+ time.sleep(0.5)
+
+[docs] def open(self):
+ """Opens a channel used to send commands.
+
+ :raises Exception: Raised if channel is already open
+ """
+ if self.channel is not None:
+ raise Exception("Channel is already open")
+
+ self.channel = self.ssh.invoke_shell()
+ self.channel.sendall("console\n")
+ time.sleep(0.5)
+
+[docs] def close(self):
+ """Closes channel used to send commands.
+
+ :raises Exception: Raised if channel is already closed
+ """
+ if self.channel is None:
+ raise Exception("Channel is not open")
+
+ self.channel.close()
+ self.channel = None
+
+# TODO: A way to edit the server.properties file easier.
+[docs]class MCServer:
+ """Used to control a minehost minecraft server
+ """
+
+ __attrs__ = [ "start_file" ]
+
+[docs] def __init__(self, server_id: str, session: Session):
+ """Initializes minecraft server instance. Retrieves some initial data like: address, ip and port.
+
+ :param server_id: [description]
+ :type server_id: str
+ :param session: [description]
+ :type session: Session
+ """
+ self.id = server_id
+ self.session = session
+
+ info_res = self.session.get(self.__url)
+ soup = BeautifulSoup(info_res.text, "lxml")
+
+ info_rows = soup.select("table td[align=right] b")
+ self.address = info_rows[6].text.split(":")[0]
+ self.ip, self.port = info_rows[7].text.split(":")
+ self.port = int(self.port)
+
+ control_res = self.session.get(f"{self.__url}/valdymas")
+ soup = BeautifulSoup(control_res.text, "lxml")
+ self.__key = soup.find("input", id="khd")["value"]
+
+
+
+ @property
+ def __url(self) -> str:
+ return f"/minecraft-serverio-valdymas/{self.id}"
+
+[docs] def getPassword(self) -> str:
+ """Retrieves the password used to login to SSH, FTP and SFTP.
+
+ :return: A string containing the password
+ """
+ res = self.session.get(f"{self.__url}/failai")
+ soup = BeautifulSoup(res.text, "lxml")
+ return soup.select("table td span:nth-child(2)")[0].text
+
+[docs] def changePassword(self) -> bool:
+ """Change the current password to a new randomly generated password. Passwords can only be changed every few minutes.
+
+ :return: Whether the change was successful
+ """
+ res = self.session.get(f"{self.__url}/failai/change-password")
+ return res.text.find("class=\"alert alert-info\"") > 0
+
+[docs] def getStats(self) -> dict:
+ """Returns a dictionary containing the current statistcs of the server.
+
+ :return: A dictionary containing "state", "version", "ping", "online_players" and "max_players"
+ """
+ res = self.session.get(self.__url)
+ soup = BeautifulSoup(res.text, "lxml")
+
+ version = soup.find(id="mc_versija").text
+ version = version != "-" and version or None
+
+ ping = soup.find(id="mc_ping").text
+ ping = ping != "-" and int(ping) or None
+
+ online_players = soup.find(id="mc_online").text
+ online_players = online_players != "-" and int(online_players) or None
+
+ max_players = soup.find(id="mc_zaidejai").text
+ max_players = max_players != "-" and int(max_players) or None
+
+ return {
+ "state": soup.find(id="mc_status").text,
+ "version": version,
+ "ping": ping,
+ "online_players": online_players,
+ "max_players": max_players
+ }
+
+[docs] def getExpirationDate(self) -> datetime.datetime:
+ """Returns the date at which the server will expire.
+
+ :return: A datetime object
+ """
+ res = self.session.get(f"{self.__url}/finansai")
+ date_match = re.search(r"Serveris galioja iki: (\d{4}-\d{2}-\d{2} \d{1,2}:\d{2}:\d{2})", res.text)
+ return datetime.datetime.strptime(date_match.group(1), "%Y-%m-%d %H:%M:%S")
+
+ @property
+ def start_file(self):
+ response = self.session.get(f"{self.__url}/paleidimo-failas")
+ soup = BeautifulSoup(response.text, "lxml")
+ return soup.find("input", id="startlinein").text
+
+ @start_file.setter
+ def start_file(self, start_file: str):
+ self.session.post(f"{self.__url}/mc-versijos", data={
+ "startfile": str(start_file)
+ })
+
+ @property
+ def ssh_port(self):
+ return self.port+1
+
+ @property
+ def sftp_port(self):
+ return self.port+1
+
+ @property
+ def ftp_port(self):
+ return self.port+3
+
+ @property
+ def short_address(self):
+ info_res = self.session.get(self.__url)
+ soup = BeautifulSoup(info_res.text, "lxml")
+ return soup.find(id="shortaddr").text
+
+ @short_address.setter
+ def short_address(self, short_address: str):
+ domains = (".minehost.lt", ".hotmc.eu")
+ subdomain, domain_index = None, None
+
+ for domain in domains:
+ found_index = short_address.find(domain)
+ if found_index == len(short_address)-len(domain):
+ domain_index = domains.index(domain)
+ subdomain = short_address[:found_index]
+ break
+
+ if domain_index is None:
+ raise InvalidDomainException()
+
+ res = self.session.get("/query/subdomenas.php", params={
+ "f": self.id,
+ "s": subdomain,
+ "t": domain_index
+ })
+ if res.content.decode("UTF-8") == "Šis subdomenas jau užimtas. Bandykite kitą":
+ raise InvalidDomainException()
+
+[docs] def FTP(self) -> ftplib.FTP:
+ """Creates a new FTP connection to the server. The password will be automatically inputted.
+
+ :return: An open FTP connection
+ """
+ ftp = ftplib.FTP()
+ ftp.connect(self.ip, self.ftp_port)
+ ftp.login("serveris", self.getPassword())
+ return ftp
+
+[docs] def SSH(self) -> paramiko.SSHClient:
+ """Creates a new SSH connection to the server. The password will be automatically inputted.
+
+ :return: An open SSH connection
+ """
+ ssh = paramiko.SSHClient()
+ ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
+ ssh.connect(self.ip, port=self.ssh_port, username="console", password=self.getPassword())
+ return ssh
+
+[docs] def CommandSender(self) -> CommandSender:
+ """Creates a new CommandSender which allow you to easily send commands to the console.
+
+ :return: The newly created CommandSender.
+ """
+ return CommandSender(self.ip, self.getPassword(), self.ssh_port)
+
+ def __isControlLocked(self):
+ res = self.session.post("/query/user_loadingct.php", data={
+ "k": self.__key
+ })
+ return res.text != ""
+
+[docs] def start(self) -> bool:
+ """Tries starting the server.
+
+ :return: Whether if it succeded in starting the server.
+ """
+ if self.__isControlLocked():
+ return False
+
+ self.session.post(f"/query/server_ct.php", data={
+ "k": self.__key,
+ "c": "startmc",
+ "st": "mc"
+ })
+ return True
+
+[docs] def stop(self) -> bool:
+ """Tries stopping the server.
+
+ :return: Whether if it succeded in stopping the server.
+ """
+ if self.__isControlLocked():
+ return False
+
+ self.session.post(f"/query/server_ct.php", data={
+ "k": self.__key,
+ "c": "stopmc",
+ "st": "mc"
+ })
+ return True
+
+[docs] def kill(self) -> bool:
+ """Tries killing the server. This is the same as stopping but killing dosen't save anything.
+
+ :return: Whether if it succeded in killing the server.
+ """
+ if self.__isControlLocked():
+ return False
+
+ self.session.post(f"/query/server_ct.php", data={
+ "k": self.__key,
+ "c": "killrestartmc",
+ "st": "mc"
+ })
+ return True
+
+
+import requests
+
+[docs]class IncorrectLoginException(Exception):
+ """Raised when the given email and passwords are incorrect.
+ """
+ pass
+
+
+[docs]class InvalidSessionException(Exception):
+ """Raised when the current session is logged out or timed out.
+ """
+ pass
+
+
+[docs]class Session(requests.Session):
+ """Wrapper object around the requests.Session.
+ Checks if it's logged on every request.
+ """
+
+[docs] def __init__(self, email: str = None, password: str = None):
+ """Initializes session if email and password are given.
+
+ :param email: If provided with password will try to login.
+ :type email: str, optional
+ :param password: If provided with email will try to login.
+ :type password: str, optional
+ """
+ super().__init__()
+ if email is not None and password is not None:
+ self.login(email, password)
+
+[docs] def login(self, email: str, password: str):
+ """Attempts to login using the given credentials.
+
+ :param email: Email used to login.
+ :type email: str
+ :param password: Password associated to given email.
+ :type password: str
+ :raises IncorrectLoginException: Raised on failed login.
+ """
+ requests.Session.request(self, "POST", "https://minehost.lt/prisijungimas-prie-sistemos", data = {
+ "login": email,
+ "password": password
+ })
+ if not self.isValid():
+ raise IncorrectLoginException()
+
+[docs] def logout(self):
+ """The session becomes invalid and will raise error if tried to request anything.
+ """
+ self.get("/logout")
+
+[docs] def request(self, method, url, *args, **kvargs) -> requests.Response:
+ """Wrapper function around the default requests.request method.
+ Instead of giving the whole url like "https://minehost.lt/logout" now you only need to write "/logout".`
+
+ :return: requests.Response object
+ """
+ response = requests.Session.request(self, method, "https://minehost.lt"+url, *args, **kvargs)
+ # Basic check to see if the session is still logged in. Looks for the logout button.
+ if response.text.find("href=\"/logout\"") == -1 and response.text.find("src=\"/img/logo.png\"") > 0:
+ raise InvalidSessionException()
+
+ return response
+
+[docs] def isValid(self) -> bool:
+ """Returns true if current session is logged in, otherwise false.
+
+ :return: True if logged in.
+ """
+ res = self.get("/valdymo-pultas")
+ return res.text.find("<meta http-equiv=\"refresh\" content=\"0;url=/prisijungimas-prie-sistemos\">") == -1
+
+