From 733c02cc856060c54b53929eb74e1a000ad9b8d3 Mon Sep 17 00:00:00 2001 From: Rokas Puzonas Date: Fri, 31 Jul 2020 01:59:09 +0300 Subject: [PATCH] Initial commit --- .gitignore | 3 + .vscode/settings.json | 3 + LICENSE | 21 ++++ README.md | 3 + minehost/__init__.py | 3 + minehost/account.py | 114 +++++++++++++++++++++ minehost/server.py | 224 ++++++++++++++++++++++++++++++++++++++++++ minehost/session.py | 42 ++++++++ 8 files changed, 413 insertions(+) create mode 100644 .gitignore create mode 100644 .vscode/settings.json create mode 100644 LICENSE create mode 100644 README.md create mode 100644 minehost/__init__.py create mode 100644 minehost/account.py create mode 100644 minehost/server.py create mode 100644 minehost/session.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a3d1833 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__ +*.pyc +*.ignore \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..4a5a4cc --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "autoDocstring.docstringFormat": "sphinx" +} \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d3ca41e --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Rokas Puzonas + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..5d53a3d --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# MineHost Interface + +A python library for executing commands or gettings information on MineHost minecraft servers. \ No newline at end of file diff --git a/minehost/__init__.py b/minehost/__init__.py new file mode 100644 index 0000000..2038635 --- /dev/null +++ b/minehost/__init__.py @@ -0,0 +1,3 @@ +from .server import MCServer +from .account import Account +from .session import Session, IncorrectLoginException, InvalidSessionException \ No newline at end of file diff --git a/minehost/account.py b/minehost/account.py new file mode 100644 index 0000000..5e99f2b --- /dev/null +++ b/minehost/account.py @@ -0,0 +1,114 @@ +from bs4 import BeautifulSoup, Tag +import re +from datetime import datetime + +from .server import MCServer +from .session import Session + +# 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" + +class Account: + def __init__(self, username: str = None, password: str = None, session: Session = None): + self.username = username + if username is not None and password is not None: + self.session = Session(username, password) + elif session is not None: + self.session = session + else: + raise Exception("Session or logins must be given") + + def __repr__(self): + return f"" + + def getServers(self): + 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 + + def getServer(self, i: int = 0): + return self.getServers()[i] + + def getBalance(self): + 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)) + + def getProfileInfo(self): + 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"] + } + + def getLoginHistory(self, limit: int = 10): + 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 + + def getFinanceHistory(self, limit: int = 10): + 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 + + def getProfileInfoHistory(self, limit: int = 10): + 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 + diff --git a/minehost/server.py b/minehost/server.py new file mode 100644 index 0000000..d058361 --- /dev/null +++ b/minehost/server.py @@ -0,0 +1,224 @@ +from bs4 import BeautifulSoup, Tag +import re +from ftplib import FTP +from datetime import datetime +import paramiko +import time + +datetime_format = "%Y-%m-%d %H:%M:%S" + + +class InvalidDomainException(Exception): + 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 +class CommandSender: + def __init__(self, host, username, password, port=22): + 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): + if self.channel is None: + self.open() + return self + + def __exit__(self, _1, _2, _3): + if self.channel is not None: + self.close() + + def __del__(self): + self.ssh.close() + + def send(self, *args): + if self.channel is None: + raise Exception("Channel is not open") + + self.channel.sendall("\n".join(args)+"\n") + time.sleep(0.5) + + def open(self): + 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) + + def close(self): + if self.channel is None: + raise Exception("Channel is not open") + + self.channel.close() + self.channel = None + +class MCServer: + def __init__(self, server_id, 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"] + + def __repr__(self): + return f"" + + @property + def __url(self): + return f"/minecraft-serverio-valdymas/{self.id}" + + def getPassword(self): + res = self.session.get(f"{self.__url}/failai") + soup = BeautifulSoup(res.text, "lxml") + return soup.select("table td span:nth-child(2)")[0].text + + def changePassword(self): + res = self.session.get(f"{self.__url}/failai/change-password") + return res.text.find("class=\"alert alert-info\"") > 0 + + def getInfo(self): + 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 + } + + def getExpirationDate(self): + 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.strptime(date_match.group(1), datetime_format) + + @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() + + def FTP(self): + ftp = FTP() + ftp.connect(self.ip, self.ftp_port) + ftp.login("serveris", self.getPassword()) + return ftp + + def SSH(self): + 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 + + def CommandSender(self): + return CommandSender(self.ip, "serveris", self.getPassword(), self.ssh_port) + + def __isControlLocked(self): + res = self.session.post("/query/user_loadingct.php", data={ + "k": self.__key + }) + return res.text != "" + + def start(self): + if self.__isControlLocked(): + return False + + self.session.post(f"/query/server_ct.php", data={ + "k": self.__key, + "c": "startmc", + "st": "mc" + }) + return True + + def stop(self): + if self.__isControlLocked(): + return False + + self.session.post(f"/query/server_ct.php", data={ + "k": self.__key, + "c": "stopmc", + "st": "mc" + }) + return True + + def kill(self): + if self.__isControlLocked(): + return False + + self.session.post(f"/query/server_ct.php", data={ + "k": self.__key, + "c": "killrestartmc", + "st": "mc" + }) + return True + diff --git a/minehost/session.py b/minehost/session.py new file mode 100644 index 0000000..60e6873 --- /dev/null +++ b/minehost/session.py @@ -0,0 +1,42 @@ +import requests + +# The "Cookies" functions are used, when you already have dictionary of cookies, +# So that the function internally would not need to create one + +class IncorrectLoginException(Exception): + pass + + +class InvalidSessionException(Exception): + pass + + +class Session(requests.Session): + def __init__(self, username: str = None, password: str = None): + super().__init__() + if username is not None and password is not None: + self.login(username, password) + + def login(self, username: str, password: str): + requests.Session.request(self, "POST", "https://minehost.lt/prisijungimas-prie-sistemos", data = { + "login": username, + "password": password + }) + if not self.isValid(): + raise IncorrectLoginException() + + def logout(self): + self.get("/logout") + + def request(self, method, url, *args, **kvargs): + 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 + + def isValid(self): + res = self.get("/valdymo-pultas") + return res.text.find("") == -1 +