From aae68587e8acb0d89c49a694a627b84581efb0fd Mon Sep 17 00:00:00 2001 From: Sepalani Date: Tue, 15 Jul 2025 19:10:23 +0400 Subject: [PATCH 1/2] mh.database: Add implementation_check function --- mh/database.py | 44 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/mh/database.py b/mh/database.py index ad63058..94cdc2e 100644 --- a/mh/database.py +++ b/mh/database.py @@ -4,6 +4,7 @@ # SPDX-License-Identifier: AGPL-3.0-or-later """Monster Hunter database module.""" +import inspect import random import sqlite3 import time @@ -966,3 +967,46 @@ def __init__(self, *args, **kwargs): def get_instance(): return CURRENT_DB + + +def implementation_check(cls, base=TempDatabase): + """Debug implementation check allowing to see methods dependencies. + + Example: + $> python -im mh.database + >>> implementation_check(TempSQLiteDatabase) + """ + def is_method(obj): + """Python3's missing unbound methods workaround.""" + if inspect.ismethod(obj): + return True + # Python 3 codepath: + # Should work in most cases (excluding module and static functions) + if inspect.isfunction(obj) and hasattr(obj, "__qualname__"): + return "." in obj.__qualname__ + return False + + mros = inspect.getmro(cls) + missing_methods = [ + name for name, _ in inspect.getmembers(base, is_method) + ] + + print("class {}:".format(cls.__name__)) + + for name, obj in inspect.getmembers(cls, is_method): + if name in missing_methods: + missing_methods.remove(name) + for c in mros: + if name in c.__dict__: + print(" {}.{}".format(c.__name__, name)) + break + else: + # Should never happen + print(" (ERROR).{}".format(name)) + + if missing_methods: + print("\nMissing methods:" + "\n {}".format("\n ".join(missing_methods))) + return False + + return True From 5d2f468b173a43c179d741bc7831927879714303 Mon Sep 17 00:00:00 2001 From: Sepalani Date: Sat, 19 Jul 2025 23:03:55 +0400 Subject: [PATCH 2/2] mh.database: Add MySQL backend --- config.ini | 10 +++ mh/database.py | 190 ++++++++++++++++++++++++++++++++++++++++++++++++- other/utils.py | 28 +++++++- 3 files changed, 225 insertions(+), 3 deletions(-) diff --git a/config.ini b/config.ini index e062510..5d8edc5 100644 --- a/config.ini +++ b/config.ini @@ -2,6 +2,16 @@ DefaultCert = cert/server.crt DefaultKey = cert/server.key +[MYSQL] +enabled = OFF +user = +password = +host = 127.0.0.1 +database = +ssl_ca = +ssl_cert = +ssl_key = + [OPN] IP = 0.0.0.0 ExternalIP = diff --git a/mh/database.py b/mh/database.py index 94cdc2e..ad7c70b 100644 --- a/mh/database.py +++ b/mh/database.py @@ -886,7 +886,190 @@ def delete_friend(self, capcom_id, friend_id): return self.parent.delete_friend(capcom_id, friend_id) -# TODO: Backport MySQLDatabase +class MySQLDatabase(TempDatabase): + """Hybrid MySQL/TempDatabase. + + Requires mysql-connector-python: + - 8.0.23 (latest Python2 version) + - Python3 users can pick more recent versions + + TODO: + - Add reconnect wrapper + - Backport methods used by central/state if needed + """ + + def __init__(self): + self.parent = super(MySQLDatabase, self) + self.parent.__init__() + from mysql import connector + self.connection = connector.connect( + **utils.get_mysql_config("MYSQL") + ) + self.create_database() + self.populate_database() + + def create_database(self): + with self.connection.cursor() as cursor: + # Consoles creation + cursor.execute( + "CREATE TABLE IF NOT EXISTS consoles" + " (support_code VARCHAR(11) NOT NULL," + " slot_index TINYINT NOT NULL," + " capcom_id VARCHAR(6) NOT NULL," + " name VARCHAR(8)," + " PRIMARY KEY(capcom_id)," + " UNIQUE profiles_uniq_idx (support_code, slot_index));" + ) + + # Friends creation + cursor.execute( + "CREATE TABLE IF NOT EXISTS friend_lists" + " (id MEDIUMINT NOT NULL AUTO_INCREMENT," + " capcom_id VARCHAR(6) NOT NULL," + " friend_id VARCHAR(6) NOT NULL," + " PRIMARY KEY (id)," + " UNIQUE friends_uniq_idx (capcom_id, friend_id));" + ) + + def populate_database(self): + with self.connection.cursor() as cursor: + cursor.execute("SELECT * FROM consoles") + rows = cursor.fetchall() + for support_code, slot_index, capcom_id, name in rows: + support_code = str(support_code) + capcom_id = str(capcom_id) + name = utils.to_bytes(name) + if support_code not in self.consoles: + self.consoles[support_code] = [BLANK_CAPCOM_ID] * 6 + self.consoles[support_code][slot_index - 1] = capcom_id + self.capcom_ids[capcom_id] = {"name": name, "session": None} + self.friend_lists[capcom_id] = [] + + cursor.execute("SELECT capcom_id, friend_id FROM friend_lists") + rows = cursor.fetchall() + for capcom_id, friend_id in rows: + capcom_id = str(capcom_id) + friend_id = str(friend_id) + self.friend_lists[capcom_id].append(friend_id) + + def force_update(self): + """For debugging purpose.""" + with self.connection.cursor() as cursor: + for support_code, capcom_ids in self.consoles.items(): + for slot_index, capcom_id in enumerate(capcom_ids, 1): + info = self.capcom_ids.get(capcom_id, {"name": b""}) + cursor.execute( + "INSERT IGNORE INTO consoles" + " (support_code, slot_index, capcom_id, name)" + " VALUES (%s, %s, %s, %s);", + (support_code, slot_index, capcom_id, info["name"]) + ) + + for capcom_id, friend_ids in self.friend_lists.items(): + for friend_id in friend_ids: + cursor.execute( + "INSERT IGNORE INTO friend_lists" + " (capcom_id, friend_id)" + " VALUES (%s, %s);", + (capcom_id, friend_id) + ) + + def assign_capcom_id(self, online_support_code, index, capcom_id): + # TODO: Backport only + """Assign a Capcom ID to an online support code.""" + self.consoles[online_support_code][index] = capcom_id + with self.connection.cursor() as cursor: + cursor.execute( + "REPLACE INTO consoles" + " (support_code, slot_index, capcom_id, name)" + " VALUES (%s, %s, %s, %s);", + (online_support_code, index, capcom_id, "????") + ) + + def get_capcom_ids(self, online_support_code): # TODO: Backport only + """Get list of associated Capcom IDs from an online support code.""" + with self.connection.cursor() as cursor: + cursor.execute( + "SELECT slot_index, capcom_id FROM consoles" + " WHERE support_code = %s;", + (online_support_code,) + ) + rows = cursor.fetchall() + ids = [] + for index, capcom_id in rows: + while len(ids) < index: + ids.append(BLANK_CAPCOM_ID) + ids.append(str(capcom_id)) + while len(ids) < 6: + ids.append(BLANK_CAPCOM_ID) + return ids + + def get_name(self, capcom_id): + # TODO: Backport only (but get_user_name exists in upstream...) + """Get the hunter name associated with a valid Capcom ID.""" + with self.connection.cursor() as cursor: + cursor.execute( + "SELECT name FROM consoles WHERE capcom_id = %s;", + (capcom_id,) + ) + name = cursor.fetchone() + if not name: + return b"" + return utils.to_bytes(name) + + def use_user(self, session, index, name): + """Insert the current hunter's info into a selected Capcom ID slot.""" + result = self.parent.use_user(session, index, name) + with self.connection.cursor() as cursor: + cursor.execute( + "REPLACE INTO consoles VALUES (%s,%s,%s,%s);", + (session.online_support_code, index, + session.capcom_id, session.hunter_name) + ) + return result + + def accept_friend(self, capcom_id, friend_id, accepted): + if accepted: + with self.connection.cursor() as cursor: + query = \ + "REPLACE INTO friend_lists (capcom_id, friend_id)" \ + " VALUES (%s,%s);" + cursor.execute( + query, + (capcom_id, friend_id) + ) + cursor.execute( + query, + (friend_id, capcom_id) + ) + return self.parent.accept_friend(capcom_id, friend_id, accepted) + + def delete_friend(self, capcom_id, friend_id): + with self.connection.cursor() as cursor: + cursor.execute( + "DELETE FROM friend_lists" + " WHERE capcom_id = %s AND friend_id = %s;", + (capcom_id, friend_id) + ) + return self.parent.delete_friend(capcom_id, friend_id) + + def get_friends(self, capcom_id, first_index=None, count=None): + begin = 0 if first_index is None else (first_index - 1) + end = count if count is None else (begin + count) + with self.connection.cursor() as cursor: + cursor.execute( + "SELECT friend_id, name FROM friend_lists" + " INNER JOIN consoles" + " ON friend_lists.friend_id = consoles.capcom_id" + " WHERE friend_lists.capcom_id = %s;", + (capcom_id,) + ) + rows = cursor.fetchall() + friends = [ + (str(friend_id), str(name)) + for friend_id, name in rows + ] + return friends[begin:end] class DebugDatabase(TempSQLiteDatabase): @@ -962,7 +1145,10 @@ def __init__(self, *args, **kwargs): self.force_update() -CURRENT_DB = TempSQLiteDatabase() +CURRENT_DB = \ + MySQLDatabase() \ + if utils.is_mysql_enabled("MYSQL") \ + else TempSQLiteDatabase() def get_instance(): diff --git a/other/utils.py b/other/utils.py index 5fcd3ec..b55bb61 100644 --- a/other/utils.py +++ b/other/utils.py @@ -240,7 +240,33 @@ def get_config(name, config_file=CONFIG_FILE): } -# TODO: Backport MySQL, latest_patch and central config code +def get_mysql_config(name, config_file=CONFIG_FILE): + """Get MySQL config.""" + config = ConfigParser.RawConfigParser(allow_no_value=True) + config.read(config_file) + ssl_ca = config.get(name, "ssl_ca") or None + from mysql.connector.constants import ClientFlag + return { + "charset": "utf8", + "autocommit": True, + "user": config.get(name, "User"), + "password": config.get(name, "Password"), + "host": config.get(name, "Host"), + "database": config.get(name, "database"), + "client_flags": [ClientFlag.SSL] if ssl_ca else None, + "ssl_ca": ssl_ca, + "ssl_cert": config.get(name, "ssl_cert") or None, + "ssl_key": config.get(name, "ssl_key") or None + } + + +def is_mysql_enabled(name, config_file=CONFIG_FILE): + config = ConfigParser.RawConfigParser(allow_no_value=True) + config.read(config_file) + return config.getboolean(name, "Enabled") + + +# TODO: Backport latest_patch and central config code def get_default_ip():