diff --git a/.github/workflows/development.yml b/.github/workflows/development.yml new file mode 100644 index 0000000..6d53f06 --- /dev/null +++ b/.github/workflows/development.yml @@ -0,0 +1,35 @@ +name: Development workflow + +on: + push: + branches: + - Development + +jobs: + base-production: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2.3.2 + - name: Set up Python 3.9 + uses: actions/setup-python@v2 + with: + python-version: 3.9 + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + - name: Lint with flake8 + run: | + pip install flake8 + # stop the build if there are Python syntax errors or undefined names + flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics --exclude .git,__pycache__,docs/source/conf.py,old,build,dist + # exit-zero treats all errors as warnings + flake8 . --count --max-line-length=80 --statistics --exclude .git,__pycache__,docs/source/conf.py,old,build,dist + - uses: actions/setup-node@v2 + with: + node-version: '14' + - name: Install pyright + run: npm install pyright -g + - name: Run pyright + run: pyright SQLMatches/* \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e3235c3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,135 @@ + Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Demo files +*dem.bz2 + +# Ignoring message_ids.txt +message_ids.txt + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ diff --git a/LICENSE b/LICENSE index f288702..0ad25db 100644 --- a/LICENSE +++ b/LICENSE @@ -1,5 +1,5 @@ - GNU GENERAL PUBLIC LICENSE - Version 3, 29 June 2007 + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies @@ -7,17 +7,15 @@ Preamble - The GNU General Public License is a free, copyleft license for -software and other kinds of works. + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, -the GNU General Public License is intended to guarantee your freedom to +our General Public Licenses are intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free -software for all its users. We, the Free Software Foundation, use the -GNU General Public License for most of our software; it applies also to -any other work released this way by its authors. You can apply it to -your programs, too. +software for all its users. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you @@ -26,44 +24,34 @@ them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. - To protect your rights, we need to prevent others from denying you -these rights or asking you to surrender the rights. Therefore, you have -certain responsibilities if you distribute copies of the software, or if -you modify it: responsibilities to respect the freedom of others. - - For example, if you distribute copies of such a program, whether -gratis or for a fee, you must pass on to the recipients the same -freedoms that you received. You must make sure that they, too, receive -or can get the source code. And you must show them these terms so they -know their rights. - - Developers that use the GNU GPL protect your rights with two steps: -(1) assert copyright on the software, and (2) offer you this License -giving you legal permission to copy, distribute and/or modify it. - - For the developers' and authors' protection, the GPL clearly explains -that there is no warranty for this free software. For both users' and -authors' sake, the GPL requires that modified versions be marked as -changed, so that their problems will not be attributed erroneously to -authors of previous versions. - - Some devices are designed to deny users access to install or run -modified versions of the software inside them, although the manufacturer -can do so. This is fundamentally incompatible with the aim of -protecting users' freedom to change the software. The systematic -pattern of such abuse occurs in the area of products for individuals to -use, which is precisely where it is most unacceptable. Therefore, we -have designed this version of the GPL to prohibit the practice for those -products. If such problems arise substantially in other domains, we -stand ready to extend this provision to those domains in future versions -of the GPL, as needed to protect the freedom of users. - - Finally, every program is threatened constantly by software patents. -States should not allow patents to restrict development and use of -software on general-purpose computers, but in those that do, we wish to -avoid the special danger that patents applied to a free program could -make it effectively proprietary. To prevent this, the GPL assures that -patents cannot be used to render the program non-free. + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. The precise terms and conditions for copying, distribution and modification follow. @@ -72,7 +60,7 @@ modification follow. 0. Definitions. - "This License" refers to version 3 of the GNU General Public License. + "This License" refers to version 3 of the GNU Affero General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. @@ -549,35 +537,45 @@ to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. - 13. Use with the GNU Affero General Public License. + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed -under version 3 of the GNU Affero General Public License into a single +under version 3 of the GNU General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, -but the special requirements of the GNU Affero General Public License, -section 13, concerning interaction through a network will apply to the -combination as such. +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of -the GNU General Public License from time to time. Such new versions will -be similar in spirit to the present version, but may differ in detail to +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU General +Program specifies that a certain numbered version of the GNU Affero General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the -GNU General Public License, you may choose any version ever published +GNU Affero General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future -versions of the GNU General Public License can be used, that proxy's +versions of the GNU Affero General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. @@ -635,40 +633,29 @@ the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. + GNU Affero General Public License for more details. - You should have received a copy of the GNU General Public License + You should have received a copy of the GNU Affero General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. - If the program does terminal interaction, make it output a short -notice like this when it starts in an interactive mode: - - Copyright (C) - This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. - This is free software, and you are welcome to redistribute it - under certain conditions; type `show c' for details. - -The hypothetical commands `show w' and `show c' should show the appropriate -parts of the General Public License. Of course, your program's commands -might be different; for a GUI interface, you would use an "about box". + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. -For more information on this, and how to apply and follow the GNU GPL, see +For more information on this, and how to apply and follow the GNU AGPL, see . - - The GNU General Public License does not permit incorporating your program -into proprietary programs. If your program is a subroutine library, you -may consider it more useful to permit linking proprietary applications with -the library. If this is what you want to do, use the GNU Lesser General -Public License instead of this License. But first, please read -. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..f5f9a34 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1 @@ +recursive-include SQLMatches *.jpg *.jpeg *.png diff --git a/README.md b/README.md index 765c6b2..ad452e9 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,8 @@ -### Development status -This project will remain archived until I have time to re-write it. +## Currently in Development! -# Setup +# Will this ever be completed? +Currently I'm focusing on new projects, but I do plan to one day complete this. -### Game Server -- Move sqlmatch.smx into addons/sourcemod/plugins -- Edit addons/sourcemod/configs/databases.cfg -``` -"sql_matches" - { - "driver" "mysql" - "host" "ip" - "database" "db" - "user" "user" - "pass" "pass" - //"timeout" "0" - "port" "3306" -} -``` -### Web Server -- Upload files inside of the web server folder into your web server. -- Configure config.php. - -### Matches Page -![SQL Matches Preview](https://i.imgur.com/c4Zxyvt.png) - -### Scoreboard Page -![SQL Matches Preview](https://i.imgur.com/pAi46az.png) +# Legacy versions of SQLMatches +- [Python - 0.0.13 - 2019 to 2020](https://github.com/SQLMatches/API/tree/0.0.13) +- [PHP - 2018 to 2019](https://github.com/SQLMatches/API/tree/Legacy-PHP) diff --git a/SQLMatches/__init__.py b/SQLMatches/__init__.py new file mode 100644 index 0000000..49d110f --- /dev/null +++ b/SQLMatches/__init__.py @@ -0,0 +1,94 @@ +import uvicorn + +from falcon import asgi +from bcrypt import gensalt, hashpw +from secrets import token_urlsafe +from colorama import init, Fore +from os import get_terminal_size +from databases import Database + +from .resources import Config, Session +from .http import APP +from .tables import create_tables + +from .env import DATABASE_SETTINGS, FRONTEND_URL + + +init() + + +__all__ = [ + "SQLMatches" +] + + +class SQLMatches: + def __init__(self) -> None: + Session.db = Database(DATABASE_SETTINGS._url) + + self.__root_generate_pass = token_urlsafe(44) + Config.root_generate_hash = hashpw( + self.__root_generate_pass.encode(), gensalt() + ) + + create_tables(DATABASE_SETTINGS._url) + + @property + def root_password(self) -> str: + """The password for the root user. + + Returns + ------- + str + """ + + return self.__root_generate_pass + + @property + def app(self) -> asgi.App: + """Return the asgi application. + + Returns + ------- + asgi.App + """ + + return APP + + def serve(self, **kwargs) -> None: + """Serve the HTTP server. + """ + + def print_line() -> None: + try: + terminal_size = get_terminal_size() + except Exception: + pass + else: + print( + Fore.BLUE, "-" * terminal_size.columns, + Fore.RESET, sep="" + ) + + print_line() + + print(Fore.BLUE, r""" + __ ____ __ _ _ +/ _\ /___ \/ / /\/\ __ _| |_ ___| |__ ___ ___ +\ \ // / / / / \ / _` | __/ __| '_ \ / _ \/ __| +_\ \/ \_/ / /___/ /\/\ \ (_| | || (__| | | | __/\__ \ +\__/\___,_\____/\/ \/\__,_|\__\___|_| |_|\___||___/ + """, Fore.RESET, sep="") + + print((f"\nVisit URL below to generate new {Fore.YELLOW}root accounts" + f" {Fore.RED}(NEVER SHARE THIS URL WITH ANYONE){Fore.RESET}:")) + print(f"{FRONTEND_URL}/root/{self.__root_generate_pass}") + print((Fore.YELLOW + "Simply restart the web server to invalidate the" + " current link & to generate a new one."), Fore.RESET) + + print_line() + + try: + uvicorn.run(self.app, **kwargs) + except KeyboardInterrupt: + pass diff --git a/SQLMatches/env.py b/SQLMatches/env.py new file mode 100644 index 0000000..387d82e --- /dev/null +++ b/SQLMatches/env.py @@ -0,0 +1,32 @@ +import os +from dotenv import load_dotenv + +from .settings import DatabaseSettings, DemoSettings, SteamSettings + + +load_dotenv() + + +DATABASE_SETTINGS = DatabaseSettings( + os.environ["DB_USERNAME"], + os.environ["DB_PASSWORD"], + os.environ["DB_NAME"], + os.getenv("DB_SERVER", "localhost"), + int(os.getenv("DB_PORT", 3306)), + os.getenv("DB_ENGINE", "mysql") +) + + +DEMO_SETTINGS = DemoSettings( + os.getenv("DEMO_PATHWAY"), + os.getenv("DEMO_EXT", ".dem.bz2") +) + + +STEAM_SETTINGS = SteamSettings( + os.environ["STEAM_API_KEY"], + os.getenv("STEAM_API_URL", "https://api.steampowered.com/") +) + + +FRONTEND_URL = os.environ["FRONTEND_URL"] diff --git a/SQLMatches/errors.py b/SQLMatches/errors.py new file mode 100644 index 0000000..6d915be --- /dev/null +++ b/SQLMatches/errors.py @@ -0,0 +1,66 @@ +from enum import Enum + + +class SQLMatchesErrorCodes(Enum): + INTERNAL = 1000 + + MATCH_NOT_FOUND = 2000 + MATCH_ID_TAKEN = 2001 + + DEMO_NOT_FOUND = 3000 + + +class SQLMatchesError(Exception): + def __init__(self, msg: str = "Internal error", status_code: int = 500, + error_code: SQLMatchesErrorCodes = SQLMatchesErrorCodes.INTERNAL, # noqa: E501 + *args: object) -> None: + self.status_code = status_code + self.error_code = error_code + self.msg = msg + super().__init__(msg, *args) + + def response(self) -> dict: + """Response data. + + Returns + ------- + dict + """ + + return { + "data": None, + "error": { + "msg": self.msg, + "status_code": self.status_code, + "error_code": self.error_code.value + } + } + + +class MatchError(SQLMatchesError): + pass + + +class MatchNotFound(MatchError): + def __init__(self, msg: str = "Match not found", status_code: int = 404, + error_code: SQLMatchesErrorCodes = SQLMatchesErrorCodes.MATCH_NOT_FOUND, # noqa: E501 + *args: object) -> None: + super().__init__(msg, status_code, error_code, *args) + + +class MatchIdTaken(MatchError): + def __init__(self, msg: str = "Match ID taken", status_code: int = 400, + error_code: SQLMatchesErrorCodes = SQLMatchesErrorCodes.MATCH_ID_TAKEN, # noqa: E501 + *args: object) -> None: + super().__init__(msg, status_code, error_code, *args) + + +class DemoError(MatchError): + pass + + +class DemoNotFound(DemoError): + def __init__(self, msg: str = "Demo not found", status_code: int = 404, + error_code: SQLMatchesErrorCodes = SQLMatchesErrorCodes.DEMO_NOT_FOUND, # noqa: E501 + *args: object) -> None: + super().__init__(msg, status_code, error_code, *args) diff --git a/SQLMatches/helpers/basic_auth.py b/SQLMatches/helpers/basic_auth.py new file mode 100644 index 0000000..756e204 --- /dev/null +++ b/SQLMatches/helpers/basic_auth.py @@ -0,0 +1,38 @@ +import base64 +import binascii + +from typing import Tuple +from falcon import Request, HTTPUnauthorized + + +def request_to_basic_auth(req: Request) -> Tuple[str, str]: + """Converts request to basic auth username & password + Parameters + ---------- + req : Request + Returns + ------- + str + username + str + password + Raises + ------ + HTTPUnauthorized + """ + + if "AUTHORIZATION" not in req.headers: + raise HTTPUnauthorized() + + auth = req.headers["AUTHORIZATION"] + try: + scheme, credentials = auth.split() + if scheme.lower() != "basic": + raise HTTPUnauthorized() + decoded = base64.b64decode(credentials).decode("ascii") + except (ValueError, UnicodeDecodeError, binascii.Error): + raise HTTPUnauthorized() + + username, _, password = decoded.partition(":") + + return username, password diff --git a/SQLMatches/helpers/match/__init__.py b/SQLMatches/helpers/match/__init__.py new file mode 100644 index 0000000..88728b5 --- /dev/null +++ b/SQLMatches/helpers/match/__init__.py @@ -0,0 +1,299 @@ +from uuid import uuid4 +from sqlalchemy import select, func +from typing import Dict, List, Optional +from datetime import datetime + +from sqlalchemy.sql.elements import ClauseElement + +from ...errors import MatchNotFound +from ...tables import ( + scoreboard_total_table, spectator_table, + scoreboard_table, statistic_table +) +from ...resources import Session + +from ...models.match import ScoreboardModel + +from .players import MatchPlayers +from .demo import DemoFile + + +class Match: + def __init__(self, match_id: Optional[str] = None) -> None: + """Interact / create a match. + + Parameters + ---------- + match_id : str, optional + If not provided random one will be generated, by default None + """ + + if match_id is None: + match_id = str(uuid4()) + + self.__match_id = match_id + + @property + def __match_id_query(self) -> ClauseElement: + return scoreboard_total_table.c.match_id == self.match_id + + @property + def match_id(self) -> str: + """The ID of the match. + + Returns + ------- + str + ID of match + """ + + return self.__match_id + + @property + def demo(self) -> DemoFile: + """Interact with your demo. + + Returns + ------- + DemoFile + """ + + return DemoFile(self) + + def players(self, players: List[str]) -> MatchPlayers: + """Interact with players in a match. + + Parameters + ---------- + players : List[str] + List of SteamID64s + + Returns + ------- + MatchPlayers + """ + + return MatchPlayers(self, players) + + async def spectators(self) -> Dict[str, int]: + """Return a dictionary of spectator IDs with team. + + Returns + ------- + Dict[str, int] + Key is SteamID64, value is team they're spectating. + """ + + query = select([ + spectator_table.c.steam_id, spectator_table.c.team + ]).select_from(spectator_table).where( + spectator_table.c.match_id == self.match_id + ) + spectators = {} + async for spectator in Session.db.iterate(query): + spectators[spectator["steam_id"]] = spectator["team"] + + return spectators + + async def exists(self) -> bool: + """Returns True if the current match exists. + + Returns + ------- + bool + """ + + return await Session.db.fetch_val( + select([func.count()]).select_from( + scoreboard_total_table + ).where( + scoreboard_total_table.c.match_id == self.match_id + ) + ) > 0 + + async def scoreboard(self) -> ScoreboardModel: + """Get the match scoreboard. + + Returns + ------- + ScoreboardModel + + Raises + ------ + MatchNotFound + """ + + query = select([ + scoreboard_total_table.c.created, + scoreboard_total_table.c.status, + scoreboard_total_table.c.map, + scoreboard_total_table.c.demo_status, + scoreboard_total_table.c.team_1_name, + scoreboard_total_table.c.team_2_name, + scoreboard_total_table.c.team_1_score, + scoreboard_total_table.c.team_2_score, + scoreboard_total_table.c.team_1_side, + scoreboard_total_table.c.team_2_side, + statistic_table.c.steam_id, + statistic_table.c.name, + statistic_table.c.pfp, + scoreboard_table.c.team, + scoreboard_table.c.alive, + scoreboard_table.c.ping, + scoreboard_table.c.kills, + scoreboard_table.c.headshots, + scoreboard_table.c.assists, + scoreboard_table.c.deaths, + scoreboard_table.c.shots_fired, + scoreboard_table.c.shots_hit, + scoreboard_table.c.mvps, + scoreboard_table.c.score, + scoreboard_table.c.disconnected + ]).select_from( + scoreboard_total_table.join( + scoreboard_table, + scoreboard_table.c.match_id == + scoreboard_total_table.c.match_id + ).join( + statistic_table, + statistic_table.c.steam_id == scoreboard_table.c.steam_id + ) + ).where( + scoreboard_total_table.c.match_id == self.match_id + ).order_by(scoreboard_table.c.score.desc()) + + scoreboard_data = { + "match": None, + "team_1": [], + "team_2": [] + } + + team_1_append = scoreboard_data["team_1"].append + team_2_append = scoreboard_data["team_2"].append + + async for row in Session.db.iterate(query=query): + if not scoreboard_data["match"]: + scoreboard_data["match"] = { + "match_id": self.match_id, + "created": row["created"], + "status": row["status"], + "demo_status": row["demo_status"], + "map_": row["map"], + "team_1_name": row["team_1_name"], + "team_2_name": row["team_2_name"], + "team_1_score": row["team_1_score"], + "team_2_score": row["team_2_score"], + "team_1_side": row["team_1_side"], + "team_2_side": row["team_2_side"] + } + + team_append = team_1_append if row["team"] == 0 else team_2_append + + team_append({ + "steam_id": row["steam_id"], + "name": row["name"], + "pfp": row["pfp"], + "team": row["team"], + "alive": row["alive"], + "ping": row["ping"], + "kills": row["kills"], + "headshots": row["headshots"], + "assists": row["assists"], + "deaths": row["deaths"], + "shots_fired": row["shots_fired"], + "shots_hit": row["shots_hit"], + "mvps": row["mvps"], + "score": row["score"], + "disconnected": row["disconnected"] + }) + + if scoreboard_data["match"]: + return ScoreboardModel(**scoreboard_data) + else: + raise MatchNotFound() + + async def update(self, team_1_name: Optional[str] = None, + map_: Optional[str] = None, + status: Optional[int] = None, + demo_status: Optional[int] = None, + team_2_name: Optional[str] = None, + team_1_score: Optional[int] = None, + team_2_score: Optional[int] = None, + team_1_side: Optional[int] = None, + team_2_side: Optional[int] = None, + pre_setup: Optional[bool] = None, + require_ready: Optional[bool] = None, + connect_wait: Optional[int] = None + ) -> ScoreboardModel: + """Update / create the match. + + Parameters + ---------- + team_1_name : str, optional + by default None + map_ : str, optional + by default None + status : int, optional + by default None + demo_status : int, optional + by default None + team_2_name : str, optional + by default None + team_1_score : int, optional + by default None + team_2_score : int, optional + by default None + team_1_side : int, optional + by default None + team_2_side : int, optional + by default None + pre_setup : bool, optional + by default None + require_ready : bool, optional + by default None + connect_wait : int, optional + by default None + + Returns + ------- + ScoreboardModel + """ + + values = {} + if team_1_name is not None: + values["team_1_name"] = team_1_name + if team_2_name is not None: + values["team_2_name"] = team_2_name + if map_ is not None: + values["map"] = map_ + if status is not None: + values["status"] = status + if demo_status is not None: + values["demo_status"] = demo_status + if team_1_score is not None: + values["team_1_score"] = team_1_score + if team_2_score is not None: + values["team_2_score"] = team_2_score + if team_1_side is not None: + values["team_1_side"] = team_1_side + if team_2_side is not None: + values["team_2_side"] = team_2_side + if pre_setup is not None: + values["pre_setup"] = pre_setup + if require_ready is not None: + values["require_ready"] = require_ready + if connect_wait is not None: + values["connect_wait"] = connect_wait + + if await self.exists(): + scoreboard_total_table.update().where( + self.__match_id_query + ).values(**values) + else: + values["match_id"] = self.match_id + values["created"] = datetime.now() + scoreboard_total_table.insert( + **values + ) + + return await self.scoreboard() diff --git a/SQLMatches/helpers/match/demo.py b/SQLMatches/helpers/match/demo.py new file mode 100644 index 0000000..cdef023 --- /dev/null +++ b/SQLMatches/helpers/match/demo.py @@ -0,0 +1,129 @@ +import aiofiles +import aiofiles.os + +from falcon import Request, Response + +from sqlalchemy import select +from os import path +from typing import TYPE_CHECKING, Optional +from datetime import datetime +from uuid import uuid4 + +from ...resources import Session +from ...tables import scoreboard_total_table, demo_log_table +from ...errors import DemoNotFound +from ...env import DEMO_SETTINGS + + +if TYPE_CHECKING: + from . import Match + + +class DemoFile: + def __init__(self, upper: "Match") -> None: + """Interact with the demo file. + + Parameters + ---------- + upper : Match + """ + + self.__upper = upper + self._pathway = path.join( + DEMO_SETTINGS._pathway, + self.__upper.match_id + DEMO_SETTINGS._extension + ) + + async def __update_match(self, **kwargs) -> None: + await Session.db.execute( + scoreboard_total_table.update(**kwargs).where( + scoreboard_total_table.c.match_id == self.__upper.match_id + ) + ) + + async def __exist_raise(self) -> None: + """Raise a DemoNotFound if the demo doesn't exist. + + Raises + ------ + DemoNotFound + """ + + if not await self.exists(): + raise DemoNotFound() + + async def exists(self) -> bool: + """Return True if the path exists False otherwise. + + Returns + ------- + bool + """ + + return await aiofiles.os.path.exists(self._pathway) # type: ignore + + async def download(self, resp: Response, + steam_id: Optional[str] = None) -> None: + """Stream the demo to client. + + Parameters + ---------- + resp : Response + steam_id : str, optional + If provided download will be logged, by default None + + Raises + ------ + DemoNotFound + """ + + await self.__exist_raise() + + if steam_id is not None: + await Session.db.execute(demo_log_table.insert().values( + match_id=self.__upper.match_id, + steam_id=steam_id, + downloaded=datetime.now(), + log_id=str(uuid4()) + )) + + demo_size = await Session.db.fetch_val( + select([scoreboard_total_table.c.demo_size]).select_from( + scoreboard_total_table + ).where( + scoreboard_total_table.c.match_id == self.__upper.match_id + ) + ) + + resp.content_length = str(demo_size) if demo_size is not None else None + resp.stream = await aiofiles.open(self._pathway, "rb") # falcon magic + + async def save(self, req: Request) -> None: + """Save the match to the local path. + + Parameters + ---------- + req : Request + """ + + await self.__update_match(demo_status=1) + + size = 0 + async with aiofiles.open(self._pathway, "wb") as f_: + async for chunk in req.stream: + size += len(chunk) + await f_.write(chunk) + + await self.__update_match(demo_size=size, demo_status=2) + + async def delete(self) -> None: + """Delete the match from disk. + + Raises + ------ + DemoNotFound + """ + + await self.__exist_raise() + await aiofiles.os.remove(self._pathway) + await self.__update_match(demo_size=0, demo_status=3) diff --git a/SQLMatches/helpers/match/players.py b/SQLMatches/helpers/match/players.py new file mode 100644 index 0000000..2c73f59 --- /dev/null +++ b/SQLMatches/helpers/match/players.py @@ -0,0 +1,198 @@ +from typing import TYPE_CHECKING, List +from sqlalchemy import select, and_ +from datetime import datetime + +from sqlalchemy.sql.elements import ClauseElement + +from ...tables import scoreboard_table, spectator_table +from ...resources import Session +from ...errors import MatchNotFound +from ...env import STEAM_SETTINGS + +from ..sql_on_conflict import on_scoreboard_conflict, on_statistic_conflict + + +if TYPE_CHECKING: + from . import Match + + +class MatchPlayers: + def __init__(self, upper: "Match", players: List[str]) -> None: + self.__upper = upper + self.__players = players + + @property + def __player_in_match_query(self) -> ClauseElement: + return and_( + scoreboard_table.c.match_id == self.__upper.match_id, + scoreboard_table.c.steam_id.in_(self.__players) + ) + + @property + def players(self) -> List[str]: + return self.__players + + async def __format_stats(self) -> dict: + steam_data = {} + async with Session.requests.get( + STEAM_SETTINGS._api_url + + (f"ISteamUser/GetPlayerSummaries/v2/?key={STEAM_SETTINGS._api_key}" + f"&steamids={','.join(self.players)}") + ) as resp: + if resp.status == 200: + for user in (await resp.json())["response"]["players"]: + steam_data[user["steamid"]] = user + + return steam_data + + def __format_player(self, steam_id: str, steam_data: dict, + now: datetime) -> dict: + return { + "steam_id": steam_id, + "name": steam_data[steam_id]["name"][:42] + if steam_id in steam_data else "Unknown", + "pfp": steam_data[steam_id]["avatarfull"].replace( + "https://steamcdn-a.akamaihd.net/steamcommunity/public/images/", # noqa: E501 + "" + ) if steam_id in steam_data else None, + "kills": 0, + "headshots": 0, + "assists": 0, + "deaths": 0, + "shots_fired": 0, + "shots_hit": 0, + "mvps": 0, + "created": now + } + + async def add_as_spectator(self, team: int) -> None: + """Add a spectator to the game. + + Parameters + ---------- + team : int + - 0 = Spec any team + - 1 = Coach team 1 + - 2 = Coach team 2 + + Raises + ------ + MatchNotFound + """ + + if not await self.__upper.exists(): + raise MatchNotFound() + + steam_data = await self.__format_stats() + + stats = [] + specs = [] + + now = datetime.now() + for player in self.players: + stats.append(self.__format_player(player, steam_data, now)) + specs.append({ + "match_id": self.__upper.match_id, + "steam_id": player, + "team": team + }) + + await Session.db.execute_many( + on_statistic_conflict(), + stats + ) + await Session.db.execute_many( + spectator_table.insert(), + specs + ) + + async def add_as_player(self, team: int) -> None: + """Add the players to the match. + + Parameters + ---------- + team : int + Team number + + Raises + ------ + MatchNotFound + """ + + if not await self.__upper.exists(): + raise MatchNotFound() + + stats = [] + scoreboard = [] + + steam_data = await self.__format_stats() + + now = datetime.now() + for player in self.players: + stats.append(self.__format_player(player, steam_data, now)) + + scoreboard.append({ + "steam_id": player, + "match_id": self.__upper.match_id, + "team": team, + "alive": 0, + "ping": 0, + "kills": 0, + "headshots": 0, + "assists": 0, + "deaths": 0, + "shots_fired": 0, + "shots_hit": 0, + "mvps": 0, + "score": 0, + "disconnected": False + }) + + await Session.db.execute_many( + on_statistic_conflict(), + stats + ) + await Session.db.execute_many( + on_scoreboard_conflict(), + scoreboard + ) + + async def remove_from_match(self, spectator: bool = False) -> None: + """Remove players / spectators from match. + + Parameters + ---------- + spectator : bool, optional + by default False + """ + + if not spectator: + await Session.db.execute(scoreboard_table.delete( + self.__player_in_match_query + )) + else: + await Session.db.execute(spectator_table.delete(and_( + spectator_table.c.match_id == self.__upper.match_id, + spectator_table.c.steam_id.in_(self.players) + ))) + + async def who_in_match(self) -> List[str]: + """list of players who have played in match. + + Returns + ------- + List[str] + SteamID64 + """ + + query = select([scoreboard_table.c.steam_id]).select_from( + scoreboard_table + ).where(scoreboard_table.c.steam_id.in_( + self.__player_in_match_query + )) + + steam_ids = [] + async for player in Session.db.iterate(query): + steam_ids.append(player["steam_id"]) + + return steam_ids diff --git a/SQLMatches/helpers/sql_on_conflict.py b/SQLMatches/helpers/sql_on_conflict.py new file mode 100644 index 0000000..65157c8 --- /dev/null +++ b/SQLMatches/helpers/sql_on_conflict.py @@ -0,0 +1,97 @@ +from sqlalchemy.dialects.mysql import insert as mysql_insert +from sqlalchemy.dialects.postgresql import insert as postgresql_insert +from sqlalchemy.sql.elements import ClauseElement + +from ..tables import scoreboard_table, statistic_table +from ..resources import Session + + +def on_statistic_conflict() -> ClauseElement: + """Used for updating a statistics on conflict. + """ + + if Session.db.url.dialect == "mysql": + query_insert = mysql_insert(statistic_table) + return query_insert.on_duplicate_key_update( + name=query_insert.inserted.name, + kills=statistic_table.c.kills + query_insert.inserted.kills, + headshots=statistic_table.c.headshots + + query_insert.inserted.headshots, + assists=statistic_table.c.assists + query_insert.inserted.assists, + deaths=statistic_table.c.deaths + query_insert.inserted.deaths, + shots_fired=statistic_table.c.shots_fired + + query_insert.inserted.shots_fired, + shots_hit=statistic_table.c.shots_hit + + query_insert.inserted.shots_hit, + mvps=statistic_table.c.mvps + query_insert.inserted.mvps + ) + elif Session.db.url.dialect == "psycopg2": + query_insert = postgresql_insert(statistic_table) + return query_insert.on_conflict_do_update( + set_=dict( + name=query_insert.inserted.name, + kills=statistic_table.c.kills + query_insert.inserted.kills, + headshots=statistic_table.c.headshots + + query_insert.inserted.headshots, + assists=statistic_table.c.assists + + query_insert.inserted.assists, + deaths=statistic_table.c.deaths + query_insert.inserted.deaths, + shots_fired=statistic_table.c.shots_fired + + query_insert.inserted.shots_fired, + shots_hit=statistic_table.c.shots_hit + + query_insert.inserted.shots_hit, + mvps=statistic_table.c.mvps + query_insert.inserted.mvps + ) + ) + else: + return statistic_table.insert # type: ignore + + +def on_scoreboard_conflict() -> ClauseElement: + """Used for updating a player on a scoreboard on conflict. + """ + + if Session.db.url.dialect == "mysql": + query_insert = mysql_insert(scoreboard_table) + return query_insert.on_duplicate_key_update( + team=query_insert.inserted.team, + alive=query_insert.inserted.alive, + ping=query_insert.inserted.ping, + kills=scoreboard_table.c.kills + query_insert.inserted.kills, + headshots=scoreboard_table.c.headshots + + query_insert.inserted.headshots, + assists=scoreboard_table.c.assists + query_insert.inserted.assists, + deaths=scoreboard_table.c.deaths + query_insert.inserted.deaths, + shots_fired=scoreboard_table.c.shots_fired + + query_insert.inserted.shots_fired, + shots_hit=scoreboard_table.c.shots_hit + + query_insert.inserted.shots_hit, + mvps=scoreboard_table.c.mvps + query_insert.inserted.mvps, + score=scoreboard_table.c.score + query_insert.inserted.score, + disconnected=query_insert.inserted.disconnected + ) + elif Session.db.url.dialect == "psycopg2": + query_insert = postgresql_insert(scoreboard_table) + return query_insert.on_conflict_do_update( + set_=dict( + team=query_insert.inserted.team, + alive=query_insert.inserted.alive, + ping=query_insert.inserted.ping, + kills=scoreboard_table.c.kills + query_insert.inserted.kills, + headshots=scoreboard_table.c.headshots + + query_insert.inserted.headshots, + assists=scoreboard_table.c.assists + + query_insert.inserted.assists, + deaths=scoreboard_table.c.deaths + + query_insert.inserted.deaths, + shots_fired=scoreboard_table.c.shots_fired + + query_insert.inserted.shots_fired, + shots_hit=scoreboard_table.c.shots_hit + + query_insert.inserted.shots_hit, + mvps=scoreboard_table.c.mvps + query_insert.inserted.mvps, + score=scoreboard_table.c.score + query_insert.inserted.score, + disconnected=query_insert.inserted.disconnected + ) + ) + else: + return scoreboard_table.insert # type: ignore diff --git a/SQLMatches/http/__init__.py b/SQLMatches/http/__init__.py new file mode 100644 index 0000000..2db6f26 --- /dev/null +++ b/SQLMatches/http/__init__.py @@ -0,0 +1,13 @@ +from falcon import asgi + +# Request serializers +from .serializers import json_serialize + +# Middlewares +from .middlewares import SessionComponent + + +APP = asgi.App() + +APP.add_middleware(SessionComponent()) +APP.set_error_serializer(json_serialize) diff --git a/SQLMatches/http/hooks.py b/SQLMatches/http/hooks.py new file mode 100644 index 0000000..858af30 --- /dev/null +++ b/SQLMatches/http/hooks.py @@ -0,0 +1,58 @@ +from typing import Callable, List, Union +from bcrypt import checkpw +from falcon import Request, Response, HTTPUnauthorized +from sqlalchemy import select + +from ..helpers.basic_auth import request_to_basic_auth +from ..resources import Config, Session +from ..tables import api_key_table + + +def root_required(req: Request, resp: Response, resource, params) -> None: + _, password = request_to_basic_auth(req) + + if not checkpw(password.encode(), Config.root_generate_hash): + raise HTTPUnauthorized() + + +def required_scopes(scopes: Union[List[str], str]) -> Callable: + """Used to validate API key has scopes. + + Parameters + ---------- + scopes : Union[List[str], str] + + Returns + ------- + Callable + + Raises + ------ + HTTPUnauthorized + """ + + scopes = [scopes] if isinstance(scopes, str) else list(scopes) + + async def hook_(req: Request, resp: Response, resource, params) -> None: + steam_id, api_key = request_to_basic_auth(req) + + row = await Session.db.fetch_one( + select( + [api_key_table.c.scopes, api_key_table.c.api_key] + ).select_from(api_key_table).where( + api_key_table.c.steam_id == steam_id + ) + ) + + if (not row or not row["scopes"] or + not checkpw(api_key.encode(), row["api_key"].encode())): + raise HTTPUnauthorized() + else: + user_scopes = row["scopes"].strip(",") + for scope in scopes: + if scope not in user_scopes: + raise HTTPUnauthorized() + + req.context.steam_id = steam_id + + return hook_ diff --git a/SQLMatches/http/middlewares.py b/SQLMatches/http/middlewares.py new file mode 100644 index 0000000..36c1327 --- /dev/null +++ b/SQLMatches/http/middlewares.py @@ -0,0 +1,13 @@ +from aiohttp import ClientSession + +from ..resources import Session + + +class SessionComponent: + async def process_startup(self, scope, event) -> None: + await Session.db.connect() + Session.requests = ClientSession() + + async def process_shutdown(self, scope, event) -> None: + await Session.db.disconnect() + await Session.requests.close() diff --git a/SQLMatches/http/routes/demo.py b/SQLMatches/http/routes/demo.py new file mode 100644 index 0000000..f29e7de --- /dev/null +++ b/SQLMatches/http/routes/demo.py @@ -0,0 +1,23 @@ +from falcon import Request, Response, before + +from ..hooks import required_scopes +from ...helpers.match import Match + + +class DemoResource: + async def on_get(self, req: Request, resp: Response, + match_id: str) -> None: + await Match(match_id).demo.download( + resp, + req.context.get_session("steam_id") + ) + + @before(required_scopes("demo.upload"), is_async=True) + async def on_put(self, req: Request, resp: Response, + match_id: str) -> None: + await Match(match_id).demo.save(req) + + @before(required_scopes("demo.delete"), is_async=True) + async def on_delete(self, req: Request, resp: Response, + match_id: str) -> None: + await Match(match_id).demo.delete() diff --git a/SQLMatches/http/serializers.py b/SQLMatches/http/serializers.py new file mode 100644 index 0000000..fe6e959 --- /dev/null +++ b/SQLMatches/http/serializers.py @@ -0,0 +1,9 @@ +import falcon +from falcon import Request, Response + + +def json_serialize(req: Request, resp: Response, exception) -> None: + resp.data = exception.to_json() + resp.content_type = falcon.MEDIA_JSON + + resp.append_header("Vary", "Accept") diff --git a/SQLMatches/models/match.py b/SQLMatches/models/match.py new file mode 100644 index 0000000..b0c5998 --- /dev/null +++ b/SQLMatches/models/match.py @@ -0,0 +1,201 @@ +from typing import Any, Dict, Generator, List +from datetime import datetime + + +class _DepthStatsModel: + def __init__(self, kills: int, deaths: int, + headshots: int, shots_hit: int, + shots_fired: int) -> None: + self.kills = kills + self.deaths = deaths + self.headshots = headshots + self.shots_hit = shots_hit + self.shots_fired = shots_fired + + @property + def kdr(self) -> float: + return ( + round(self.kills / self.deaths, 2) + if self.kills > 0 and self.deaths > 0 else 0.00 + ) + + @property + def hs_percentage(self) -> float: + return ( + round((self.headshots / self.kills) * 100, 2) + if self.kills > 0 and self.headshots > 0 else 0.00 + ) + + @property + def hit_percentage(self) -> float: + return ( + round((self.shots_hit / self.shots_fired) * 100, 2) + if self.shots_fired > 0 and self.shots_hit > 0 else 0.00 + ) + + +class MatchModel: + def __init__(self, match_id: str, created: datetime, status: int, + demo_status: int, map_: str, team_1_name: str, + team_2_name: str, team_1_score: int, + team_2_score: int, team_1_side: int, + team_2_side: int) -> None: + self.match_id = match_id + self.created = created + self.status = status + self.demo_status = demo_status + self.map_ = map_ + self.team_1_name = team_1_name + self.team_2_name = team_2_name + self.team_1_score = team_1_score + self.team_2_score = team_2_score + self.team_1_side = team_1_side + self.team_2_side = team_2_side + + @property + def api_schema(self) -> dict: + return { + "match_id": self.match_id, + "created": self.created.timestamp(), + "status": self.status, + "demo_status": self.demo_status, + "map": self.map_, + "team_1_name": self.team_1_name, + "team_2_name": self.team_2_name, + "team_1_score": self.team_1_score, + "team_2_score": self.team_2_score, + "team_1_side": self.team_1_side, + "team_2_side": self.team_2_side, + } + + +class ProfileModel(_DepthStatsModel): + def __init__(self, name: str, steam_id: str, kills: int, headshots: int, + assists: int, deaths: int, pfp: str, + shots_fired: int, shots_hit: int, + mvps: int, created: datetime, **kwargs) -> None: + _DepthStatsModel.__init__( + self, kwargs["kills"], kwargs["deaths"], + kwargs["headshots"], shots_hit, shots_fired + ) + + self.name = name + self.steam_id = steam_id + self.kills = kills + self.headshots = headshots + self.assists = assists + self.deaths = deaths + self.pfp = pfp + self.shots_fired = shots_fired + self.shots_hit = shots_hit + self.mvps = mvps + self.created = created + + @property + def api_schema(self) -> dict: + return { + "name": self.name, + "steam_id": self.steam_id, + "kills": self.kills, + "headshots": self.headshots, + "assists": self.assists, + "deaths": self.deaths, + "pfp": self.pfp, + "kdr": self.kdr, + "hs_percentage": self.hs_percentage, + "hit_percentage": self.hit_percentage, + "shots_fired": self.shots_fired, + "shots_hit": self.shots_hit, + "mvps": self.mvps, + "created": self.created.timestamp() + } + + +class _ScoreboardPlayerModel(_DepthStatsModel): + def __init__(self, name: str, steam_id: str, team: int, + alive: bool, ping: int, kills: int, headshots: int, + assists: int, deaths: int, shots_fired: int, + shots_hit: int, mvps: int, score: int, + disconnected: bool, pfp: str) -> None: + super().__init__(kills, deaths, + headshots, shots_hit, shots_fired) + + self.name = name + self.steam_id = steam_id + self.team = team + self.alive = alive + self.ping = ping + self.kills = kills + self.headshots = headshots + self.assists = assists + self.deaths = deaths + self.shots_fired = shots_fired + self.shots_hit = shots_hit + self.mvps = mvps + self.score = score + self.disconnected = disconnected + self.pfp = pfp + + +class ScoreboardModel(MatchModel): + def __init__(self, team_1: List[Dict[str, Any]], + team_2: List[Dict[str, Any]], match: Dict[str, Any]) -> None: + super().__init__(**match) + + self.__team_1 = team_1 + self.__team_2 = team_2 + + def team_1(self) -> Generator[_ScoreboardPlayerModel, None, None]: + """Lists players in team 1. + + Yields + ------ + _ScoreboardPlayerModel + Holds player data. + """ + + for player in self.__team_1: + yield _ScoreboardPlayerModel(**player) + + def team_2(self) -> Generator[_ScoreboardPlayerModel, None, None]: + """Lists players in team 2. + + Yields + ------ + _ScoreboardPlayerModel + Holds player data. + """ + + for player in self.__team_2: + yield _ScoreboardPlayerModel(**player) + + @property + def api_schema(self) -> dict: + return { + **super().api_schema, + "team_1": self.__team_1, + "team_2": self.__team_2 + } + + +class ServerModel: + def __init__(self, ip: str, + port: int, name: str, players: int, + max_players: int, map_: str) -> None: + self.ip = ip + self.port = port + self.name = name + self.players = players + self.max_players = max_players + self.map_ = map_ + + @property + def api_schema(self) -> dict: + return { + "name": self.name, + "players": self.players, + "max_players": self.max_players, + "ip": self.ip, + "port": self.port, + "map": self.map_ + } diff --git a/SQLMatches/resources.py b/SQLMatches/resources.py new file mode 100644 index 0000000..8dd5c39 --- /dev/null +++ b/SQLMatches/resources.py @@ -0,0 +1,11 @@ +from databases import Database +from aiohttp import ClientSession + + +class Session: + db: Database + requests: ClientSession + + +class Config: + root_generate_hash: bytes diff --git a/SQLMatches/settings/__init__.py b/SQLMatches/settings/__init__.py new file mode 100644 index 0000000..5138405 --- /dev/null +++ b/SQLMatches/settings/__init__.py @@ -0,0 +1,9 @@ +from .demo import DemoSettings +from .database import DatabaseSettings +from .steam import SteamSettings + +__all__ = [ + "DemoSettings", + "DatabaseSettings", + "SteamSettings" +] diff --git a/SQLMatches/settings/database.py b/SQLMatches/settings/database.py new file mode 100644 index 0000000..9eca6fe --- /dev/null +++ b/SQLMatches/settings/database.py @@ -0,0 +1,32 @@ +from urllib.parse import quote_plus + + +class DatabaseSettings: + def __init__( + self, + username: str, + password: str, + database: str, + server: str, + port: int, + engine: str + ) -> None: + """Database settings. + Parameters + ---------- + username : str + password : str + database : str + server : str + port : int + engine : str + """ + + self._url = "{}://{}:{}@{}:{}/{}?charset=utf8mb4".format( + engine, + username, + quote_plus(password), + server, + port, + database + ) diff --git a/SQLMatches/settings/demo.py b/SQLMatches/settings/demo.py new file mode 100644 index 0000000..0c2059c --- /dev/null +++ b/SQLMatches/settings/demo.py @@ -0,0 +1,30 @@ +from typing import Optional +from os import path, mkdir + + +class DemoSettings: + def __init__(self, pathway: Optional[str], + extension: str) -> None: + """Initialize the demo directory. + + Parameters + ---------- + pathway : Optional[str] + extension: str + """ + + if pathway: + self._pathway = pathway + else: + self._pathway = path.join( + path.abspath(path.dirname(__name__)), + "demos" + ) + + try: + if not path.exists(self._pathway): + mkdir(self._pathway) + except Exception: + pass + + self._extension = extension diff --git a/SQLMatches/settings/steam.py b/SQLMatches/settings/steam.py new file mode 100644 index 0000000..45682b1 --- /dev/null +++ b/SQLMatches/settings/steam.py @@ -0,0 +1,4 @@ +class SteamSettings: + def __init__(self, api_key: str, api_url: str) -> None: + self._api_key = api_key + self._api_url = api_url diff --git a/SQLMatches/tables.py b/SQLMatches/tables.py new file mode 100644 index 0000000..403f75a --- /dev/null +++ b/SQLMatches/tables.py @@ -0,0 +1,391 @@ +from sqlalchemy import ( + Table, + MetaData, + String, + Column, + TIMESTAMP, + ForeignKey, + Integer, + Boolean, + PrimaryKeyConstraint, + create_engine +) + +metadata = MetaData() + +server_table = Table( + "server", + metadata, + Column( + "ip", + String(length=15), + primary_key=True + ), + Column( + "port", + Integer, + primary_key=True + ), + Column( + "name", + String(length=64) + ), + Column( + "players", + Integer, + default=0 + ), + Column( + "max_players", + Integer, + default=0 + ), + Column( + "map", + String(length=24) + ), + PrimaryKeyConstraint( + "ip", + "port" + ), + mysql_engine="InnoDB", + mysql_charset="utf8mb4" +) + +statistic_table = Table( + "statistic", + metadata, + Column( + "steam_id", + String(length=64), + primary_key=True + ), + Column( + "name", + String(length=42) + ), + Column( + "pfp", + String(length=70) + ), + Column( + "kills", + Integer, + default=0 + ), + Column( + "headshots", + Integer, + default=0 + ), + Column( + "assists", + Integer, + default=0 + ), + Column( + "deaths", + Integer, + default=0 + ), + Column( + "shots_fired", + Integer, + default=0 + ), + Column( + "shots_hit", + Integer, + default=0 + ), + Column( + "mvps", + Integer, + default=0 + ), + Column( + "created", + TIMESTAMP + ), + mysql_engine="InnoDB", + mysql_charset="utf8mb4" +) + +api_key_table = Table( + "api_key", + metadata, + Column( + "api_key", + String(length=70), + primary_key=True + ), + Column( + "steam_id", + String(length=64), + ForeignKey("statistic.steam_id") + ), + Column( + "timestamp", + TIMESTAMP + ), + Column( + "scopes", + String(length=556) + ), + mysql_engine="InnoDB", + mysql_charset="utf8mb4" +) + +# Scoreboard total table +# Status codes +# 0 - Finished +# 1 - Live + +# Demo status codes +# 0 - No demo +# 1 - Processing +# 2 - Ready for Download +# 3 - Deleted + +# Team Sides +# 0 - CT +# 1 - T +scoreboard_total_table = Table( + "scoreboard_total", + metadata, + Column( + "match_id", + String(length=36), + primary_key=True + ), + Column( + "created", + TIMESTAMP + ), + Column( + "status", + Integer + ), + Column( + "demo_status", + Integer + ), + Column( + "demo_size", # Size of demo in bytes + Integer + ), + Column( + "map", + String(length=24) + ), + Column( + "team_1_name", + String(length=64) + ), + Column( + "team_2_name", + String(length=64) + ), + Column( + "team_1_score", + Integer, + default=0 + ), + Column( + "team_2_score", + Integer, + default=0 + ), + Column( + "team_1_side", + Integer, + default=0 + ), + Column( + "team_2_side", + Integer, + default=0 + ), + Column( + "pre_setup", + Boolean + ), + Column( + "require_ready", + Boolean + ), + Column( + "connect_wait", + Integer + ), + mysql_engine="InnoDB", + mysql_charset="utf8mb4" +) + +demo_log_table = Table( + "demo_log", + metadata, + Column( + "log_id", + String(length=36), + primary_key=True + ), + Column( + "match_id", + String(length=36), + ForeignKey("scoreboard_total.match_id", ondelete="CASCADE") + ), + Column( + "steam_id", # No a forign key because ID might not in stats. + String(length=64) + ), + Column( + "downloaded", + TIMESTAMP + ), + mysql_engine="InnoDB", + mysql_charset="utf8mb4" +) + +# Team Codes (Who they can spectate, if a specific team they'll be coach) +# 0 = Any +# 1 = Team 1 +# 2 = Team 2 +spectator_table = Table( + "spectator", + metadata, + Column( + "match_id", + String(length=36), + ForeignKey("scoreboard_total.match_id", ondelete="CASCADE"), + primary_key=True + ), + Column( + "steam_id", + String(length=64), + ForeignKey("statistic.steam_id"), + primary_key=True + ), + Column( + "team", + Integer + ), + PrimaryKeyConstraint( + "steam_id", + "match_id", + sqlite_on_conflict="REPLACE" + ), + mysql_engine="InnoDB", + mysql_charset="utf8mb4" +) + +# Team Codes +# 1 = Team 1 +# 2 = Team 2 +scoreboard_table = Table( + "scoreboard", + metadata, + Column( + "match_id", + String(length=36), + ForeignKey("scoreboard_total.match_id", ondelete="CASCADE"), + primary_key=True + ), + Column( + "steam_id", + String(length=64), + ForeignKey("statistic.steam_id"), + primary_key=True + ), + Column( + "team", + Integer + ), + Column( + "alive", + Boolean, + default=True + ), + Column( + "ping", + Integer, + default=0 + ), + Column( + "kills", + Integer, + default=0 + ), + Column( + "headshots", + Integer, + default=0 + ), + Column( + "assists", + Integer, + default=0 + ), + Column( + "deaths", + Integer, + default=0 + ), + Column( + "shots_fired", + Integer, + default=0 + ), + Column( + "shots_hit", + Integer, + default=0 + ), + Column( + "mvps", + Integer, + default=0 + ), + Column( + "score", + Integer, + default=0 + ), + Column( + "disconnected", + Boolean, + default=False + ), + PrimaryKeyConstraint( + "steam_id", + "match_id", + sqlite_on_conflict="REPLACE" + ), + mysql_engine="InnoDB", + mysql_charset="utf8mb4" +) + + +def create_tables(url: str) -> None: + """Create tables in the URL. + + Parameters + ---------- + url : str + """ + + if "mysql" in url: + old_engine = "mysql" + engine = "pymysql" + elif "sqlite" in url: + old_engine = "sqlite" + engine = "sqlite3" + elif "postgresql" in url: + old_engine = "postgresql" + engine = "psycopg2" + else: + assert False, "Invalid database URL engine." + + metadata.create_all( + create_engine(url.replace(old_engine, f"{old_engine}+{engine}", 1)) + ) diff --git a/game server/sqlmatch.smx b/game server/sqlmatch.smx deleted file mode 100644 index becdf6b..0000000 Binary files a/game server/sqlmatch.smx and /dev/null differ diff --git a/game server/sqlmatch.sp b/game server/sqlmatch.sp deleted file mode 100644 index 45d96a0..0000000 --- a/game server/sqlmatch.sp +++ /dev/null @@ -1,151 +0,0 @@ -#include -#include -#include - -#pragma semicolon 1 -#pragma newdecls required - -Handle db; - -public void OnPluginStart() -{ - char buffer[1024]; - - if ((db = SQL_Connect("sql_matches", true, buffer, sizeof(buffer))) == null) - { - SetFailState(buffer); - } - - Format(buffer, sizeof(buffer), "CREATE TABLE IF NOT EXISTS sql_matches_scoretotal ("); - Format(buffer, sizeof(buffer), "%s match_id bigint(20) unsigned NOT NULL AUTO_INCREMENT,", buffer); - Format(buffer, sizeof(buffer), "%s timestamp timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,", buffer); - Format(buffer, sizeof(buffer), "%s team_0 int(11) NOT NULL,", buffer); - Format(buffer, sizeof(buffer), "%s team_1 int(11) NOT NULL,", buffer); - Format(buffer, sizeof(buffer), "%s team_2 int(11) NOT NULL,", buffer); - Format(buffer, sizeof(buffer), "%s team_3 int(11) NOT NULL,", buffer); - Format(buffer, sizeof(buffer), "%s teamname_1 varchar(64) NOT NULL,", buffer); - Format(buffer, sizeof(buffer), "%s teamname_2 varchar(64) NOT NULL,", buffer); - Format(buffer, sizeof(buffer), "%s map varchar(128) NOT NULL,", buffer); - Format(buffer, sizeof(buffer), "%s PRIMARY KEY (match_id),", buffer); - Format(buffer, sizeof(buffer), "%s UNIQUE KEY match_id (match_id));", buffer); - - if (!SQL_FastQuery(db, buffer)) - { - SQL_GetError(db, buffer, sizeof(buffer)); - SetFailState(buffer); - } - - Format(buffer, sizeof(buffer), "CREATE TABLE IF NOT EXISTS sql_matches ("); - Format(buffer, sizeof(buffer), "%s match_id bigint(20) NOT NULL,", buffer); - Format(buffer, sizeof(buffer), "%s name varchar(65) NOT NULL,", buffer); - Format(buffer, sizeof(buffer), "%s steamid64 varchar(64) NOT NULL,", buffer); - Format(buffer, sizeof(buffer), "%s team int(11) NOT NULL,", buffer); - Format(buffer, sizeof(buffer), "%s alive int(11) NOT NULL,", buffer); - Format(buffer, sizeof(buffer), "%s ping int(11) NOT NULL,", buffer); - Format(buffer, sizeof(buffer), "%s account int(11) NOT NULL,", buffer); - Format(buffer, sizeof(buffer), "%s kills int(11) NOT NULL,", buffer); - Format(buffer, sizeof(buffer), "%s assists int(11) NOT NULL,", buffer); - Format(buffer, sizeof(buffer), "%s deaths int(11) NOT NULL,", buffer); - Format(buffer, sizeof(buffer), "%s mvps int(11) NOT NULL,", buffer); - Format(buffer, sizeof(buffer), "%s score int(11) NOT NULL);", buffer); - - if (!SQL_FastQuery(db, buffer)) - { - SQL_GetError(db, buffer, sizeof(buffer)); - SetFailState(buffer); - } - - HookEventEx("cs_win_panel_match", cs_win_panel_match); -} - -public void cs_win_panel_match(Handle event, const char[] eventname, bool dontBroadcast) -{ - CreateTimer(0.1, delay, _, TIMER_FLAG_NO_MAPCHANGE); -} - -public Action delay(Handle timer) -{ - Transaction txn = SQL_CreateTransaction(); - - char mapname[128]; - GetCurrentMap(mapname, sizeof(mapname)); - - char teamname1[64]; - char teamname2[64]; - - GetConVarString(FindConVar("mp_teamname_1"), teamname1, sizeof(teamname1)); - GetConVarString(FindConVar("mp_teamname_2"), teamname2, sizeof(teamname2)); - - char buffer[512]; - - Format(buffer, sizeof(buffer), "INSERT INTO sql_matches_scoretotal (team_0, team_1, team_2, team_3, teamname_1, teamname_2, map) VALUES (0, 0, 0, 0, '%s', '%s', '%s');", teamname1, teamname2, mapname); - SQL_AddQuery(txn, buffer); - - int ent = MaxClients+1; - - while ((ent = FindEntityByClassname(ent, "cs_team_manager")) != -1) - { - Format(buffer, sizeof(buffer), "UPDATE sql_matches_scoretotal SET team_%i = %i WHERE match_id = LAST_INSERT_ID();", GetEntProp(ent, Prop_Send, "m_iTeamNum"), GetEntProp(ent, Prop_Send, "m_scoreTotal")); - SQL_AddQuery(txn, buffer); - } - - char name[MAX_NAME_LENGTH]; - char steamid64[64]; - - int m_iTeam; - int m_bAlive; - int m_iPing; - int m_iAccount; - int m_iKills; - int m_iAssists; - int m_iDeaths; - int m_iMVPs; - int m_iScore; - - if ((ent = FindEntityByClassname(-1, "cs_player_manager")) != -1) - { - for (int i = 1; i <= MaxClients; i++) - { - if (!IsClientInGame(i)) - { - continue; - } - - m_iTeam = GetEntProp(ent, Prop_Send, "m_iTeam", _, i); - m_bAlive = GetEntProp(ent, Prop_Send, "m_bAlive", _, i); - m_iPing = GetEntProp(ent, Prop_Send, "m_iPing", _, i); - m_iAccount = GetEntProp(i, Prop_Send, "m_iAccount"); - m_iKills = GetEntProp(ent, Prop_Send, "m_iKills", _, i); - m_iAssists = GetEntProp(ent, Prop_Send, "m_iAssists", _, i); - m_iDeaths = GetEntProp(ent, Prop_Send, "m_iDeaths", _, i); - m_iMVPs = GetEntProp(ent, Prop_Send, "m_iMVPs", _, i); - m_iScore = GetEntProp(ent, Prop_Send, "m_iScore", _, i); - - Format(name, MAX_NAME_LENGTH, "%N", i); - SQL_EscapeString(db, name, name, sizeof(name)); - - if (!GetClientAuthId(i, AuthId_SteamID64, steamid64, sizeof(steamid64))) - { - steamid64[0] = '\0'; - } - - Format(buffer, sizeof(buffer), "INSERT INTO sql_matches"); - Format(buffer, sizeof(buffer), "%s (match_id, team, alive, ping, name, account, kills, assists, deaths, mvps, score, steamid64)", buffer); - Format(buffer, sizeof(buffer), "%s VALUES (LAST_INSERT_ID(), '%i', '%i', '%i', '%s', '%i', '%i', '%i', '%i', '%i', '%i', '%s');", buffer, m_iTeam, m_bAlive, m_iPing, name, m_iAccount, m_iKills, m_iAssists, m_iDeaths, m_iMVPs, m_iScore, steamid64); - SQL_AddQuery(txn, buffer); - } - } - - SQL_ExecuteTransaction(db, txn); - -} - -public void onSuccess(Database database, any data, int numQueries, Handle[] results, any[] bufferData) -{ - PrintToServer("onSuccess"); -} - -public void onError(Database database, any data, int numQueries, const char[] error, int failIndex, any[] queryData) -{ - PrintToServer("onError"); -} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4e03f96 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,13 @@ +falcon +jsonschema +uvicorn +bcrypt +aiofiles +colorama +sqlalchemy +databases +asyncpg +aiomysql +aiosqlite +aiohttp +python-dotenv \ No newline at end of file diff --git a/run_server.py b/run_server.py new file mode 100644 index 0000000..06213d3 --- /dev/null +++ b/run_server.py @@ -0,0 +1,8 @@ +from SQLMatches import SQLMatches + + +app = SQLMatches() + + +if __name__ == "__main__": + app.serve() diff --git a/web server/assets/css/Search-Field-With-Icon.css b/web server/assets/css/Search-Field-With-Icon.css deleted file mode 100644 index aa35285..0000000 --- a/web server/assets/css/Search-Field-With-Icon.css +++ /dev/null @@ -1,46 +0,0 @@ -.search-container { - display:flex; - font-size:14px; - margin:25px 0px; -} - -.search-input { - border:1px solid #ddd; - border-radius:5px 0 0 5px; - padding:10px; - border-right:none; - width:100%; -} - -.btn.btn-default.search-btn { - border:1px solid #ddd; - background-color:#FFFFFF !important; - border-radius:0 5px 5px 0; - border-left:none; - background-image:none; - box-shadow:none; - color:#919693; -} - -.match-card { - width:80%; - color:rgb(255,255,255); - border:none !important; - background-color:initial; -} - -.matches-img { - background-color:#3e3e3f; - background-position:center center; - background-size:100% auto; - -webkit-filter:blur(4px); - filter:blur(4px); -} - -.rounded-borders { - -webkit-box-shadow:0px 0px 40px -5px rgba(0,0,0,0.58); - -moz-box-shadow:0px 0px 40px -5px rgba(0,0,0,0.58); - box-shadow:0px 0px 40px -5px rgba(0,0,0,0.58); - border-radius:25px !important; -} - diff --git a/web server/assets/css/styles.css b/web server/assets/css/styles.css deleted file mode 100644 index 77e16fa..0000000 --- a/web server/assets/css/styles.css +++ /dev/null @@ -1,28 +0,0 @@ -.center { - margin-left:auto; - margin-right:auto; -} - -.match-image { - background-position:center; - background-size:cover; - background-repeat:no-repeat; -} - -.pagination>li>a, -.pagination>li>span { - color: #000000 !important; - background: #fff; -} - -.page-item.disabled .page-link { - background-color: #b8b8b8; -} - -.page-item.active .page-link, -.pagination > li > a:focus, -.pagination > li > a:hover, -.pagination > li > span:focus, -.pagination > li > span:hover { - background-color: #e9e9e9; -} \ No newline at end of file diff --git a/web server/assets/img/White.png b/web server/assets/img/White.png deleted file mode 100644 index 7acab8b..0000000 Binary files a/web server/assets/img/White.png and /dev/null differ diff --git a/web server/assets/img/icons/ct_icon.png b/web server/assets/img/icons/ct_icon.png deleted file mode 100644 index 12e3a00..0000000 Binary files a/web server/assets/img/icons/ct_icon.png and /dev/null differ diff --git a/web server/assets/img/icons/t_icon.png b/web server/assets/img/icons/t_icon.png deleted file mode 100644 index f850a99..0000000 Binary files a/web server/assets/img/icons/t_icon.png and /dev/null differ diff --git a/web server/assets/img/icons/tie_icon.png b/web server/assets/img/icons/tie_icon.png deleted file mode 100644 index a399489..0000000 Binary files a/web server/assets/img/icons/tie_icon.png and /dev/null differ diff --git a/web server/assets/img/maps/austria.jpg b/web server/assets/img/maps/austria.jpg deleted file mode 100644 index 8dfd199..0000000 Binary files a/web server/assets/img/maps/austria.jpg and /dev/null differ diff --git a/web server/assets/img/maps/cache.jpg b/web server/assets/img/maps/cache.jpg deleted file mode 100644 index 373ea5d..0000000 Binary files a/web server/assets/img/maps/cache.jpg and /dev/null differ diff --git a/web server/assets/img/maps/cache_new.jpg b/web server/assets/img/maps/cache_new.jpg deleted file mode 100644 index f089dd1..0000000 Binary files a/web server/assets/img/maps/cache_new.jpg and /dev/null differ diff --git a/web server/assets/img/maps/canals.jpg b/web server/assets/img/maps/canals.jpg deleted file mode 100644 index 3725ad0..0000000 Binary files a/web server/assets/img/maps/canals.jpg and /dev/null differ diff --git a/web server/assets/img/maps/cbble.jpg b/web server/assets/img/maps/cbble.jpg deleted file mode 100644 index b7788b1..0000000 Binary files a/web server/assets/img/maps/cbble.jpg and /dev/null differ diff --git a/web server/assets/img/maps/dust.png b/web server/assets/img/maps/dust.png deleted file mode 100644 index 779fcfc..0000000 Binary files a/web server/assets/img/maps/dust.png and /dev/null differ diff --git a/web server/assets/img/maps/dust2.jpg b/web server/assets/img/maps/dust2.jpg deleted file mode 100644 index 30aa5a0..0000000 Binary files a/web server/assets/img/maps/dust2.jpg and /dev/null differ diff --git a/web server/assets/img/maps/inferno.jpg b/web server/assets/img/maps/inferno.jpg deleted file mode 100644 index 0872c83..0000000 Binary files a/web server/assets/img/maps/inferno.jpg and /dev/null differ diff --git a/web server/assets/img/maps/mirage.jpg b/web server/assets/img/maps/mirage.jpg deleted file mode 100644 index eb6bee9..0000000 Binary files a/web server/assets/img/maps/mirage.jpg and /dev/null differ diff --git a/web server/assets/img/maps/nuke.jpg b/web server/assets/img/maps/nuke.jpg deleted file mode 100644 index a15d912..0000000 Binary files a/web server/assets/img/maps/nuke.jpg and /dev/null differ diff --git a/web server/assets/img/maps/overpass.jpg b/web server/assets/img/maps/overpass.jpg deleted file mode 100644 index 3f2c150..0000000 Binary files a/web server/assets/img/maps/overpass.jpg and /dev/null differ diff --git a/web server/assets/img/maps/train.jpg b/web server/assets/img/maps/train.jpg deleted file mode 100644 index d7f062a..0000000 Binary files a/web server/assets/img/maps/train.jpg and /dev/null differ diff --git a/web server/assets/js/bs-animation.js b/web server/assets/js/bs-animation.js deleted file mode 100644 index 8edf89e..0000000 --- a/web server/assets/js/bs-animation.js +++ /dev/null @@ -1,9 +0,0 @@ -$(document).ready(function() { - $('[data-bs-hover-animate]') - .mouseenter(function() { - var elem = $(this); elem.addClass('animated ' + elem.attr('data-bs-hover-animate')); - }) - .mouseleave(function() { - var elem = $(this); elem.removeClass('animated ' + elem.attr('data-bs-hover-animate')); - }); -}); \ No newline at end of file diff --git a/web server/config.php b/web server/config.php deleted file mode 100644 index 4121b3f..0000000 --- a/web server/config.php +++ /dev/null @@ -1,35 +0,0 @@ - "full_map_name", - "assets/img/maps/austria.jpg" => "de_austria", - "assets/img/maps/cache.jpg" => "de_cache", - "assets/img/maps/cache_new.jpg" => "workshop/1855851320/de_cache_new", - "assets/img/maps/cache_new.jpg" => "workshop/1855851320/de_cache", - "assets/img/maps/canals.jpg" => "de_canals", - "assets/img/maps/cbble.jpg" => "de_cbble", - "assets/img/maps/dust.png" => "de_dust", - "assets/img/maps/dust.png" => "de_shortdust", - "assets/img/maps/dust2.jpg" => "de_dust2", - "assets/img/maps/mirage.jpg" => "de_mirage", - "assets/img/maps/nuke.jpg" => "de_nuke", - "assets/img/maps/nuke.jpg" => "de_shortnuke", - "assets/img/maps/overpass.jpg" => "de_overpass", - "assets/img/maps/train.jpg" => "de_train", - "assets/img/maps/inferno.jpg" => "de_inferno" -); - -$conn = new mysqli($servername, $username, $password, $dbname); -if ($conn->connect_error) { - die("Connection failed: " . $conn->connect_error); -} -?> diff --git a/web server/head.php b/web server/head.php deleted file mode 100644 index 0f07aa0..0000000 --- a/web server/head.php +++ /dev/null @@ -1,8 +0,0 @@ - -

