commit a670191b312c76499e67f4e5e4a5ada58d83489f Author: Peery Date: Sat Sep 27 15:00:54 2025 +0200 v0.1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3d1895a --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +# Default ignored files +/shelf/ +/workspace.xml +# contain secrets +*.env +application/mc-auth.config + +# test-env data +test-env/data +test-env/config diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..dd0b6c8 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +FROM python:3.13-slim + +# User setup +RUN adduser -u 1001 python_user + +RUN mkdir /home/python_user/app && chown python_user:python_user /home/python_user/app +WORKDIR /home/python_user/app/ + +# Copy application files +COPY ./application /home/python_user/app +RUN rm -rf /home/python_user/app/test-env \ + /home/python_user/app/.venv \ + /home/python_user/app/mc-auth.config +RUN chown -R python_user:python_user /home/python_user/app + +USER python_user + +# Install python requirements +#COPY ./requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + + +CMD ["python", "start.py"] diff --git a/application/.idea/DiscordMinecraftAuthenticator.iml b/application/.idea/DiscordMinecraftAuthenticator.iml new file mode 100644 index 0000000..423c6b9 --- /dev/null +++ b/application/.idea/DiscordMinecraftAuthenticator.iml @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/application/.idea/inspectionProfiles/Project_Default.xml b/application/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..b8b46aa --- /dev/null +++ b/application/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,24 @@ + + + + \ No newline at end of file diff --git a/application/.idea/inspectionProfiles/profiles_settings.xml b/application/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/application/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/application/.idea/misc.xml b/application/.idea/misc.xml new file mode 100644 index 0000000..3494186 --- /dev/null +++ b/application/.idea/misc.xml @@ -0,0 +1,10 @@ + + + + + + + + \ No newline at end of file diff --git a/application/.idea/modules.xml b/application/.idea/modules.xml new file mode 100644 index 0000000..277a448 --- /dev/null +++ b/application/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/application/.idea/vcs.xml b/application/.idea/vcs.xml new file mode 100644 index 0000000..6c0b863 --- /dev/null +++ b/application/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/application/.idea/workspace.xml b/application/.idea/workspace.xml new file mode 100644 index 0000000..cbfa7f9 --- /dev/null +++ b/application/.idea/workspace.xml @@ -0,0 +1,189 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + { + "associatedIndex": 3 +} + + + + { + "keyToString": { + "ModuleVcsDetector.initialDetectionPerformed": "true", + "Python.DB.executor": "Run", + "Python.MinecraftAPI.executor": "Debug", + "Python.MinecraftServerAPI.executor": "Run", + "Python.start.executor": "Run", + "RunOnceActivity.ShowReadmeOnStart": "true", + "RunOnceActivity.git.unshallow": "true", + "git-widget-placeholder": "main", + "last_opened_file_path": "/home/peery/Software_Projects/DiscordMinecraftAuthenticator/application", + "settings.editor.selected.configurable": "project.propVCSSupport.DirectoryMappings" + } +} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1758723830290 + + + + + + + + + + + \ No newline at end of file diff --git a/application/__init__.py b/application/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/application/__pycache__/version.cpython-313.pyc b/application/__pycache__/version.cpython-313.pyc new file mode 100644 index 0000000..089b52c Binary files /dev/null and b/application/__pycache__/version.cpython-313.pyc differ diff --git a/application/config/__init__.py b/application/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/application/config/__pycache__/__init__.cpython-313.pyc b/application/config/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..e76a819 Binary files /dev/null and b/application/config/__pycache__/__init__.cpython-313.pyc differ diff --git a/application/config/__pycache__/config.cpython-313.pyc b/application/config/__pycache__/config.cpython-313.pyc new file mode 100644 index 0000000..0784dcc Binary files /dev/null and b/application/config/__pycache__/config.cpython-313.pyc differ diff --git a/application/config/config.py b/application/config/config.py new file mode 100644 index 0000000..bd9a229 --- /dev/null +++ b/application/config/config.py @@ -0,0 +1,96 @@ +import logging +import os.path +from typing import List, Dict + +import discord +import yaml + +class ConfigLoader: + """ + Loads the configuration data + """ + DEFAULT_CONFIG_LOCATION = "mc-auth.config" + _CONF_INST = None + + def __init__(self, path=None): + ConfigLoader._CONF_INST = self + self.log = logging.getLogger("Conf") + try: + path = os.environ.get('MC_AUTH_CONFIG_LOCATION') if os.environ.get('MC_AUTH_CONFIG_LOCATION') is not None else path + except KeyError: + pass + self.conf_file_path = os.path.join(os.getcwd(), ConfigLoader.DEFAULT_CONFIG_LOCATION) if path is None else path + assert(os.path.isfile(self.conf_file_path)) + + self.config: dict = dict() + self.read_config() + + self.__auth_guilds_per_domain: Dict[str, List[discord.Guild]] = None + self.__auth_guilds: List[discord.Guild] = None + self.__whitelist_locations_per_domain: Dict[str, str] = None + + async def load_guilds(self, bot): + if self.__auth_guilds_per_domain is not None: + return + self.log.debug("Loading all auth_guild objects from discord API") + self.__auth_guilds_per_domain = dict() + guilds_bot_is_in: List[discord.Guild] = bot.fetch_guilds() + for domain in self.config["minecraft"]["domains"]: + gs = [] + for gid in self.config["minecraft"]["domains"][domain]["auth_guilds"]: + async for guild in guilds_bot_is_in: + if guild.id == int(gid): + gs.append(guild) + self.__auth_guilds_per_domain[domain] = gs + + @staticmethod + def get_config_loader(path=None): + if ConfigLoader._CONF_INST is None: + ConfigLoader(path) + return ConfigLoader._CONF_INST + + def read_config(self): + self.log.debug(f"Reading config \"{self.conf_file_path}\"") + self.config = yaml.safe_load(open(self.conf_file_path, 'r')) + + @property + def auth_guilds_per_domain(self) -> Dict[str, List[discord.Guild]]: + if self.__auth_guilds_per_domain is None: + self.log.critical("Looked up ConfigLoader.auth_guilds before it could be loaded by the DiscordBot!") + raise ValueError("Looked up ConfigLoader.auth_guilds before it could be loaded by the DiscordBot!") + return self.__auth_guilds_per_domain + + @property + def auth_guilds(self) -> List[discord.Guild]: + if self.__auth_guilds is None: + self.__auth_guilds = list() + for domain in self.auth_guilds_per_domain: + self.__auth_guilds += self.auth_guilds_per_domain[domain] + self.__auth_guilds = list(set(self.__auth_guilds)) + return self.__auth_guilds + + @property + def whitelist_location_per_domain(self) -> Dict[str, str]: + if self.__whitelist_locations_per_domain is None: + self.__whitelist_locations_per_domain = dict() + for domain in self.config["minecraft"]["domains"]: + self.__whitelist_locations_per_domain[domain] = self.config["minecraft"]["domains"][domain]["whitelist_location"] + return self.__whitelist_locations_per_domain + + @property + def default_minecraft_domain(self) -> str: + return self.config["minecraft"]["default_domain"] + + @property + def database_host(self) -> str: + return self.config["database"]["host"] + + @property + def database_port(self) -> str: + return self.config["database"]["port"] + + def post_application_text(self, domain: str) -> str: + return self.config["minecraft"]["domains"][domain]["post_application_text"] + + def roles_with_server_access(self, domain: str) -> List[str]: + return self.config["minecraft"]["domains"][domain]["roles_with_server_access"] \ No newline at end of file diff --git a/application/database/DB.py b/application/database/DB.py new file mode 100644 index 0000000..525e700 --- /dev/null +++ b/application/database/DB.py @@ -0,0 +1,99 @@ +import os +from typing import List + +import sqlalchemy.engine +from sqlalchemy import String, create_engine, URL, Table, Column, ForeignKey +from sqlalchemy.orm import DeclarativeBase, Mapped, relationship, Session +from sqlalchemy.sql.expression import select +from sqlalchemy.testing.schema import mapped_column + +from config.config import ConfigLoader + +def get_engine() -> sqlalchemy.engine.Engine: + url_obj = URL.create( + "postgresql", + username=os.environ.get('POSTGRES_USER'), + password=os.environ.get('POSTGRES_PASSWORD'), + database=os.environ.get('POSTGRES_DB'), + host=ConfigLoader.get_config_loader().database_host, + port=ConfigLoader.get_config_loader().database_port + ) + e = create_engine(url_obj) + return e + +class Base(DeclarativeBase): + pass + +mc_users_on_mc_server = Table( + "mc_users_on_mc_server", + Base.metadata, + Column("minecraft_user", ForeignKey("minecraft_users.uuid"), primary_key=True), + Column("minecraft_server", ForeignKey("minecraft_servers.domain"), primary_key=True) +) + +class DiscordUser(Base): + __tablename__ = "discord_users" + + snowflake_id: Mapped[str] = mapped_column(primary_key=True) + handle: Mapped[str] = mapped_column(String(32)) + + minecraft_user_uuid = mapped_column(ForeignKey("minecraft_users.uuid")) + + def __repr__(self): + return f"User(snowflake_id={self.snowflake_id}, handle={self.handle})" + +class MinecraftUser(Base): + __tablename__ = "minecraft_users" + + uuid: Mapped[String] = mapped_column(String(32), primary_key=True) + user_name: Mapped[String] = mapped_column(String(16), primary_key=True) + + #discord_user = mapped_column(ForeignKey("discord_users.snowflake_id")) + minecraft_servers: Mapped[List["MinecraftServer"]] = relationship(secondary=mc_users_on_mc_server, back_populates="minecraft_users") + + def __repr__(self): + return f"MCUser(uuid={self.uuid}, user_name={self.user_name})" + +class MinecraftServer(Base): + __tablename__ = "minecraft_servers" + + domain: Mapped[str] = mapped_column(String(64), primary_key=True) + minecraft_users: Mapped[List["MinecraftUser"]] = relationship(secondary=mc_users_on_mc_server, back_populates="minecraft_servers") + + def __repr__(self): + return f"MinecraftServer(domain={self.domain}, discord_user={self.discord_user})" + +if __name__ == "__main__": + url_obj = URL.create( + "postgresql", + username=os.environ.get('POSTGRES_USER'), + password=os.environ.get('POSTGRES_PASSWORD'), + database=os.environ.get('POSTGRES_DB'), + host=ConfigLoader.get_config_loader().database_host, + port=ConfigLoader.get_config_loader().database_port + ) + engine = create_engine(url_obj, echo=True) + + with Session(engine) as session: + pass + #mc_server = MinecraftServer(domain="feather-mc.pandro.de") + #uuid = MinecraftAPI.get_uuid_from_user_name("Pandr0") + #mc_user = MinecraftUser(uuid=uuid, + # user_name="Pandr0" + # ) + #disc_user = DiscordUser(snowflake_id="682326483921797146", + # handle="pandro_", + # minecraft_user_uuid=mc_user.uuid) + #stmt = update(mc_users_on_mc_server).where().values() + #session.execute(stmt) + #stmt = insert(mc_users_on_mc_server).values(minecraft_user=uuid, minecraft_server="feather-mc.pandro.de") + #session.execute(stmt) + #session.commit() + stmt = select(MinecraftUser).where(MinecraftUser.user_name == "Pandr0") + mc_user = session.execute(stmt).first()[0] + stmt = select(MinecraftServer).where(MinecraftServer.minecraft_users.any(MinecraftUser.user_name == "Pandr0")) # TODO figure out how to query minecraft servers with a given minecraft user + result = session.execute(stmt).all() + + print(f"PG user: {os.environ.get('POSTGRES_USER')}") + #print(f"PG pw: {os.environ.get('POSTGRES_PASSWORD')}") + print(f"PG db: {os.environ.get('POSTGRES_DB')}") diff --git a/application/database/__init__.py b/application/database/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/application/database/__pycache__/DB.cpython-313.pyc b/application/database/__pycache__/DB.cpython-313.pyc new file mode 100644 index 0000000..5245dd7 Binary files /dev/null and b/application/database/__pycache__/DB.cpython-313.pyc differ diff --git a/application/database/__pycache__/__init__.cpython-313.pyc b/application/database/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..925706f Binary files /dev/null and b/application/database/__pycache__/__init__.cpython-313.pyc differ diff --git a/application/discord_bot/__init__.py b/application/discord_bot/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/application/discord_bot/__pycache__/__init__.cpython-313.pyc b/application/discord_bot/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..4de614f Binary files /dev/null and b/application/discord_bot/__pycache__/__init__.cpython-313.pyc differ diff --git a/application/discord_bot/__pycache__/discord_bot.cpython-313.pyc b/application/discord_bot/__pycache__/discord_bot.cpython-313.pyc new file mode 100644 index 0000000..44ff6b0 Binary files /dev/null and b/application/discord_bot/__pycache__/discord_bot.cpython-313.pyc differ diff --git a/application/discord_bot/discord_bot.py b/application/discord_bot/discord_bot.py new file mode 100644 index 0000000..fdbe998 --- /dev/null +++ b/application/discord_bot/discord_bot.py @@ -0,0 +1,130 @@ +import logging + +import discord +from discord import Intents, ui, Interaction, Activity, app_commands + +from config.config import ConfigLoader +from minecraft_interface import MinecraftAPI, MinecraftServerAPI + + +class DiscordBot(discord.Client): + + def __init__(self): + self.log = logging.getLogger("Discord") + self.conf: ConfigLoader = ConfigLoader.get_config_loader() + + try: + mc_server_name = self.conf.default_minecraft_domain + except KeyError: + self.log.critical("Could not find configuration for minecraft server domain in config file!") + exit(1) + intents = Intents.default() + #intents.message_content = True + super().__init__(intents=intents, activity=Activity(name= "BotActivity", state=f"Guarding {mc_server_name}", type=discord.ActivityType.custom)) + + self.tree = app_commands.CommandTree(self) + + + def start_discord_bot(self, token: str, log_level): + logging.getLogger("Main").info("Starting the discord bot.") + self.run(token, log_level=logging.WARN) # blocks + + async def setup_hook(self) -> None: + # Sync the application command with all Authentication Guilds + await self.register_commands() + for guild in self.conf.auth_guilds: + await self.tree.sync(guild=guild) + + async def register_commands(self): + await self.conf.load_guilds(self) + self.tree.command( + name="signup_for_minecraft_server", + description="Enter your minecraft account name to sign up for the minecraft server", + guilds=self.conf.auth_guilds + )(self.signup_for_minecraft) + self.tree.command( + name="leave_minecraft_server", + description="Removes you from the minecraft server.", + guilds=self.conf.auth_guilds + )(self.leave_minecraft) + + async def on_ready(self): + await self.conf.load_guilds(self) + self.log.info(f"We have logged in as {self.user}") + + async def signup_for_minecraft(self, interaction: Interaction): + if interaction.guild in self.conf.auth_guilds: + + user_has_correct_role = False + for user_role in interaction.user.roles: + if str(user_role.id) in self.conf.roles_with_server_access(self.conf.default_minecraft_domain): + user_has_correct_role = True + if not user_has_correct_role: + await interaction.response.send_message("Access denied!\n" + "You do not have the correct role to use this command.", + ephemeral=True) + return + self.log.info( + f"Command \"signup_for_minecraft\" was called from guild \"{interaction.guild}\" ({interaction.guild.id}) " + f"by \"{interaction.user.name}\" ({interaction.user.id})") + await interaction.response.send_modal(MinecraftModal(interaction)) + else: + self.log.warning(f"Command \"signup_for_minecraft\" was called from outside an auth_guild! " + f"{interaction.guild} by {interaction.user}") + + async def leave_minecraft(self, interaction: Interaction): + if interaction.guild in self.conf.auth_guilds: + user_has_correct_role = False + for user_role in interaction.user.roles: + if str(user_role.id) in self.conf.roles_with_server_access(self.conf.default_minecraft_domain): + user_has_correct_role = True + if not user_has_correct_role: + await interaction.response.send_message("Access denied!\n" + "You do not have the correct role to use this command.", + ephemeral=True) + return + self.log.info( + f"Command \"leave_minecraft_server\" was called from guild \"{interaction.guild}\" ({interaction.guild.id}) " + f"by \"{interaction.user.name}\" ({interaction.user.id})" + ) + await MinecraftServerAPI.handle_minecraft_retirement(self.conf.default_minecraft_domain, interaction.user, interaction) + +class MinecraftModal(ui.Modal, title="Minecraft server application"): + mc_name = ui.TextInput(label='Minecraft Username') + #reason = ui.TextInput(label='Why do you want to join?', style=discord.TextStyle.paragraph) + + def __init__(self, interaction: discord.Interaction): + super().__init__() + self.log = logging.getLogger("Discord") + + async def on_submit(self, interaction: Interaction) -> None: + config: ConfigLoader = ConfigLoader.get_config_loader() + + if interaction.guild not in config.auth_guilds: + self.log.warning(f"Interaction from non-auth_guilds: {interaction} {interaction.guild} {interaction.user}") + await interaction.response.send_message(f"Interaction came from a guild not on the list. Not doing anything (except logging this)!") + return + + # validating given minecraft username + mc_uuid = MinecraftAPI.get_uuid_from_user_name(str(self.mc_name)) + if mc_uuid is not None: + self.log.info(f"Received server application with valid minecraft username \"{self.mc_name}\" from " + f"{interaction.user.name} ({interaction.user.id}) on " + f"{interaction.guild.name} ({interaction.guild.id})!") + else: + self.log.warning(f"Received server application with INVALID minecraft username \"{self.mc_name}\" from " + f"{interaction.user.name} ({interaction.user.id}) on " + f"{interaction.guild.name} ({interaction.guild.id})!") + await interaction.response.send_message(f'The minecraft username \"{self.mc_name}\" failed verification!\n\n' + f'Aborted!', + ephemeral=True) + return + + # Got a valid application. + await MinecraftServerAPI.handle_minecraft_application(str(self.mc_name), mc_uuid, config.default_minecraft_domain, + interaction.user, interaction) + await interaction.response.send_message("Application received!\n" + "Give it a minute. If it hasn't worked by then please " + "contact an admin instead!\n\n" + "Remember that you will be responsible for any actions of that minecraft account!", + ephemeral=True) diff --git a/application/log/Log.py b/application/log/Log.py new file mode 100644 index 0000000..8a47d30 --- /dev/null +++ b/application/log/Log.py @@ -0,0 +1,25 @@ +import logging + + +class Log: + + @staticmethod + def setup(level="DEBUG"): + level = getattr(logging, level) + mlog = logging.getLogger("Main") + dlog = logging.getLogger("Discord") + mclog = logging.getLogger("MC-API") + console_handler = logging.StreamHandler() + formatter = logging.Formatter( + fmt="[%(asctime)s][%(levelname)s] %(name)s: %(message)s", + datefmt="%Y-%m-%d %H:%M:%S" + ) + console_handler.setFormatter(formatter) + + logs = [mlog, dlog, mclog] + for log in logs: + log.handlers.clear() + log.setLevel(level) + log.addHandler(console_handler) + dlog.addHandler(console_handler) + mlog.debug("Logging has been setup!") \ No newline at end of file diff --git a/application/log/__init__.py b/application/log/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/application/log/__pycache__/Log.cpython-313.pyc b/application/log/__pycache__/Log.cpython-313.pyc new file mode 100644 index 0000000..05c1fb2 Binary files /dev/null and b/application/log/__pycache__/Log.cpython-313.pyc differ diff --git a/application/log/__pycache__/__init__.cpython-313.pyc b/application/log/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..d1c201e Binary files /dev/null and b/application/log/__pycache__/__init__.cpython-313.pyc differ diff --git a/application/mc-auth.config.sample b/application/mc-auth.config.sample new file mode 100644 index 0000000..c7cf806 --- /dev/null +++ b/application/mc-auth.config.sample @@ -0,0 +1,13 @@ +minecraft: + default_domain: "your-mc-server-domain" + domains: + your-mc-server-domain: + auth_guilds: + - "" + roles_with_server_access: + - "" + whitelist_location: "./whitelist.json" + post_application_text: "" +database: + host: "127.0.0.1" + port: 5432 diff --git a/application/minecraft_interface/MinecraftAPI.py b/application/minecraft_interface/MinecraftAPI.py new file mode 100644 index 0000000..af1ac3b --- /dev/null +++ b/application/minecraft_interface/MinecraftAPI.py @@ -0,0 +1,63 @@ +import logging + +import requests + +from log.Log import Log +from version import VERSION + +HEADERS = {'User-agent': f"DiscordMinecraft-Authenticator ({VERSION})(peery.coding@gmail.com)"} + +def is_valid_user_name(user_name: str) -> bool: + """ + Checks if user_name is a valid minecraft player account by querying api.mojang.com if the profile exists + :param user_name: + :return: + """ + log = logging.getLogger("McAPI") + url = f"https://api.mojang.com/users/profiles/minecraft/{user_name}" + response = requests.get(url, headers=HEADERS) + + if response.status_code == 404: + log.info(f"Looked up user name \"{user_name}\". Failed!") + return False + elif response.status_code == 200: + try: + uuid = response.json()['id'] + except KeyError: + log.critical(f"Unexpected response from {url}! Response ({response.status_code}): {response.json()}") + return False + log.info(f"Looked up user name \"{user_name}\" (UUID: \"{uuid}\"). Success!") + return True + else: + log.error(f"Got unexpected status code from user_name lookup \"{user_name}\": {response.status_code}") + return False + +def get_uuid_from_user_name(user_name: str) -> str: + """ + Checks if user_name is a valid minecraft player account by querying api.mojang.com if the profile exists + :param user_name: + :return: + """ + log = logging.getLogger("McAPI") + url = f"https://api.mojang.com/users/profiles/minecraft/{user_name}" + response = requests.get(url, headers=HEADERS) + + if response.status_code == 404: + log.info(f"Looked up user name \"{user_name}\". Failed!") + return None + elif response.status_code == 200: + try: + uuid = response.json()['id'] + log.info(f"Looked up user name \"{user_name}\" (UUID: \"{uuid}\"). Success!") + return uuid + except KeyError: + log.critical(f"Unexpected response from {url}! Response ({response.status_code}): {response.json()}") + return None + else: + log.error(f"Got unexpected status code from user_name lookup \"{user_name}\": {response.status_code}") + return None + + +if __name__ == "__main__": + Log.setup("DEBUG") + print(is_valid_user_name("MausKomant")) \ No newline at end of file diff --git a/application/minecraft_interface/MinecraftServerAPI.py b/application/minecraft_interface/MinecraftServerAPI.py new file mode 100644 index 0000000..a7a480e --- /dev/null +++ b/application/minecraft_interface/MinecraftServerAPI.py @@ -0,0 +1,224 @@ +import json +import logging +import os +from time import sleep +from typing import List + +import discord +from mcrcon import MCRcon +from sqlalchemy import select, and_ +from sqlalchemy.orm import Session + +from config.config import ConfigLoader +from database.DB import get_engine, MinecraftUser, MinecraftServer, DiscordUser +from log.Log import Log + + +def add_user_name_to_whitelist(domain: str, user_name: str): + """ + Adds a minecraft user_name to the whitelist + :param domain: + :param user_name: + :return: + """ + log = logging.getLogger("MC-API") + with MCRcon(os.environ.get('MCRCON_HOST'), os.environ.get('MCRCON_PW')) as mcr: + log.info(f"Adding minecraft user \"{user_name}\" to the whitelist of \"{domain}\"") + resp = mcr.command(f"/whitelist add {user_name}") + print(resp) + +def get_all_users_on_whitelist(domain: str) -> dict: + """ + Gets a list of all users on the whitelist by reading the server's whitelist.json + :param domain: + :return: + """ + c: ConfigLoader = ConfigLoader.get_config_loader() + with open(c.whitelist_location_per_domain[domain], 'r') as whitelist_file: + whitelist = json.load(whitelist_file) + return whitelist + +def get_list_of_users_on_whitelist(domain: str) -> List[str]: + on_whitelist = [name for name in + map(lambda x: x['name'], get_all_users_on_whitelist(domain))] + return on_whitelist + +def remove_user_name_from_whitelist(domain: str, user_name: str): + """ + Removes a minecraft user_name from the whitelist + :param domain: + :param user_name: + :return: + """ + log = logging.getLogger("MC-API") + with MCRcon(os.environ.get('MCRCON_HOST'), os.environ.get('MCRCON_PW')) as mcr: + log.info(f"Removing minecraft user \"{user_name}\" from the whitelist of \"{domain}\"") + resp = mcr.command(f"/whitelist remove {user_name}") + print(resp) + +async def handle_minecraft_application(mc_name: str, mc_uuid: str, domain: str, discord_user: discord.User, + interaction: discord.Interaction): + """ + Applies an application to the server whitelist or ignores it depending on if the minecraft user is already whitelisted. + + Will alter the database to reflecte the current state. + :param mc_name: + :param mc_uuid: + :param domain: + :param discord_user: + :param interaction + :return: + """ + log = logging.getLogger("MC-API") + c: ConfigLoader = ConfigLoader.get_config_loader() + + engine = get_engine() + with Session(engine) as session: + # query if the user is already related to our server + stmt = select(MinecraftUser).where( + MinecraftUser.minecraft_servers.any(and_(MinecraftServer.domain == domain, MinecraftUser.user_name == mc_name))) + mc_user_on_server: MinecraftUser = session.scalars(stmt).first() + # query for the server obj + stmt = select(MinecraftServer).where(MinecraftServer.domain == domain) + dc_user: DiscordUser = session.scalars( + select(DiscordUser).where(DiscordUser.snowflake_id == str(discord_user.id)) + ).first() + curr_server: MinecraftServer = session.scalars(stmt).first() + + on_whitelist = get_list_of_users_on_whitelist(domain) + + if mc_user_on_server is not None: # this user is already associated with the server (could be missing on whitelist?) + log.info( + f"Found minecraft username \"{mc_name}\" to be already associated with the server. Aborting ...") + if mc_name not in on_whitelist: + log.error(f"Minecraft username \"{mc_name}\" was found in the database but is " + f"not in \"whitelist.json\"! Did someone manually remove them? Removing the relationship ...") + mc_user_on_server.minecraft_servers.remove(curr_server) + session.add(mc_user_on_server) + session.commit() + if dc_user.minecraft_user_uuid == mc_uuid: + log.info(f"Discord user \"{dc_user.handle}\" ({dc_user.snowflake_id}) tried to add minecraft user " + f"\"{mc_name}\" ({mc_uuid}) twice! Aborting...") + #interaction.response.send_message(f"The minecraft user \"{mc_name}\" was already added by you!\n" + # f"Not doing anything because this seems unnecessary.\n\n" + # f"If this is an error inform an admin!", + # ephemeral=True) + return # do not inform user about this, otherwise leaks who is on the server by guessing their name with the bot + # minecraft user is not associated with server or was just dis-associated (could still be on whitelist) + log.info( + f"Did not find minecraft user \"{mc_name}\" in relation to server \"{curr_server.domain}\"") + if mc_name in on_whitelist: + log.warning( + f"Minecraft username \"{mc_name}\" was not found in the database but is " + f"in \"whitelist.json\"! Were they added manually? Ignoring that.") + return + + # query for the minecraft user + mc_user = session.scalars( + select(MinecraftUser).where(MinecraftUser.user_name == str(mc_name)) + ).first() + if mc_user is None: # create mc_user + mc_user = MinecraftUser(user_name=str(mc_name), uuid=mc_uuid) + + session.add(mc_user) + session.commit() + + if dc_user is None: # got to create discord user + dc_user = DiscordUser(snowflake_id=str(discord_user.id), + handle=str(discord_user.name), + minecraft_user_uuid=mc_uuid) + session.add(dc_user) + else: # discord user is known + if dc_user.minecraft_user_uuid is not None and dc_user.minecraft_user_uuid != mc_uuid: # discord user already had another minecraft account + old_mc_user: MinecraftUser = session.scalars(select(MinecraftUser).where( + MinecraftUser.uuid == dc_user.minecraft_user_uuid + )).first() + log.info(f"Discord user \"{dc_user.handle}\" ({dc_user.snowflake_id}) already had a minecraft uuid " + f"associated: \"{old_mc_user.user_name}\" ({old_mc_user.uuid})\n" + f"Updating it to \"{mc_name}\" ({mc_uuid})!") + remove_user_name_from_whitelist(domain, str(old_mc_user.user_name)) + dc_user.minecraft_user_uuid = mc_uuid + session.add(dc_user) + mc_user.minecraft_servers.append(curr_server) + session.commit() + + add_user_name_to_whitelist(domain, mc_name) + #await interaction.response.send_message("Thanks for your response. You've been added to the server's whitelist!\n" + # "Enjoy your time playing!\n\n" + # "Also don't forget to inform yourself about available mods or " + # "which minecraft version to use! Some are: \n" + # f"{c.post_application_text(domain)}", + # ephemeral=True) + +async def handle_minecraft_retirement(domain: str, discord_user: discord.User, interaction: discord.Interaction): + """ + Only removes any minecraft user associated with a discord user. + :param domain: + :param discord_user: + :param interaction: + :return: + """ + log = logging.getLogger("MC-API") + + engine = get_engine() + with Session(engine) as session: + dc_user: DiscordUser = session.scalars(select(DiscordUser).where( + DiscordUser.snowflake_id == str(discord_user.id) + )).first() + if dc_user is None: # unknown discord user called this command + log.info(f"Can't find discord user \"{discord_user.name}\" (\"{discord_user.id}\") in the database!") + await interaction.response.send_message(f"Hey, {discord_user.name}!\nWould love to help but don't think we have met before!\n" + f"If you're still on the whitelist of \"{domain}\" " + "please tell an admin or moderator instead. " + "They'll figure this out for you!", + ephemeral=True) + return + else: # discord user has interacted with this bot before + if dc_user.minecraft_user_uuid is None: # no minecraft user known (used this command before or manual edit)? + log.info(f"Found discord user \"{dc_user.handle}\" ({dc_user.snowflake_id}) in the database " + f"but there was no minecraft UUID associated with it.") + await interaction.response.send_message("Couldn't find any minecraft user associated with your account.\n" + "But I've seen you before! Have you used this command already?", + ephemeral=True) + return + + mc_user: MinecraftUser = session.scalars(select(MinecraftUser).where( + MinecraftUser.uuid == dc_user.minecraft_user_uuid + )).first() + if mc_user is None: + log.critical(f"Could not find minecraft user with UUID \"{dc_user.minecraft_user_uuid}\" but it was " + f"associated with discord account \"{dc_user.handle}\" ({dc_user.snowflake_id}). " + f"Something is broken in the database schema! Did a delete not cascade?") + exit(1) + curr_server: MinecraftServer = session.scalars(select(MinecraftServer).where( + MinecraftServer.domain == domain + )).first() + if curr_server is None: # domain is unknown to the database?! + log.critical(f"Tried to look up minecraft server \"{domain}\" but the database query was empty! \n" + f"Something is broken in the database!") + exit(1) + + dc_user.minecraft_user_uuid = None + session.add(dc_user) + mc_user.minecraft_servers.remove(curr_server) + session.add(mc_user) + log.info(f"Discord user \"{dc_user.handle}\" ({dc_user.snowflake_id}) is leaving server \"{domain}\" with " + f"minecraft user \"{mc_user.user_name}\" ({mc_user.uuid})!") + remove_user_name_from_whitelist(curr_server.domain, str(mc_user.user_name)) + session.commit() + await interaction.response.send_message(f"Removed your minecraft account \"{mc_user.user_name}\" " + f"from the server's whitelist.\n" + f"Take care!", + ephemeral=True) + +if __name__ == "__main__": + Log.setup() + + print(get_all_users_on_whitelist("feather-mc.pandro.de")) + sleep(2) + add_user_name_to_whitelist("feather-mc.pandro.de", "MausKomant") + sleep(2) + print(get_all_users_on_whitelist("feather-mc.pandro.de")) + remove_user_name_from_whitelist("feather-mc.pandro.de", "MausKomant") + sleep(2) + print(get_all_users_on_whitelist("feather-mc.pandro.de")) \ No newline at end of file diff --git a/application/minecraft_interface/__init__.py b/application/minecraft_interface/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/application/minecraft_interface/__pycache__/MinecraftAPI.cpython-313.pyc b/application/minecraft_interface/__pycache__/MinecraftAPI.cpython-313.pyc new file mode 100644 index 0000000..99b31e7 Binary files /dev/null and b/application/minecraft_interface/__pycache__/MinecraftAPI.cpython-313.pyc differ diff --git a/application/minecraft_interface/__pycache__/MinecraftServerAPI.cpython-313.pyc b/application/minecraft_interface/__pycache__/MinecraftServerAPI.cpython-313.pyc new file mode 100644 index 0000000..6f66750 Binary files /dev/null and b/application/minecraft_interface/__pycache__/MinecraftServerAPI.cpython-313.pyc differ diff --git a/application/minecraft_interface/__pycache__/__init__.cpython-313.pyc b/application/minecraft_interface/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..b38fba6 Binary files /dev/null and b/application/minecraft_interface/__pycache__/__init__.cpython-313.pyc differ diff --git a/application/requirements.txt b/application/requirements.txt new file mode 100644 index 0000000..54d3fa0 --- /dev/null +++ b/application/requirements.txt @@ -0,0 +1,6 @@ +discord>=2.3.2 +SQLAlchemy>=2.0.43 +pyyaml>=6.0.3 +requests>=2.32.5 +psycopg2-binary>=2.9.10 +mcrcon>=0.7.0 diff --git a/application/start.py b/application/start.py new file mode 100644 index 0000000..e3f3613 --- /dev/null +++ b/application/start.py @@ -0,0 +1,27 @@ +import logging +import os + +from database.DB import get_engine +from discord_bot.discord_bot import DiscordBot +from log.Log import Log + +# TODO use docker secrets instead of env variables +# TODO rework MINECRAFT_SERVER_DOMAIN env + +# TODO create command /lookup_minecraft_name to see which discord account is associated with a minecraft name +# TODO make /lookup_minecraft_name only available to a configured role #2 + +def main(): + Log.setup(os.environ.get('LOG')) + if not (len(os.environ.get('DISCORD_BOT_TOKEN')) > 0): + logging.getLogger("Main").critical("No discord bot token was found! Please generate a token at " + "https://discord.com/developers/applications//bot " + "and make sure it is present as the " + "environment variable \"DISCORD_BOT_TOKEN\".") + exit(1) + get_engine() + disc = DiscordBot() + disc.start_discord_bot(token=os.environ.get('DISCORD_BOT_TOKEN'), log_level=os.environ.get('LOG')) + +if __name__ == "__main__": + main() diff --git a/application/version.py b/application/version.py new file mode 100644 index 0000000..1d054a4 --- /dev/null +++ b/application/version.py @@ -0,0 +1 @@ +VERSION = "v0.1" \ No newline at end of file diff --git a/application/whitelist.json b/application/whitelist.json new file mode 120000 index 0000000..ad53ef2 --- /dev/null +++ b/application/whitelist.json @@ -0,0 +1 @@ +test-env/data/mc-server/whitelist.json \ No newline at end of file diff --git a/test-env/docker-compose.yaml b/test-env/docker-compose.yaml new file mode 100644 index 0000000..d91bc3b --- /dev/null +++ b/test-env/docker-compose.yaml @@ -0,0 +1,59 @@ +services: + db: + image: postgres:17-alpine + restart: unless-stopped +# ports: +# - 127.0.0.1:5432:5432 # only when testing from the outside + healthcheck: + test: ['CMD', 'pg_isready', '-U', "mc-authenticator", "minecraft_access"] + timeout: 5s + retries: 2 + env_file: + - ./env/postgres-secrets.env + volumes: + - "./data/postgres_data:/var/lib/postgresql/data" + networks: + - internal + + paper: + image: "docker.io/eclipse-temurin:21-jre" + restart: unless-stopped + ports: + - "127.0.0.1:25565:25565/tcp" # default mc + - "127.0.0.1:25565:25565/udp" # default mc + - "127.0.0.1:24454:24454/udp" # simple-voice-chat plugin +# - "127.0.0.1:25575:25575/tcp" # RCON, only when testing from the outside + volumes: + - ./data/mc-server:/app + working_dir: /app + command: "java -Xms8192M -Xmx8192M -jar paper.jar nogui" + stdin_open: true + tty: true + networks: + - internet + mc-authenticator: + image: "minecraft-authenticator:latest" + restart: unless-stopped + env_file: + - ./env/postgres-secrets.env + - ./env/discord-secrets.env + - ./env/minecraft-secrets.env + - ./env/base.env + volumes: + - ./data/mc-server/whitelist.json:/home/python_user/app/whitelist.json:ro + - ./config/mc-authenticator/mc-auth.config:/home/python_user/app/mc-auth.config:ro + depends_on: + - db + - paper + networks: + - internal + - internet + +networks: + internet: + enable_ipv6: true + ipam: + config: + - subnet: fdf7:53f5:341e::/64 + internal: + internal: true diff --git a/test-env/generate_tables.sql b/test-env/generate_tables.sql new file mode 100644 index 0000000..492f3bb --- /dev/null +++ b/test-env/generate_tables.sql @@ -0,0 +1,26 @@ +-- Creating the DB structure +-- Version 1.0 (2025-09-25) + +CREATE TABLE IF NOT EXISTS minecraft_users ( + uuid VARCHAR(32) PRIMARY KEY not null, + user_name VARCHAR(16) not null +); + +CREATE TABLE IF NOT EXISTS discord_users ( + snowflake_id VARCHAR(32) PRIMARY KEY not null, + handle VARCHAR(32) not null, + minecraft_user_uuid VARCHAR(32), + FOREIGN KEY (minecraft_user_uuid) REFERENCES minecraft_users(uuid) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS minecraft_servers ( + domain VARCHAR(128) PRIMARY KEY not null +); + +CREATE TABLE IF NOT EXISTS mc_users_on_mc_server ( + minecraft_user VARCHAR(32) not null, + minecraft_server VARCHAR(64) not null, + PRIMARY KEY(minecraft_user, minecraft_server), + FOREIGN KEY(minecraft_user) REFERENCES minecraft_users(uuid) ON DELETE CASCADE, + FOREIGN KEY(minecraft_server) REFERENCES minecraft_servers(domain) ON DELETE CASCADE +);