diff --git a/.Dockerignore b/.Dockerignore new file mode 100644 index 0000000..6f88a7a --- /dev/null +++ b/.Dockerignore @@ -0,0 +1,4 @@ +__pycache__/ +*.pyc +/.env +/node_modules diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 853081c..2afec04 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -7,6 +7,15 @@ on: pull_request: jobs: + docker: + name: Docker build + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Build + run: docker build . + flake8: name: Flake8 runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6f88a7a --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.pyc +/.env +/node_modules diff --git a/.pyup.yml b/.pyup.yml new file mode 100644 index 0000000..30182e6 --- /dev/null +++ b/.pyup.yml @@ -0,0 +1,4 @@ +# autogenerated pyup.io config file +# see https://pyup.io/docs/configuration/ for all available options + +schedule: every month diff --git a/.version b/.version new file mode 100644 index 0000000..38f8e88 --- /dev/null +++ b/.version @@ -0,0 +1 @@ +dev diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f04fe48 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,32 @@ +FROM python:3.8-slim + +ARG BUILD_DATE="" +ARG BUILD_VERSION="dev" + +LABEL maintainer="truebrain@openttd.org" +LABEL org.label-schema.schema-version="1.0" +LABEL org.label-schema.build-date=${BUILD_DATE} +LABEL org.label-schema.version=${BUILD_VERSION} + +WORKDIR /code + +COPY requirements.txt \ + LICENSE \ + README.md \ + .version \ + /code/ +# Needed for Sentry to know what version we are running +RUN echo "${BUILD_VERSION}" > /code/.version + +RUN pip --no-cache-dir install -r requirements.txt + +# Validate that what was installed was what was expected +RUN pip freeze 2>/dev/null > requirements.installed \ + && diff -u --strip-trailing-cr requirements.txt requirements.installed 1>&2 \ + || ( echo "!! ERROR !! requirements.txt defined different packages or versions for installation" \ + && exit 1 ) 1>&2 + +COPY webclient /code/webclient + +ENTRYPOINT ["python", "-m", "webclient"] +CMD ["--api-url", "https://api.master.staging.openttd.org", "run", "-p", "80", "-h", "0.0.0.0"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d159169 --- /dev/null +++ b/LICENSE @@ -0,0 +1,339 @@ + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service 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 make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. 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. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the 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 a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +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 2 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. + + You should have received a copy of the GNU General Public License along + with this program; if not, write to the Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision 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, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + , 1 April 1989 + Ty Coon, President of Vice + +This 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. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e65aa5d --- /dev/null +++ b/Makefile @@ -0,0 +1,13 @@ +.PHONY: run venv flake8 + +run: .env/pyvenv.cfg + FLASK_ENV=development .env/bin/python3 -m webclient --api-url "http://localhost:8080" run + +venv: .env/pyvenv.cfg + +.env/pyvenv.cfg: requirements.txt + python3 -m venv .env + .env/bin/pip install -r requirements.txt + +flake8: + python3 -m flake8 webclient diff --git a/README.md b/README.md new file mode 100644 index 0000000..66c32fd --- /dev/null +++ b/README.md @@ -0,0 +1,41 @@ +# Master Server web front-end + +[![GitHub License](https://img.shields.io/github/license/OpenTTD/master-server-web)](https://github.com/OpenTTD/master-server-web/blob/master/LICENSE) +[![GitHub Tag](https://img.shields.io/github/v/tag/OpenTTD/master-server-web?include_prereleases&label=stable)](https://github.com/OpenTTD/master-server-web/releases) +[![GitHub commits since latest release](https://img.shields.io/github/commits-since/OpenTTD/master-server-web/latest/master)](https://github.com/OpenTTD/master-server-web/commits/master) + +[![GitHub Workflow Status (Testing)](https://img.shields.io/github/workflow/status/OpenTTD/master-server-web/Testing/master?label=master)](https://github.com/OpenTTD/master-server-web/actions?query=workflow%3ATesting) +[![GitHub Workflow Status (Publish Image)](https://img.shields.io/github/workflow/status/OpenTTD/master-server-web/Publish%20image?label=publish)](https://github.com/OpenTTD/master-server-web/actions?query=workflow%3A%22Publish+image%22) +[![GitHub Workflow Status (Deployments)](https://img.shields.io/github/workflow/status/OpenTTD/master-server-web/Deployment?label=deployment)](https://github.com/OpenTTD/master-server-web/actions?query=workflow%3A%22Deployment%22) + +[![GitHub deployments (Staging)](https://img.shields.io/github/deployments/OpenTTD/master-server-web/staging?label=staging)](https://github.com/OpenTTD/master-server-web/deployments) +[![GitHub deployments (Production)](https://img.shields.io/github/deployments/OpenTTD/master-server-web/production?label=production)](https://github.com/OpenTTD/master-server-web/deployments) + +This is a front-end for the Master Server Public Server listing. +It works together with [master-server](https://github.com/OpenTTD/master-server), which serves the HTTP API. + +## Development + +This front-end is written in Python 3.7 with Flask. + +## Usage + +To start it, you are advised to first create a virtualenv: + +```bash +python3 -m venv .env +.env/bin/pip install -r requirements.txt +``` + +After this, you can run the flask application by running: + +```bash +make run +``` + +### Running via docker + +```bash +docker build -t openttd/master-server-web:local . +docker run --rm -p 127.0.0.1:5000:80 openttd/master-server-web:local +``` diff --git a/requirements.base b/requirements.base new file mode 100644 index 0000000..05610b1 --- /dev/null +++ b/requirements.base @@ -0,0 +1,3 @@ +Flask +requests +sentry_sdk diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..62ee805 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,12 @@ +certifi==2020.6.20 +chardet==3.0.4 +click==7.1.2 +Flask==1.1.2 +idna==2.10 +itsdangerous==1.1.0 +Jinja2==2.11.2 +MarkupSafe==1.1.1 +requests==2.24.0 +sentry-sdk==0.17.3 +urllib3==1.25.10 +Werkzeug==1.0.1 diff --git a/webclient/__init__.py b/webclient/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/webclient/__main__.py b/webclient/__main__.py new file mode 100644 index 0000000..0c601fa --- /dev/null +++ b/webclient/__main__.py @@ -0,0 +1,41 @@ +import click +import flask +import logging + +from werkzeug import serving + +from . import pages # noqa +from .app import app +from .click import click_additional_options +from .helpers import click_urls +from .sentry import click_sentry + + +# Patch the werkzeug logger to only log errors +def log_request(self, code="-", size="-"): + if str(code).startswith(("2", "3")): + return + original_log_request(self, code, size) + + +original_log_request = serving.WSGIRequestHandler.log_request +serving.WSGIRequestHandler.log_request = log_request + + +@click_additional_options +def click_logging(): + logging.basicConfig( + format="%(asctime)s %(levelname)-8s %(message)s", datefmt="%Y-%m-%d %H:%M:%S", level=logging.INFO + ) + + +@click.group(cls=flask.cli.FlaskGroup, create_app=lambda: app) +@click_logging +@click_sentry +@click_urls +def cli(): + pass + + +if __name__ == "__main__": + cli(auto_envvar_prefix="WEBCLIENT") diff --git a/webclient/api.py b/webclient/api.py new file mode 100644 index 0000000..b50c529 --- /dev/null +++ b/webclient/api.py @@ -0,0 +1,55 @@ +import flask +import requests +import urllib + +from .app import app +from .helpers import ( + api_host, + redirect, +) + +_client_id = None + + +def api_call(method, path, params=None, json=None, session=None, return_errors=False): + url = api_host() + "/" + "/".join(urllib.parse.quote(p, safe="") for p in path) + headers = None + if session and session.api_token: + headers = {"Authorization": "Bearer " + session.api_token} + + error_response = redirect("error", message="API call failed; sorry for the inconvenience") + try: + r = method(url, params=params, headers=headers, json=json) + + success = r.status_code in (200, 201, 204) + if not success: + app.logger.warning("API failed: {} {}".format(r.status_code, r.text)) + + if success: + result = None + try: + result = r.json() + except Exception: + result = None + if return_errors: + return (result, None) + else: + return result + elif r.status_code == 404: + if return_errors: + return (None, "Data not found") + error_response = redirect("error", message="Data not found") + elif return_errors: + error = str(r.json().get("errors", "API call failed")) + return (None, error) + except Exception: + pass + + if return_errors: + return (None, "API call failed") + else: + flask.abort(error_response) + + +def api_get(*args, **kwargs): + return api_call(requests.get, *args, **kwargs) diff --git a/webclient/app.py b/webclient/app.py new file mode 100644 index 0000000..321b894 --- /dev/null +++ b/webclient/app.py @@ -0,0 +1,3 @@ +import flask + +app = flask.Flask("webclient") diff --git a/webclient/click.py b/webclient/click.py new file mode 100644 index 0000000..fdfa3a9 --- /dev/null +++ b/webclient/click.py @@ -0,0 +1,22 @@ +def click_additional_options(additional_func): + def decorator(func): + additional_params = [] + for param in getattr(additional_func, "__click_params__", []): + additional_params.append(param.name) + + def inner_decorator(**kwargs): + additional_kwargs = {param: kwargs[param] for param in additional_params} + additional_func(**additional_kwargs) + + # Remove the kwargs that are consumed by the additional_func + [kwargs.pop(kwarg) for kwarg in additional_kwargs] + + func(**kwargs) + + inner_decorator.__click_params__ = getattr(func, "__click_params__", []) + getattr( + additional_func, "__click_params__", [] + ) + inner_decorator.__doc__ = func.__doc__ + return inner_decorator + + return decorator diff --git a/webclient/helpers.py b/webclient/helpers.py new file mode 100644 index 0000000..ad2b20f --- /dev/null +++ b/webclient/helpers.py @@ -0,0 +1,43 @@ +import click +import datetime +import flask + +from .click import click_additional_options + +_api_url = None + + +@click_additional_options +@click.option( + "--api-url", + help="Master Server API URL.", + default="https://api.master.openttd.org", + show_default=True, + metavar="URL", +) +def click_urls(api_url): + global _api_url + _api_url = api_url + + +def template(*args, **kwargs): + messages = kwargs.setdefault("messages", []) + if "message" in kwargs: + messages.append(kwargs["message"]) + if "message" in flask.request.args: + messages.append(flask.request.args["message"]) + kwargs["globals"] = { + "copyright_year": datetime.datetime.utcnow().year, + } + + response = flask.make_response(flask.render_template(*args, **kwargs)) + response.headers["Content-Security-Policy"] = "default-src 'self'" + return response + + +def api_host(): + return _api_url + + +def redirect(*args, **kwargs): + return flask.redirect(flask.url_for(*args, **kwargs)) diff --git a/webclient/pages/__init__.py b/webclient/pages/__init__.py new file mode 100644 index 0000000..7ef7a5c --- /dev/null +++ b/webclient/pages/__init__.py @@ -0,0 +1,2 @@ +from . import static # noqa +from . import servers # noqa diff --git a/webclient/pages/servers.py b/webclient/pages/servers.py new file mode 100644 index 0000000..2248b13 --- /dev/null +++ b/webclient/pages/servers.py @@ -0,0 +1,236 @@ +import time + +from collections import defaultdict +from datetime import datetime + +from ..app import app +from ..api import api_get +from ..helpers import ( + redirect, + template, +) + +LANGUAGES = { + 0: "All", + 1: "English", + 2: "German", + 3: "French", + 4: "Brazillian", + 5: "Bulgarian", + 6: "Chinese", + 7: "Czech", + 8: "Danish", + 9: "Dutch", + 10: "Esperanto", + 11: "Finnish", + 12: "Hungarian", + 13: "Icelandic", + 14: "Italian", + 15: "Japanese", + 16: "Korean", + 17: "Lithuanian", + 18: "Norwegian", + 19: "Polish", + 20: "Portuguese", + 21: "Romanian", + 22: "Russian", + 23: "Slovak", + 24: "Slovenian", + 25: "Spanish", + 26: "Swedish", + 27: "Turkish", + 28: "Ukranian", + 29: "Afrikaans", + 30: "Croatian", + 31: "Catalan", + 32: "Estonian", + 33: "Galician", + 34: "Greek", + 35: "Latvian", +} +MAPSETS = { + 0: "Temperate", + 1: "Arctic", + 2: "Tropical", + 3: "Toyland", +} +DAYS_IN_MONTH = { + 1: 31, + 2: 29, + 3: 31, + 4: 30, + 5: 31, + 6: 30, + 7: 31, + 8: 31, + 9: 30, + 10: 31, + 11: 30, + 12: 31, +} +# Expire the cache after 2 minutes; the API caches for 5 minutes, so there +# is a small chances you won't receive an update for ~7 minutes when hitting +# constant refresh. +CACHE_EXPIRE_TIME = 60 * 2 + +_server_list_cache = None +_server_entry_cache = defaultdict(lambda: None) + + +def is_leap_year(year): + return year % 4 == 0 and (year % 100 != 0 or year % 400 == 0) + + +def _date_to_string(date): + # Year determination in multiple steps to account for leap years. First do + # the large steps, then the smaller ones. + + # There are 97 leap years in 400 years. + year = 400 * (date // (365 * 400 + 97)) + remainder = date % (365 * 400 + 97) + + if remainder >= 365 * 100 + 25: + # There are 25 leap years in the first 100 years after every 400th + # year, as every 400th year is a leap year. + year += 100 + remainder -= 365 * 100 + 25 + + # There are 24 leap years in the next couple of 100 years. + year += 100 * (remainder // (365 * 100 + 24)) + remainder = remainder % (365 * 100 + 24) + + if remainder >= 365 * 4 and not is_leap_year(year): + # The first 4 year of the century are not always a leap year. + year += 4 + remainder -= 365 * 4 + + # There is 1 leap year every 4 years. + year += 4 * (remainder // (365 * 4 + 1)) + remainder = remainder % (365 * 4 + 1) + + # The last (max 3) years to account for; the first one can be, but is not + # necessarily a leap year. + while remainder >= (366 if is_leap_year(year) else 365): + remainder -= 366 if is_leap_year(year) else 365 + year += 1 + + # Skip the 29th of February in non-leap years. + if not is_leap_year(year) and remainder >= 31 + 29 - 1: + remainder += 1 + + for month, days in DAYS_IN_MONTH.items(): + if remainder < days: + break + remainder -= days + day = remainder + 1 + + return f"{year}-{month:02d}-{day:02d}" + + +def _fix_server_info(server): + # Set the server_id to the one based on the IPv4 or, if not available, to + # the IPv6 variant. + server["server_id"] = getattr(server, "ipv4", server["ipv6"])["server_id"] + + # Make dates human readable. + server["info"]["start_date"] = _date_to_string(server["info"]["start_date"]) + server["info"]["game_date"] = _date_to_string(server["info"]["game_date"]) + server["time_first_seen"] = ( + datetime.utcfromtimestamp(server["time_first_seen"]).strftime("%Y-%m-%d %H:%M:%S") + " UTC" + ) + server["time_last_seen"] = ( + datetime.utcfromtimestamp(server["time_last_seen"]).strftime("%Y-%m-%d %H:%M:%S") + " UTC" + ) + + +def _split_version(raw_version): + """ + For official versions we control the format; for patchpacks we do not. So + we are guestimating a bit here what is most likely happening. If it fails, + we just fall back to sorting by string. + """ + + if "-" in raw_version: + version, _, extra = raw_version.partition("-") + else: + version = raw_version + extra = "stable" + + # Check if this is most likely an official release and sort it correctly. + version_parts = version.split(".") + if len(version_parts) == 3: + return [int(p) for p in version_parts] + [extra] + + # Check if this is a patchpack like jgrpp-0.31.0. + version_parts = extra.split(".") + if len(version_parts) == 3: + return [int(p) for p in version_parts] + [version] + + # We did not recognize what this is, so just fall back to string comparison. + return [0, 0, 0, raw_version] + + +def _sort_servers(servers): + servers.sort(key=lambda x: _split_version(x["info"]["server_revision"]), reverse=True) + + +@app.route("/listing/") +def servers(filter): + global _server_list_cache + + if _server_list_cache is None or time.time() > _server_list_cache["expire"]: + data = api_get(("server",)) + for server in data["servers"]: + _fix_server_info(server) + + _sort_servers(data["servers"]) + + _server_list_cache = { + "servers": data["servers"], + "cached": data["expire"], + "expire": time.time() + CACHE_EXPIRE_TIME, + } + + servers = _server_list_cache["servers"] + if filter != "all": + servers = [server for server in servers if server["info"]["server_revision"].startswith(filter)] + + expire = datetime.utcfromtimestamp(_server_list_cache["cached"]).strftime("%Y-%m-%d %H:%M:%S") + " UTC" + clients = sum([server["info"]["clients_on"] for server in servers]) + servers_ipv4 = len([server for server in servers if "ipv4" in server]) + servers_ipv6 = len([server for server in servers if "ipv6" in server]) + + return template( + "server_list.html", + servers=servers, + expire=expire, + clients=clients, + servers_ipv4=servers_ipv4, + servers_ipv6=servers_ipv6, + filter=filter, + languages=LANGUAGES, + mapsets=MAPSETS, + ) + + +@app.route("/server/") +def server_entry(server_id): + global _server_entry_cache + + if _server_entry_cache[server_id] is None or time.time() > _server_entry_cache[server_id]["expire"]: + data = api_get(("server", server_id)) + server = data["server"] + if server is not None: + _fix_server_info(server) + + _server_entry_cache[server_id] = { + "server": server, + "cached": data["expire"], + "expire": time.time() + CACHE_EXPIRE_TIME, + } + + server = _server_entry_cache[server_id]["server"] + if server is None: + return redirect("error", message="This server (no longer) exists") + + return template("server_entry.html", server=server, languages=LANGUAGES, mapsets=MAPSETS) diff --git a/webclient/pages/static.py b/webclient/pages/static.py new file mode 100644 index 0000000..e1e52b6 --- /dev/null +++ b/webclient/pages/static.py @@ -0,0 +1,24 @@ +import flask + +from ..app import app +from ..helpers import ( + redirect, + template, +) + + +@app.route("/") +def root(): + return redirect("servers", filter="all") + + +@app.route("/error") +def error(): + return template("error.html") + + +@app.route("/healthz") +def healthz_handler(): + response = flask.make_response("200: OK") + response.headers["Content-Type"] = "text/plain" + return response diff --git a/webclient/sentry.py b/webclient/sentry.py new file mode 100644 index 0000000..da33f8e --- /dev/null +++ b/webclient/sentry.py @@ -0,0 +1,30 @@ +import click +import logging +import sentry_sdk + +from .click import click_additional_options + +log = logging.getLogger(__name__) + + +@click_additional_options +@click.option("--sentry-dsn", help="Sentry DSN.") +@click.option( + "--sentry-environment", + help="Environment we are running in.", + default="development", +) +def click_sentry(sentry_dsn, sentry_environment): + if not sentry_dsn: + return + + # Release is expected to be in the file '.version' + with open(".version") as f: + release = f.readline().strip() + + sentry_sdk.init(sentry_dsn, release=release, environment=sentry_environment) + log.info( + "Sentry initialized with release='%s' and environment='%s'", + release, + sentry_environment, + ) diff --git a/webclient/static/css/base.css b/webclient/static/css/base.css new file mode 100644 index 0000000..1223de3 --- /dev/null +++ b/webclient/static/css/base.css @@ -0,0 +1,209 @@ +body { + margin: 0px; + padding: 0px; + text-align: center; +} +html { + background: url("/static/img/background.png") repeat; + color: black; + font-family: "Trebuchet MS", Arial, Verdana, Sans-Serif; + font-size: 12px; +} +select { + color: black; +} +img { + border: 0px; +} +a { + color: #DD6000; +} +hr { + background: url("/static/img/hr.png") repeat-x; + border: none; + height: 2px; + width: 95%; +} +h3, .h3 { + font-weight: normal; + font-size: 16px; + margin: 0px; + text-decoration: underline; +} +h4, .h4 { + font-weight: normal; + font-size: 14px; + margin: 0px; +} +h5, .h5 { + font-weight: normal; + font-size: 12px; + margin: 0px; +} + +.nowrap { + overflow: hidden; + white-space: nowrap; + + /* The following elements are not part of CSS2, but are required for some + * (broken) browsers who do not understand the former definitions. */ + text-overflow: ellipsis; + -o-text-overflow: ellipsis; +} + +#header { + background: url("/static/img/header-bg.png") repeat; + height: 90px; + margin: 0px auto; + width: 1000px; +} +#header-left { + background: url("/static/img/header-bg-left.png") repeat-y; + float: left; + height: 90px; + width: 15px; +} +#header-right { + background: url("/static/img/header-bg-right.png") repeat-y; + float: right; + height: 90px; + width: 15px; +} + +#header-session { + float: left; + width: 400px; + margin-top: 15px; + margin-left: 15px; + text-align: left; + font-size: 12px; + line-height: 15px; +} +#header-session p { + color: #DDDDDD; +} +#header-session a { + color: #DDDDDD; +} + +#header-logo { + float: right; + width: 400px; +} + +#ovh-header { + float: right; + height: 90px; + line-height: 11px; + margin-top: -2px; + margin-right: -6px; + padding: 0px; + width: 90px; +} +#openttd-logo { + background: url("/static/img/openttd-64.gif") no-repeat; + background-position: left center; + float: right; + height: 75px; + margin-top: 6px; + width: 250px; +} +#openttd-logo-text { + height: 29px; + margin: 36px 0px 0px 77px; + width: 151px; +} +#openttd-logo-text-servers { + height: 29px; + margin: 36px 0px 0px 77px; + width: 151px; +} + +#navigation { + background: url("/static/img/navigation-bg.png") repeat-x; + height: auto !important; + height: 32px; + margin: 0px auto; + min-height: 32px; + overflow: hidden; + width: 1000px; +} +#navigation-left { + background: url("/static/img/navigation-bg-left.png") no-repeat; + float: left; + height: 32px; + width: 15px; +} +#navigation-right { + background: url("/static/img/navigation-bg-right.png") no-repeat; + float: right; + height: 32px; + width: 15px; +} +#navigation-bar { + float: left; + margin: 0px; + padding: 0px; +} +#navigation-bar li { + font-size: 12px; + float: left; + line-height: 32px; + list-style-type: none; +} +#navigation-bar li a { + color: #DDDDDD; + display: block; + padding: 0px 7px 0px 7px; + text-decoration: none; +} +#navigation-bar li a:hover { + background: url("/static/img/navigation-bg-hover.png") repeat-x; + color: #444444; +} +#navigation-bar li.selected { + background: url("/static/img/navigation-bg-selected.png") repeat-x; +} +#navigation-bar li.selected a { + color: #444444; +} + +#content-main { + background-color: white; + margin: 7px auto; + width: 976px; + padding: 7px; + border-radius: 4px; + box-shadow: 0 0 3px rgba(0, 0, 0, 0.5); +} +#content-bottom-links { + float: left; + font-size: 11px; + padding: 7px 5px 0px 5px; +} +#content-bottom-copyright { + float: right; + font-size: 11px; + padding: 7px 5px 0px 5px; +} +#content { + margin: 0px 12px 0px 12px; + padding-top: 12px; +} +body > footer { + background-color: white; + margin: 12px auto 36px auto; + padding: 7px 7px 6px 7px; + overflow: hidden; + border-radius: 5px; + box-shadow: 0 0 3px 3px rgba(0, 0, 0, 0.11); + width: 976px; +} + +#hr-clear { + clear: both; +} + +.mono { + font-family: 'Courier New', monospace; +} diff --git a/webclient/static/css/page.css b/webclient/static/css/page.css new file mode 100644 index 0000000..15107a5 --- /dev/null +++ b/webclient/static/css/page.css @@ -0,0 +1,57 @@ + +#section, #section-full { + text-align: left; +} +.section-header { + background: url("/static/img/section-bg.png") no-repeat; + color: #365800; + line-height: 38px; + height: 38px; + padding: 0px 0px 0px 10px; + text-align: left; + width: 500px; +} +.section-header h1 { + font-size: 18px; + margin-right: 20px; + margin-top: 0; + text-decoration: none; +} + +.section-item { + padding-top: 10px; +} +.section-item .title { + font-size: 16px; + float: left; + padding-top: 5px; +} +.section-item .title h3, .section-list li .header, .section-item .title a { + color: #000000; + font-size: 18px; + font-weight: bold; + text-decoration: none; +} +.section-item .date { + text-align: right; +} +.section-item .user { + text-align: right; + padding-bottom: 5px; +} +.section-item .content { + border-top: 1px solid #EEEEEE; + font-size: 14px; + padding-top: 15px; + padding-bottom: 30px; +} + +.section-list { + list-style-type: none; +} + +.right { + float: right; + margin-bottom: 20px; +} + diff --git a/webclient/static/css/servers.css b/webclient/static/css/servers.css new file mode 100644 index 0000000..9e36ba5 --- /dev/null +++ b/webclient/static/css/servers.css @@ -0,0 +1,76 @@ + +#server-info-table { + font-size: 12px; + table-layout: fixed; + width: 976px; +} + +#server-info-table th { + width: 150px; +} + +#server-table { + font-size: 12px; + table-layout: fixed; + width: 976px; +} + +#server-table th { + text-align: left; + text-decoration: underline; +} +#server-table td, #server-info-table td, #server-info-table th { + border-bottom: 1px solid #FFFFFF; + border-top: 1px solid #FFFFFF; + padding: 1px 3px 1px 3px; + text-align: left; +} + +#server-table .odd, #server-info-table .odd { + background-color: #EEEEEE; +} +#server-table .even, #server-info-table .even { + background-color: #FFFFFF; +} +#server-table tr.odd:hover, #server-table tr.even:hover, #server-info-table tr.odd:hover, #server-info-table tr.even:hover, #server-table tr.odd:hover td, #server-table tr.even:hover td { + background-color: #CCCCCC; + border-bottom: 1px dashed #000000; + border-top: 1px dashed #000000; +} + +#server-table .image { + background-color: #FFFFFF; + border-bottom: 1px solid #FFFFFF; + border-top: 1px solid #FFFFFF; +} + +#server-table .odd:hover .image, #server-table .even:hover .image { + background-color: #FFFFFF; + border-bottom: 1px dashed #000000; + border-top: 1px dashed #000000; +} + +#server-table .dedicated { + width: 15px; +} +#server-table .name { + width: 562px; +} +#server-table .address { + width: 100px; +} +#server-table .clients { + width: 70px; +} +#server-table .companies { + width: 70px; +} +#server-table .version { + width: 120px; +} +#server-table .icon-password { + width: 12px; +} +#server-table .icon-grf { + width: 15px; +} diff --git a/webclient/static/favicon.ico b/webclient/static/favicon.ico new file mode 100644 index 0000000..2f40be7 Binary files /dev/null and b/webclient/static/favicon.ico differ diff --git a/webclient/static/img/background.png b/webclient/static/img/background.png new file mode 100644 index 0000000..d961da6 Binary files /dev/null and b/webclient/static/img/background.png differ diff --git a/webclient/static/img/header-bg-left.png b/webclient/static/img/header-bg-left.png new file mode 100644 index 0000000..01aedc4 Binary files /dev/null and b/webclient/static/img/header-bg-left.png differ diff --git a/webclient/static/img/header-bg-right.png b/webclient/static/img/header-bg-right.png new file mode 100644 index 0000000..7341568 Binary files /dev/null and b/webclient/static/img/header-bg-right.png differ diff --git a/webclient/static/img/header-bg.png b/webclient/static/img/header-bg.png new file mode 100644 index 0000000..ca2dafc Binary files /dev/null and b/webclient/static/img/header-bg.png differ diff --git a/webclient/static/img/hr.png b/webclient/static/img/hr.png new file mode 100644 index 0000000..c1deb51 Binary files /dev/null and b/webclient/static/img/hr.png differ diff --git a/webclient/static/img/navigation-bg-hover.png b/webclient/static/img/navigation-bg-hover.png new file mode 100644 index 0000000..7b2323a Binary files /dev/null and b/webclient/static/img/navigation-bg-hover.png differ diff --git a/webclient/static/img/navigation-bg-left.png b/webclient/static/img/navigation-bg-left.png new file mode 100644 index 0000000..9ea31f9 Binary files /dev/null and b/webclient/static/img/navigation-bg-left.png differ diff --git a/webclient/static/img/navigation-bg-right.png b/webclient/static/img/navigation-bg-right.png new file mode 100644 index 0000000..f54db9d Binary files /dev/null and b/webclient/static/img/navigation-bg-right.png differ diff --git a/webclient/static/img/navigation-bg-selected.png b/webclient/static/img/navigation-bg-selected.png new file mode 100644 index 0000000..d9c1d30 Binary files /dev/null and b/webclient/static/img/navigation-bg-selected.png differ diff --git a/webclient/static/img/navigation-bg.png b/webclient/static/img/navigation-bg.png new file mode 100644 index 0000000..3cc89b8 Binary files /dev/null and b/webclient/static/img/navigation-bg.png differ diff --git a/webclient/static/img/openttd-64.gif b/webclient/static/img/openttd-64.gif new file mode 100644 index 0000000..05e9cc4 Binary files /dev/null and b/webclient/static/img/openttd-64.gif differ diff --git a/webclient/static/img/openttd-logo-servers.png b/webclient/static/img/openttd-logo-servers.png new file mode 100644 index 0000000..64e3aad Binary files /dev/null and b/webclient/static/img/openttd-logo-servers.png differ diff --git a/webclient/static/img/section-bg.png b/webclient/static/img/section-bg.png new file mode 100644 index 0000000..f49a6a0 Binary files /dev/null and b/webclient/static/img/section-bg.png differ diff --git a/webclient/static/img/server-client.png b/webclient/static/img/server-client.png new file mode 100644 index 0000000..f4d8ec9 Binary files /dev/null and b/webclient/static/img/server-client.png differ diff --git a/webclient/static/img/server-dedicated.png b/webclient/static/img/server-dedicated.png new file mode 100644 index 0000000..b95e30e Binary files /dev/null and b/webclient/static/img/server-dedicated.png differ diff --git a/webclient/static/img/server-grf.png b/webclient/static/img/server-grf.png new file mode 100644 index 0000000..503dd2d Binary files /dev/null and b/webclient/static/img/server-grf.png differ diff --git a/webclient/static/img/server-lock.png b/webclient/static/img/server-lock.png new file mode 100644 index 0000000..33d9369 Binary files /dev/null and b/webclient/static/img/server-lock.png differ diff --git a/webclient/templates/base.html b/webclient/templates/base.html new file mode 100644 index 0000000..26237e4 --- /dev/null +++ b/webclient/templates/base.html @@ -0,0 +1,56 @@ + + + + + {% block title %}{% endblock %} - OpenTTD + + + + + + + + +
+
+
+{% block header %}{% endblock %} +
+
+
+{% if messages %} +
    + {% for message in messages %} +
  • {{ message }}
  • + {% endfor %} +
