Skip to content

Commit

Permalink
Add: initial version of a web-frontend for the Master Server API (#3)
Browse files Browse the repository at this point in the history
It is a nearly one-to-one copy of the current website serving the
Public Server Listing, with a few extra additions. For example:

- You can now filter on versions
- Version sorting is done correctly for official releases
  (no more bumping the "latest" every release)
- Information is cached properly
- Some more information for servers
- Server-links are no longer the MySQL-id but a hash of IP+port
  • Loading branch information
TrueBrain committed Sep 6, 2020
1 parent 9339654 commit b4f6ee4
Show file tree
Hide file tree
Showing 46 changed files with 1,516 additions and 0 deletions.
4 changes: 4 additions & 0 deletions .Dockerignore
@@ -0,0 +1,4 @@
__pycache__/
*.pyc
/.env
/node_modules
9 changes: 9 additions & 0 deletions .github/workflows/testing.yml
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
@@ -0,0 +1,4 @@
__pycache__/
*.pyc
/.env
/node_modules
4 changes: 4 additions & 0 deletions .pyup.yml
@@ -0,0 +1,4 @@
# autogenerated pyup.io config file
# see https://pyup.io/docs/configuration/ for all available options

schedule: every month
1 change: 1 addition & 0 deletions .version
@@ -0,0 +1 @@
dev
32 changes: 32 additions & 0 deletions 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"]
339 changes: 339 additions & 0 deletions LICENSE

Large diffs are not rendered by default.

13 changes: 13 additions & 0 deletions 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
41 changes: 41 additions & 0 deletions 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
```
3 changes: 3 additions & 0 deletions requirements.base
@@ -0,0 +1,3 @@
Flask
requests
sentry_sdk
12 changes: 12 additions & 0 deletions 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
Empty file added webclient/__init__.py
Empty file.
41 changes: 41 additions & 0 deletions 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")
55 changes: 55 additions & 0 deletions 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)
3 changes: 3 additions & 0 deletions webclient/app.py
@@ -0,0 +1,3 @@
import flask

app = flask.Flask("webclient")
22 changes: 22 additions & 0 deletions 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
43 changes: 43 additions & 0 deletions 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))
2 changes: 2 additions & 0 deletions webclient/pages/__init__.py
@@ -0,0 +1,2 @@
from . import static # noqa
from . import servers # noqa

0 comments on commit b4f6ee4

Please sign in to comment.