'.$site_name.'

- '; -?> \ No newline at end of file diff --git a/web server/index.php b/web server/index.php deleted file mode 100644 index 22fe2ff..0000000 --- a/web server/index.php +++ /dev/null @@ -1,139 +0,0 @@ - - - - - - - - <?php echo $page_title; ?> - - - - - - - - - -
- -
-
-
-real_escape_string($_POST['search-bar']); - $sql = "SELECT DISTINCT sql_matches_scoretotal.match_id, sql_matches_scoretotal.map, sql_matches_scoretotal.team_2, sql_matches_scoretotal.team_3 - FROM sql_matches_scoretotal INNER JOIN sql_matches - ON sql_matches_scoretotal.match_id = sql_matches.match_id - WHERE sql_matches.name LIKE '%".$search."%' OR sql_matches.steamid64 = '".$search."' OR sql_matches_scoretotal.match_id = '".$search."' ORDER BY sql_matches_scoretotal.match_id DESC"; - - } else { - if (isset($_GET["page"])) { - $page_number = $conn->real_escape_string($_GET["page"]); - $offset = ($page_number - 1) * $limit; - $sql = "SELECT * FROM sql_matches_scoretotal ORDER BY match_id DESC LIMIT $offset, $limit"; - } else { - $page_number = 1; - $sql = "SELECT * FROM sql_matches_scoretotal ORDER BY match_id DESC LIMIT $limit"; - } - } - - $result = $conn->query($sql); - - if($result->num_rows > 0) { - while($row = $result->fetch_assoc()) { - $half = ($row["team_2"] + $row["team_3"]) / 2; - - if ($row["team_3"] > $half) { - $image = 'ct_icon.png'; - } elseif ($row["team_2"] == $half && $row["team_3"] == $half) { - $image = 'tie_icon.png'; - } else { - $image = 't_icon.png'; - } - - $map_img = array_search($row["map"], $maps); - - echo ' - -
-
-

'.$row['team_3'].':'.$row['team_2'].'

-
-
'; - } - } else { - echo '

No Results!

'; - } -?> -query($sql_pages); - $row_pages = $result_pages->fetch_assoc(); - - $total_pages = ceil($row_pages["COUNT(*)"] / $limit); - - echo ' - '; - } -?> -
Developed by DistrictNine.Host - - - - - - diff --git a/web server/scoreboard.php b/web server/scoreboard.php deleted file mode 100644 index cc1490f..0000000 --- a/web server/scoreboard.php +++ /dev/null @@ -1,175 +0,0 @@ - - - - - - - - <?php echo $page_title; ?> - - - - - - - - - -
-real_escape_string($match_id); - - $sql = "SELECT sql_matches_scoretotal.*, sql_matches.* - FROM sql_matches_scoretotal INNER JOIN sql_matches - ON sql_matches_scoretotal.match_id = sql_matches.match_id - WHERE sql_matches_scoretotal.match_id = '".$match_id."' ORDER BY sql_matches.score DESC"; - - $result = $conn->query($sql); - - if ($result->num_rows > 0) { - $t = ''; - $ct = ''; - while ($row = $result->fetch_assoc()) { - if ($row["kills"] > 0 && $row["deaths"] > 0) { - $kdr = round(($row["kills"] / $row["deaths"]), 2); - } else { - $kdr = 0; - } - if ($row["team"] == 2) { - $t_score = $row["team_2"]; - $t_name = $row["teamname_1"]; - if ($t_name == NULL) { - $t_name = "Terrorists"; - } - $t .= ' - - '.unicode2html(htmlspecialchars(substr($row["name"],0,12))).' - '.$row["kills"].' - '.$row["assists"].' - '.$row["deaths"].' - '.$kdr.' - '.$row["mvps"].' - '.$row["score"].' - '.$row["ping"].' - '; - } elseif ($row["team"] == 3) { - $ct_score = $row["team_3"]; - $ct_name = $row["teamname_2"]; - if ($ct_name == NULL) { - $ct_name = "Counter-Terrorists"; - } - $ct .= ' - - '.unicode2html(htmlspecialchars(substr($row["name"],0,12))).' - '.$row["kills"].' - '.$row["assists"].' - '.$row["deaths"].' - '.$kdr.' - '.$row["mvps"].' - '.$row["score"].' - '.$row["ping"].' - '; - } - } - if (!isset($ct)) { - $ct = '

No Players Recorded!

'; - } - if (!isset($t)) { - $t = '

No Players Recorded!

'; - } - echo ' -
-
-
-
-
-

'.$ct_name.'

-
-
- - - - - - - - - - - - - - - '.$ct.' - -
PlayerKillsAssistsDeathsKDRMVPsScorePing
-
-
-
-
-
-
-
-

'.$ct_score.':'.$t_score.'

-
-
-
-
-
-
-
-

'.$t_name.'

-
-
- - - - - - - - - - - - - - - '.$t.' - -
PlayerKillsAssistsDeathsKDRMVPsScorePing
-
-
-
-
-
'; - } else { - echo '

No Match with that ID!

'; - } -?> - Developed by DistrictNine.Host - - - - - -