+{% endif %} +{% block content %}{% endblock %} +
+
+
+
+
+ + +
+ + diff --git a/webclient/templates/error.html b/webclient/templates/error.html new file mode 100644 index 0000000..b550ad8 --- /dev/null +++ b/webclient/templates/error.html @@ -0,0 +1,6 @@ +{% extends 'base.html' %} +{% block header %} +

{% block title %}Error{% endblock %}

+{% endblock %} +{% block content %} +{% endblock %} diff --git a/webclient/templates/server_entry.html b/webclient/templates/server_entry.html new file mode 100644 index 0000000..a05c740 --- /dev/null +++ b/webclient/templates/server_entry.html @@ -0,0 +1,130 @@ +{% extends 'base.html' %} +{% block header %} +

{% block title %}{{ server["info"]["server_name"] }} {% endblock %}

+{% endblock %} +{% block content %} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Online: + {% if server["online"] %} + Yes + {% else %} + No + {% endif %} +
First seen: + {{ server["time_first_seen"] }} +
Last queried: + {{ server["time_last_seen"] }} +
Clients: + {{ server["info"]["clients_on"] }} / {{ server["info"]["clients_max"] }} +
Companies: + {{ server["info"]["companies_on"] }} / {{ server["info"]["companies_max"] }} +
Spectators: + {{ server["info"]["spectators_on"] }} / {{ server["info"]["spectators_max"] }} +
Language: + {{ languages[server["info"]["server_lang"]] }} +
Map name: + {{ server["info"]["map_name"] }} +
Landscape: + {{ mapsets[server["info"]["map_set"]] }} +
Map size: + {{ server["info"]["map_width"] }} x {{ server["info"]["map_height"] }} +
Server version: + {{ server["info"]["server_revision"] }} +
Server address(es): + {% if "ipv4" in server %} + {{ server["ipv4"]["ip"] }}:{{ server["ipv4"]["port"] }} + {% endif %} + {% if "ipv4" in server and "ipv6" in server %}
{% endif %} + {% if "ipv6" in server %} + {{ server["ipv6"]["ip"] }}:{{ server["ipv6"]["port"] }} + {% endif %} +
Dedicated server: + {% if server["info"]["dedicated"] == 1 %} + Yes + {% else %} + No + {% endif %} +
Server password: + {% if server["info"]["use_password"] == 1 %} + Yes + {% else %} + No + {% endif %} +
Start date: + {{ server["info"]["start_date"] }} +
Current date: + {{ server["info"]["game_date"] }} +
NewGRFs in use: + {{ server["info"]["num_grfs"] }} +
+{% endblock %} diff --git a/webclient/templates/server_list.html b/webclient/templates/server_list.html new file mode 100644 index 0000000..f6f446b --- /dev/null +++ b/webclient/templates/server_list.html @@ -0,0 +1,64 @@ +{% set selected_nav = filter %} +{% extends 'base.html' %} +{% block header %} +

{% block title %}{{ filter[0].upper() + filter[1:] }} - Server List{% endblock %}

+{% endblock %} +{% block content %} + + + + + + + + + + + + + + +

Servers registered as on {{ expire }} UTC. There are {{ clients }} clients, {{ servers_ipv4 }} IPv4 servers and {{ servers_ipv6 }} IPv6 servers in this list.

+{% for server in servers %} + + + + + + + + + +{% endfor %} + +
NameClientsCompaniesVersion
+ {% if server['info']['dedicated'] == 1 %} + Dedicated + {% else %} + Non-Dedicated + {% endif %} + + + + {{ server['info']['clients_on'] }} / {{ server['info']['clients_max'] }} + + {{ server['info']['companies_on'] }} / {{ server['info']['companies_max'] }} + + + + {% if server['info']['use_password'] == 1 %} + Password + {% endif %} + + {% if server['info']['num_grfs'] > 0 %} + GRF + {% endif %} +
+ +{% endblock %}