diff --git a/.Dockerignore b/.Dockerignore new file mode 100644 index 0000000..925a3a1 --- /dev/null +++ b/.Dockerignore @@ -0,0 +1,3 @@ +__pycache__/ +*.pyc +/.env diff --git a/.github/workflows/deployment.yml b/.github/workflows/deployment.yml new file mode 100644 index 0000000..a138464 --- /dev/null +++ b/.github/workflows/deployment.yml @@ -0,0 +1,41 @@ +name: Deployment + +on: + deployment: + +jobs: + deploy_to_aws: + name: Deploy to AWS + runs-on: ubuntu-latest + + steps: + - name: Deployment in progress + uses: openttd/actions/deployments-update@v1 + with: + github-token: ${{ secrets.DEPLOYMENT_TOKEN }} + state: in_progress + description: "Deployment of ${{ github.event.deployment.payload.version }} to ${{ github.event.deployment.environment }} started" + + - name: Deploy on AWS + uses: openttd/actions/deploy-aws@v1 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-region: ${{ secrets.AWS_REGION }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + name: BananasFrontendWeb + + - if: success() + name: Deployment successful + uses: openttd/actions/deployments-update@v1 + with: + github-token: ${{ secrets.DEPLOYMENT_TOKEN }} + state: success + description: "Successfully deployed ${{ github.event.deployment.payload.version }} on ${{ github.event.deployment.environment }}" + url: "https://bananas.staging.openttd.org/" + + - if: failure() || cancelled() + name: Deployment failed + uses: openttd/actions/deployments-update@v1 + with: + github-token: ${{ secrets.DEPLOYMENT_TOKEN }} + state: failure diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..8ffa5fe --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,68 @@ +name: Publish image + +on: + push: + branches: + - master + tags: + - '*' + repository_dispatch: + types: + - publish_latest_tag + - publish_master + +jobs: + publish_image: + name: Publish image + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v2 + with: + fetch-depth: 0 + + - if: github.event_name == 'repository_dispatch' + name: Repository dispatch + uses: openttd/actions/checkout-dispatch@v1 + + - name: Checkout tags and submodules + uses: openttd/actions/checkout@v1 + with: + with-tags: true + + - name: Set variables + id: vars + uses: openttd/actions/docker-vars@v1 + with: + docker-hub-username: ${{ secrets.DOCKER_USERNAME }} + + - name: Build + uses: openttd/actions/docker-build@v1 + with: + name: ${{ steps.vars.outputs.name }} + tag: ${{ steps.vars.outputs.tag }} + tags: ${{ steps.vars.outputs.tags }} + version: ${{ steps.vars.outputs.version }} + date: ${{ steps.vars.outputs.date }} + + - if: steps.vars.outputs.dry-run == 'false' + name: Publish + id: publish + uses: openttd/actions/docker-publish@v1 + with: + docker-hub-username: ${{ secrets.DOCKER_USERNAME }} + docker-hub-password: ${{ secrets.DOCKER_PASSWORD }} + name: ${{ steps.vars.outputs.name }} + tag: ${{ steps.vars.outputs.tag }} + + - if: steps.vars.outputs.dry-run == 'false' + name: Trigger deployment + uses: openttd/actions/deployments-create@v1 + with: + ref: ${{ steps.vars.outputs.sha }} + environment: ${{ steps.vars.outputs.environment }} + version: ${{ steps.vars.outputs.version }} + date: ${{ steps.vars.outputs.date }} + docker-tag: ${{ steps.publish.outputs.remote-tag }} + github-token: ${{ secrets.DEPLOYMENT_TOKEN }} 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..7661765 --- /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 ["--authentication-method", "developer", "--developer-username", "developer", "--api-url", "http://127.0.0.1:8080", "--frontend-url", "https://127.0.0.1:5000", "run", "-p", "80", "-h", "0.0.0.0"] diff --git a/README.md b/README.md index 47386fb..1fe3fa6 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,16 @@ # BaNaNaS web front-end +[![GitHub License](https://img.shields.io/github/license/OpenTTD/bananas-frontend-web)](https://github.com/OpenTTD/bananas-frontend-web/blob/master/LICENSE) +[![GitHub Tag](https://img.shields.io/github/v/tag/OpenTTD/bananas-frontend-web?include_prereleases&label=stable)](https://github.com/OpenTTD/bananas-frontend-web/releases) +[![GitHub commits since latest release](https://img.shields.io/github/commits-since/OpenTTD/bananas-frontend-web/latest/master)](https://github.com/OpenTTD/bananas-frontend-web/commits/master) + +[![GitHub Workflow Status (Testing)](https://img.shields.io/github/workflow/status/OpenTTD/bananas-frontend-web/Testing/master?label=master)](https://github.com/OpenTTD/bananas-frontend-web/actions?query=workflow%3ATesting) +[![GitHub Workflow Status (Publish Image)](https://img.shields.io/github/workflow/status/OpenTTD/bananas-frontend-web/Publish%20image?label=publish)](https://github.com/OpenTTD/bananas-frontend-web/actions?query=workflow%3A%22Publish+image%22) +[![GitHub Workflow Status (Deployments)](https://img.shields.io/github/workflow/status/OpenTTD/bananas-frontend-web/Deployment?label=deployment)](https://github.com/OpenTTD/bananas-frontend-web/actions?query=workflow%3A%22Deployment%22) + +[![GitHub deployments (Staging)](https://img.shields.io/github/deployments/OpenTTD/bananas-frontend-web/staging?label=staging)](https://github.com/OpenTTD/bananas-frontend-web/deployments) +[![GitHub deployments (Production)](https://img.shields.io/github/deployments/OpenTTD/bananas-frontend-web/production?label=production)](https://github.com/OpenTTD/bananas-frontend-web/deployments) + This is a front-end for browsing and upload content to OpenTTD's content service, called BaNaNaS. It works together with [bananas-api](https://github.com/OpenTTD/bananas-api), which serves the HTTP API. @@ -21,3 +32,12 @@ After this, you can run the flask application by running: ```bash make run ``` + +### Running via docker + +```bash +docker build -t openttd/bananas-frontend-web:local . +docker run --rm -p 127.0.0.1:5000:80 openttd/bananas-frontend-web:local +``` + +The mount assumes that [bananas-api](https://github.com/OpenTTD/bananas-api) is already running on the system. diff --git a/requirements.base b/requirements.base index e635204..05610b1 100644 --- a/requirements.base +++ b/requirements.base @@ -1,2 +1,3 @@ Flask requests +sentry_sdk diff --git a/requirements.txt b/requirements.txt index f4d86a8..86d50a5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,5 +7,6 @@ itsdangerous==1.1.0 Jinja2==2.11.2 MarkupSafe==1.1.1 requests==2.23.0 -urllib3==1.25.8 +sentry-sdk==0.14.3 +urllib3==1.25.9 Werkzeug==1.0.1 diff --git a/webclient/__main__.py b/webclient/__main__.py index 86d199d..2f7ab97 100644 --- a/webclient/__main__.py +++ b/webclient/__main__.py @@ -1,56 +1,46 @@ import click -import datetime import flask +import logging + +from werkzeug import serving from . import pages # noqa from .app import app -from .helpers import ( - set_api_url, - set_frontend_url, -) +from .click import click_additional_options +from .helpers import click_urls +from .sentry import click_sentry from .session import ( - set_auth_backend, - set_max_age, + click_auth_backend, + click_max_age, ) +# 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.option( - "--api-url", help="BaNaNaS API URL.", default="https://api.bananas.openttd.org", show_default=True, metavar="URL", -) -@click.option( - "--frontend-url", - help="Frontend URL (this server).", - default="https://bananas.openttd.org", - show_default=True, - metavar="URL", -) -@click.option( - "--authentication-method", - help="Authentication method to use.", - type=click.Choice(["developer", "github", "openttd"], case_sensitive=False), - default="github", - show_default=True, -) -@click.option("--developer-username", help="Username to use if authentication is set to 'developer'.") -@click.option( - "--session-expire", - help="Time for a session to expire.", - default=60 * 60 * 14, - show_default=True, - metavar="SECONDS", -) -@click.option( - "--csrf-expire", help="Time for the CSRF token to expire.", default=60 * 30, show_default=True, metavar="SECONDS", -) -def cli(api_url, frontend_url, authentication_method, developer_username, session_expire, csrf_expire): - if authentication_method == "developer" and not developer_username: - raise click.UsageError("'developer-username' should be set if 'authentication-method' is 'developer'") - - set_api_url(api_url) - set_frontend_url(frontend_url) - set_auth_backend(authentication_method, developer_username) - set_max_age(datetime.timedelta(seconds=session_expire), datetime.timedelta(seconds=csrf_expire)) +@click_logging +@click_sentry +@click_urls +@click_auth_backend +@click_max_age +def cli(): + pass if __name__ == "__main__": 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 index b8f7b98..2a99fb5 100644 --- a/webclient/helpers.py +++ b/webclient/helpers.py @@ -1,21 +1,30 @@ +import click import flask import requests import urllib from .app import app +from .click import click_additional_options _api_url = None _frontend_url = None -def set_api_url(url): - global _api_url - _api_url = url - - -def set_frontend_url(url): - global _frontend_url - _frontend_url = url +@click_additional_options +@click.option( + "--api-url", help="BaNaNaS API URL.", default="https://api.bananas.openttd.org", show_default=True, metavar="URL", +) +@click.option( + "--frontend-url", + help="Frontend URL (this server).", + default="https://bananas.openttd.org", + show_default=True, + metavar="URL", +) +def click_urls(api_url, frontend_url): + global _api_url, _frontend_url + _api_url = api_url + _frontend_url = frontend_url def template(*args, **kwargs): diff --git a/webclient/pages/static.py b/webclient/pages/static.py index cbf8692..a1f2d20 100644 --- a/webclient/pages/static.py +++ b/webclient/pages/static.py @@ -1,3 +1,5 @@ +import flask + from ..app import app from ..helpers import ( not_found, @@ -13,6 +15,13 @@ def root(): return template("main.html") +@app.route("/healthz") +def healthz_handler(): + response = flask.make_response("200: OK") + response.headers["Content-Type"] = "text/plain" + return response + + @app.route("/manager/tos") def tos_latest(): return redirect("tos", version="1.2") diff --git a/webclient/sentry.py b/webclient/sentry.py new file mode 100644 index 0000000..0b61540 --- /dev/null +++ b/webclient/sentry.py @@ -0,0 +1,26 @@ +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/session.py b/webclient/session.py index c24e9e4..1b29471 100644 --- a/webclient/session.py +++ b/webclient/session.py @@ -1,7 +1,9 @@ +import click import datetime import flask import secrets +from .click import click_additional_options from .helpers import ( api_get, redirect, @@ -15,16 +17,39 @@ SESSION_COOKIE = "bananas_sid" -def set_auth_backend(method, developer_username=None): - auth_backend["method"] = method +@click_additional_options +@click.option( + "--authentication-method", + help="Authentication method to use.", + type=click.Choice(["developer", "github", "openttd"], case_sensitive=False), + default="github", + show_default=True, +) +@click.option("--developer-username", help="Username to use if authentication is set to 'developer'.") +def click_auth_backend(authentication_method, developer_username=None): + if authentication_method == "developer" and not developer_username: + raise click.UsageError("'developer-username' should be set if 'authentication-method' is 'developer'") + + auth_backend["method"] = authentication_method auth_backend["developer-username"] = developer_username -def set_max_age(session_age, csrf_age): +@click_additional_options +@click.option( + "--session-expire", + help="Time for a session to expire.", + default=60 * 60 * 14, + show_default=True, + metavar="SECONDS", +) +@click.option( + "--csrf-expire", help="Time for the CSRF token to expire.", default=60 * 30, show_default=True, metavar="SECONDS", +) +def click_max_age(session_expire, csrf_expire): global _max_session_age, _max_csrf_age - _max_session_age = session_age - _max_csrf_age = csrf_age + _max_session_age = datetime.timedelta(seconds=session_expire) + _max_csrf_age = datetime.timedelta(seconds=csrf_expire) class SessionData: diff --git a/wishlist.txt b/wishlist.txt index 53b540d..37cb331 100644 --- a/wishlist.txt +++ b/wishlist.txt @@ -2,3 +2,5 @@ - uploading files - tos 1.3: drop .pdf, add .md - nice enums for content-type, availability, compatibility, license +- either allow this app to run multiple processes or switch to aiohttp + (flask only handles 1 request at the time, and uses a few globals)