diff --git a/La Tanière Launcher.spec b/La Tanière Launcher.spec index 686111a..315963a 100644 --- a/La Tanière Launcher.spec +++ b/La Tanière Launcher.spec @@ -2,7 +2,7 @@ a = Analysis( ['src\\main.py'], - pathex=[], + pathex=['src'], binaries=[], datas=[ ('.\\styles\\styles.qss', 'styles'), @@ -65,34 +65,105 @@ a = Analysis( 'PySide6.QtWebSockets', 'PySide6.QtXml', - # Stdlib inutile en prod - 'unittest', - 'email', - 'html', - 'http', - 'xmlrpc', - 'pydoc', - 'doctest', - 'difflib', - 'tkinter', - 'curses', - 'readline', - 'xml', - 'xmlrpc', - 'csv', - 'multiprocessing', - 'concurrent', - 'asyncio', - 'sqlite3', - 'ssl', - 'socket', - 'ctypes', - 'lib2to3', - 'test', - 'distutils', - 'setuptools', - 'pkg_resources', - 'pip', + # Tests / dev tools + "test", + "tests", + "unittest", + "doctest", + "pydoc", + "pydoc_data", + + # Packaging / build tooling + "distutils", + "setuptools", + "pkg_resources", + "pip", + "ensurepip", + + # GUI stdlib inutiles + "tkinter", + "turtle", + "idlelib", + "curses", + + # Legacy / obsolete + "lib2to3", + "2to3", + "nis", + "ossaudiodev", + "spwd", + + # RPC / servers non utilisés + "xmlrpc", + "wsgiref", + "cgi", + "cgitb", + + # Data / DB non utilisés + "sqlite3", + "dbm", + "dbm.dumb", + "csv", + + # Concurrency non utilisée dans ton code + "multiprocessing", + "concurrent", + "asyncio", + + # REPL / terminal + "readline", + "code", + # "codeop", + "cmd", + + # mail / network protocols non utilisés + "mailbox", + "imaplib", + "poplib", + "smtplib", + "nntplib", + "telnetlib", + "ftplib", + "netrc", + + # Docs / browsing / parsing non utilisés directement + "pydoc_data", + "mailbox", + "imaplib", + "poplib", + "smtplib", + "nntplib", + "telnetlib", + + # XML optionnel : agressif mais plutôt safe ici + "xml.dom", + "xml.etree", + "xml.parsers", + "xml.sax", + + # Compression / archive optionnelles si non utilisées + "bz2", + "lzma", + "gzip", + #"zipfile", + "tarfile", + "zipapp", + + # audio stdlib non utilisée + "aifc", + "wave", + "sunau", + "chunk", + + # divers peu probables + "mailcap", + "xdrlib", + "tabnanny", + "getpass", + + # Windows services non utilisés + "win32service", + "win32serviceutil", ], noarchive=False, optimize=2, diff --git a/src/config/config_manager.py b/src/config/config_manager.py index c0dc2ad..7dba665 100644 --- a/src/config/config_manager.py +++ b/src/config/config_manager.py @@ -2,7 +2,7 @@ import json from pathlib import Path from typing import Any, Callable, NotRequired, TypedDict, cast -from utils import get_bundle_dir +from tools.utils import get_executable_dir class ConfigData(TypedDict): discord_user_id: NotRequired[str] @@ -16,7 +16,7 @@ class ConfigField(TypedDict): validator: Validator normalizer: Normalizer -CONFIG_PATH = get_bundle_dir() / "config.json" +CONFIG_PATH = get_executable_dir() / "config.json" DISCORD_USER_KEY = "discord_user_id" VOLUME_KEY = "volume" diff --git a/src/controllers/audio_controller.py b/src/controllers/audio_controller.py index fe772a6..eb17442 100644 --- a/src/controllers/audio_controller.py +++ b/src/controllers/audio_controller.py @@ -3,7 +3,7 @@ from PySide6.QtMultimedia import QMediaPlayer, QAudioOutput from config.config_manager import ConfigManager, VOLUME_KEY -from constants import Resources +from tools.constants import Resources class AudioController: # Encapsule toute la logique audio : lecture, volume, mute. diff --git a/src/controllers/glow_animator.py b/src/controllers/glow_animator.py index ffc1caf..c1fcad6 100644 --- a/src/controllers/glow_animator.py +++ b/src/controllers/glow_animator.py @@ -1,7 +1,7 @@ from PySide6.QtCore import QPropertyAnimation, QEasingCurve from PySide6.QtWidgets import QGraphicsDropShadowEffect -from constants import Glow +from tools.constants import Glow class GlowAnimator: diff --git a/src/main.py b/src/main.py index 75e27f4..e463a13 100644 --- a/src/main.py +++ b/src/main.py @@ -1,5 +1,5 @@ import sys -from utils import get_bundle_dir +from tools.utils import get_internal_dir from PySide6.QtCore import QResource from PySide6.QtGui import QFontDatabase, QFont @@ -9,21 +9,18 @@ from PySide6.QtWidgets import QApplication import resources # noqa: F401 - required to register Qt resources from ui.main_window import MainWindow -from constants import Resources +from tools.constants import Resources # --------------------------------------------------------------------------- # Bundle path resolution # --------------------------------------------------------------------------- - -bundle_dir = get_bundle_dir() +bundle_dir = get_internal_dir() QResource.registerResource(f"{bundle_dir}/resources.py") - # --------------------------------------------------------------------------- # Font helper # --------------------------------------------------------------------------- - def load_custom_font() -> str: font_id = QFontDatabase.addApplicationFont(Resources.FONT.value) if font_id == -1: @@ -37,7 +34,6 @@ def load_custom_font() -> str: # --------------------------------------------------------------------------- # Entry point # --------------------------------------------------------------------------- - if __name__ == "__main__": app = QApplication(sys.argv) diff --git a/src/constants.py b/src/tools/constants.py similarity index 93% rename from src/constants.py rename to src/tools/constants.py index 81daa54..0515a95 100644 --- a/src/constants.py +++ b/src/tools/constants.py @@ -19,6 +19,7 @@ class Resources(Enum): class Urls(Enum): DISCORD = "https://discord.gg/A7eanmSkp2" INTRANET = "https://la-taniere.fun/connexion/" + API_URL = 'https://prod.la-taniere.fun:30121/' class Glow(Enum): COLOR = QColor(255, 140, 0, 255) diff --git a/src/tools/discord_oauth.py b/src/tools/discord_oauth.py new file mode 100644 index 0000000..c7d6401 --- /dev/null +++ b/src/tools/discord_oauth.py @@ -0,0 +1,87 @@ +import requests +import webbrowser +import os +from urllib.parse import urlencode +from http.server import HTTPServer, BaseHTTPRequestHandler +from get_server_token import GetServerTokenForDiscord + +# Disable stderr output +os.environ['PYTHONWARNINGS'] = 'ignore' + +REDIRECT_URI = "http://localhost:5000/callback" +SCOPES = ["identify"] +CLIENT_ID = "1240007913175781508" +AUTENTICATION_SUCCESS_MESSAGE = """ + + + + + + +

Authentication réussie

+

Vous pouvez maintenant fermer cette fenêtre et revenir au launcher de La Tanière.

+ + + """.encode('utf-8') + +class OAuthCallbackHandler(BaseHTTPRequestHandler): + code: str | None = None + + def do_GET(self): + if "/callback" in self.path: + query = self.path.split("?")[1] + params = dict(p.split("=") for p in query.split("&")) + OAuthCallbackHandler.code = params.get("code") + + self.send_response(200) + self.send_header("Content-type", "text/html") + self.end_headers() + self.wfile.write(AUTENTICATION_SUCCESS_MESSAGE) + +# return discord application id (client id) +def get_discord_client_id() -> str: + return CLIENT_ID + +# return discord user id +def get_discord_user_id() -> str: + # récupération des infos serveur lataupe + session_id = GetServerTokenForDiscord.authenticate() + client_secret = GetServerTokenForDiscord.get_token(session_id) + + auth_url = "https://discord.com/api/oauth2/authorize" + params = { + "client_id": CLIENT_ID, + "redirect_uri": REDIRECT_URI, + "response_type": "code", + "scope": " ".join(SCOPES), + } + + webbrowser.open(f"{auth_url}?{urlencode(params)}") + + server = HTTPServer(("localhost", 5000), OAuthCallbackHandler) + # celle ligne cache le stderr output pour ne pas afficher l'url de callback dans la console + # valable en debug mode + os.dup2(os.open(os.devnull, os.O_WRONLY), 2) + server.handle_request() + + if not OAuthCallbackHandler.code: + raise RuntimeError("OAuth échoué") + + token = requests.post( + "https://discord.com/api/oauth2/token", + data={ + "client_id": CLIENT_ID, + "client_secret": client_secret, + "grant_type": "authorization_code", + "code": OAuthCallbackHandler.code, + "redirect_uri": REDIRECT_URI, + }, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ).json() + + user = requests.get( + "https://discord.com/api/users/@me", + headers={"Authorization": f"Bearer {token['access_token']}"}, + ).json() + + return user["id"] diff --git a/src/tools/discord_tools.py b/src/tools/discord_tools.py new file mode 100644 index 0000000..e079941 --- /dev/null +++ b/src/tools/discord_tools.py @@ -0,0 +1,33 @@ +import psutil + +from pypresence import Presence +from get_server_token import GetServerTokenForDiscord +from constants import Urls + +class DiscordToken: + @staticmethod + def decode_discord_token(): + discord_token = GetServerTokenForDiscord.get_token(GetServerTokenForDiscord.authenticate(Urls.API_URL.value)) + return discord_token + +class CheckDiscord: + + @staticmethod + def isdiscordrunning() -> bool: + for process in psutil.process_iter(["name"]): + if process.info["name"].lower() == "discord.exe": + return True + return False + + @staticmethod + def isuserconnected(clientid: str) -> bool: + rpc = Presence(clientid) + try: + return True + except Exception: + return False + finally: + try: + rpc.close() + except Exception: + pass diff --git a/src/tools/get_server_token.py b/src/tools/get_server_token.py new file mode 100644 index 0000000..a5327e8 --- /dev/null +++ b/src/tools/get_server_token.py @@ -0,0 +1,76 @@ +import base64 +import requests +from cryptography.hazmat.primitives import serialization, hashes +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives.kdf.hkdf import HKDF +from cryptography.hazmat.primitives.ciphers.aead import AESGCM + +API_URL = 'https://prod.la-taniere.fun:30121/' + +class GetServerTokenForDiscord: + derived_key: bytes | None = None + + @staticmethod + def authenticate(server = API_URL): + + if server is None: + server = API_URL + # ========================== + # Génération clé ECDH client + # ========================== + client_private = ec.generate_private_key(ec.SECP256R1()) + client_public = client_private.public_key() + + client_pub_pem = client_public.public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo + ) + + # ========================== + # AUTH + # ========================== + auth = requests.post(server + "/api_v2/auth", verify=False, json={ + "client_pub": base64.b64encode(client_pub_pem).decode() + }).json() + + server_pub = serialization.load_pem_public_key( + base64.b64decode(auth["server_pub"]) + ) + + shared_key = client_private.exchange(ec.ECDH(), server_pub) + + GetServerTokenForDiscord.derived_key = HKDF( + algorithm=hashes.SHA256(), + length=32, + salt=None, + info=b"fivem-private-server" + ).derive(shared_key) + + return auth["session_id"] + + @staticmethod + def get_token(session_id: bytes, server = API_URL): + # ========================== + # DISCORD TOKEN + # ========================== + if server is None: + server = API_URL + download = requests.post(server + "/api_v2/tkn_auth", verify=False, headers={ + "x-session-id": session_id + }).json() + + nonce = base64.b64decode(download["nonce"]) + encrypted_data = base64.b64decode(download["data"]) + + aesgcm = AESGCM(GetServerTokenForDiscord.derived_key) # type: ignore[arg-type] + return aesgcm.decrypt(nonce, encrypted_data, None) + + # @staticmethod + # def register_discord_user(user_id: str, server = API_URL) -> str: + # if server is None: + # server = API_URL + # registeredId = requests.post(server + "/api_v2/connection/register", verify=False, json={ + # "x-session-id": user_id + # }).json() + # + # return registeredId["discord_id"] diff --git a/src/tools/utils.py b/src/tools/utils.py new file mode 100644 index 0000000..ceb80b9 --- /dev/null +++ b/src/tools/utils.py @@ -0,0 +1,19 @@ +import sys +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[2] + +def get_internal_dir() -> Path: + # Retourne le chemin vers les ressources figées à l'intérieur de l'EXE (_MEIPASS). + # En mode script, retourne le dossier du fichier .py. + if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'): + return Path(sys._MEIPASS).resolve() + return Path(__file__).resolve().parents[2] + +def get_executable_dir() -> Path: + # Retourne le chemin du dossier contenant réellement le fichier .exe. + # C'est ici que se trouve votre 'config.json'. + if getattr(sys, 'frozen', False): + # sys.executable est le chemin complet vers l'application .exe + return Path(sys.executable).parent.resolve() + return Path(__file__).resolve().parents[2] diff --git a/src/ui/main_window.py b/src/ui/main_window.py index 0c7b621..1321c80 100644 --- a/src/ui/main_window.py +++ b/src/ui/main_window.py @@ -8,7 +8,7 @@ from PySide6.QtUiTools import QUiLoader from PySide6.QtWidgets import QMainWindow, QSizePolicy from config.config_manager import ConfigManager -from constants import NO_DISCORD, NO_STAFF, Urls +from tools.constants import NO_DISCORD, NO_STAFF, Urls from controllers.audio_controller import AudioController from controllers.glow_animator import GlowAnimator from controllers.window_dragger import WindowDragger diff --git a/src/utils.py b/src/utils.py deleted file mode 100644 index 5a7d6ef..0000000 --- a/src/utils.py +++ /dev/null @@ -1,7 +0,0 @@ -import sys -from pathlib import Path - -def get_bundle_dir() -> Path: - if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'): - return Path(sys._MEIPASS) - return Path(__file__).resolve().parent.parent