Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
.git
.github
.pytest_cache
__pycache__
*.pyc
shadow
tests
37 changes: 37 additions & 0 deletions .github/workflows/publish.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
name: Publish container

on:
push:
tags: ["v*"]
workflow_dispatch:

permissions:
contents: read
packages: write

jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: docker/setup-buildx-action@v4.2.0
- uses: docker/login-action@v4.4.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- id: meta
uses: docker/metadata-action@v6.2.0
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest
- uses: docker/build-push-action@v7.3.0
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64,linux/arm64
32 changes: 32 additions & 0 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
name: Test

on:
pull_request:
push:
branches: [master]

permissions:
contents: read

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v6.3.0
with:
python-version: "3.12"
cache: pip
- run: pip install --requirement requirements-dev.txt
- run: python -m compileall -q app
- run: pytest --cov=app --cov-report=term-missing

docker:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: docker/setup-buildx-action@v4.2.0
- uses: docker/build-push-action@v7.3.0
with:
context: .
push: false
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ __pycache__/
# Distribution / packaging
.Python
env/
.venv/
build/
develop-eggs/
dist/
Expand Down Expand Up @@ -66,4 +67,6 @@ target/

# Private config and keys
shadow
*.pem # excessive but for avoid attention
*.pem
.pytest_cache/
.ruff_cache/
20 changes: 20 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
FROM python:3.12-slim AS runtime

ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PYTHONPATH=/app/app

RUN groupadd --system app && useradd --system --gid app --home /app app
WORKDIR /app

COPY requirements.txt ./
RUN pip install --no-cache-dir --requirement requirements.txt

COPY --chown=app:app app ./app
USER app

EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/healthz', timeout=2)"]

CMD ["gunicorn", "--bind=0.0.0.0:8000", "--workers=2", "--threads=2", "--access-logfile=-", "--error-logfile=-", "app_main:create_app()"]
71 changes: 44 additions & 27 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,43 +1,60 @@
## Abstract
# chtoes.li feedback API

The simple flask application implements an API for the chtoes.li website.
Small Flask API used by the feedback form on [chtoes.li](https://chtoes.li).
It validates reader submissions and creates issues in `whatifrussian/website`
through an installed GitHub App using PyGithub.

## Setup
## Development

The private configuration and keys supposed to be into `shadow/` directory sibling to `app/` one. This directory is NOT synced with the server by `fab publish` and generally differs between local (debug) and server (production) setup. It should be created manually.
Python 3.12 is the supported runtime.

`shadow/config.json` -- overwrites `app/config.json` and has the same format. This file necessary to set credentials for interacting with GitHub Integrations API and keeping its out of version control. Any other settings (like target repository for issues) can be set there to leave repository clean during developing / debugging.
```console
python -m venv .venv
. .venv/bin/activate
pip install -r requirements-dev.txt
pytest
```

**Warning:** The shadow config non-deeply merged into the public one. While it’s possible to omit a first-level key in the shadow config, the overwritten value should consist all corresponding keys and not only modified ones.
Private GitHub App configuration belongs in `shadow/config.json`. The directory
is ignored by Git. It overrides top-level values from `app/config.json` and must
provide the complete `github` object:

```json
{
"github": {
"integration_id": 123,
"installation_id": 456,
"secret_key_file": "shadow/github-app.private-key.pem"
}
}
```

## Deploy
The private key path may be absolute or relative to the repository root. Set
`CHTOES_CONFIG` to use a configuration file at another location.

As simple as:
## Container

```
user@host api $ fab publish
```
Build and test the image locally:

It just sync actual content of the `app/` directory with the server and reloads uwsgi.
```console
docker build -t chtoes-api .
docker run --rm -p 127.0.0.1:8000:8000 \
-v "$PWD/shadow:/app/shadow:ro" chtoes-api
curl http://127.0.0.1:8000/healthz
```

## Debug
`compose.prod.yml` runs the published GHCR image on `127.0.0.1:8090`. It mounts
the server's existing private configuration read-only, uses a read-only root
filesystem, drops root, and enables automatic restart.

The website expects `/api/issue/` to be on the same domain as the website. It makes debugging on a local machine tricky, but still possible.
The nginx proxy snippet and production rollback notes live in `deploy/`.

The idea is to run flask's webserver on the same port as a webserver serving the website. Example:
## Release

```
user@host website $ fab reserve:debug # or reserve:local
... open http://localhost:8000 in a browser ...
user@host website $ ^C # stop the website's server
... cd to api ...
user@host api $ export FLASK_APP=app/app_main.py
user@host api $ flask run -p 8000
... use the issue form in the browser ...
```
Pull requests and pushes to `master` run Python tests and build the image.
Tags matching `v*` publish multi-architecture images to
`ghcr.io/whatifrussian/api` with version and `latest` tags.

## License

Public domain. You free to use it as you need without any restrictions. No guarantees provided.

Consider LICENSE file for more information.
Public domain. See `LICENSE`.
183 changes: 81 additions & 102 deletions app/app_issue.py
Original file line number Diff line number Diff line change
@@ -1,111 +1,90 @@
import logging
import re
from traceback import format_exc
from flask import Blueprint, request
from flask import current_app as app
from github import GitHubAPI
from utils import get_base_dir, get_config, dump_to_json
from app_log import log_request

from flask import Blueprint, jsonify, request
from github import GithubException

app_issue = Blueprint('issue', __name__)
gitHub = GitHubAPI(base_dir=get_base_dir(), **get_config('github'))
escape_md_re = re.compile(r'([*_\\<>\[\]`])')

LOGGER = logging.getLogger(__name__)
EXPECTED_FIELDS = {
"comment",
"slug",
"before_far",
"before_near",
"sel",
"after_near",
"after_far",
}
ESCAPE_MARKDOWN = re.compile(r"([*_\\<>\[\]`])")

# stub for receiving events
@app_issue.route('/api/github-gate/', methods=['POST'])
def github_gate():
log_request(request, 'api-github-gate')
return 'ok'

def validate_issue_request(req):
if req.headers.get("X-Requested-With") != "XMLHttpRequest":
return None, "X-Requested-With must be XMLHttpRequest"
if not req.is_json:
return None, "Content-Type must be application/json"

def is_issue_request_valid(request):
exp_headers = {
'X-Requested-With': ('XMLHttpRequest',),
'Content-Type': (
'application/json; charset=utf-8',
'application/json; charset=UTF-8',
),
payload = req.get_json(silent=True)
if not isinstance(payload, dict):
return None, "Request body must be a JSON object"
if set(payload) != EXPECTED_FIELDS:
return None, "Request body contains unexpected or missing fields"
if not all(isinstance(payload[field], str) for field in EXPECTED_FIELDS):
return None, "All request fields must be strings"
return payload, None


def render_issue(template, user_data):
escaped = {
key: ESCAPE_MARKDOWN.sub(r"\\\1", value)
if key != "comment"
else value
for key, value in user_data.items()
}
for k, exp_v in exp_headers.items():
value = request.headers.get(k)
if value not in exp_v:
exp_v_str = '* "' + '"\n* "'.join(exp_v) + '"'
why = ('Header "{k}" is "{value}", ' +
'expected one of the following:\n' +
'{exp_v}').format(k=k, value=value, exp_v=exp_v_str)
return False, why
exp_keys = {'comment', 'slug', 'before_far', 'before_near', 'sel',
'after_near', 'after_far'}
user_data = request.get_json()
if not user_data:
why = 'The request cannot be interpreted as JSON'
return False, why
keys = user_data.keys()
if keys ^ exp_keys != set():
why = 'Keys are {keys}, expected {exp_keys}'.format(
keys=keys, exp_keys=exp_keys)
return False, why
return True, None


def escape_sel_context(user_data):
res = dict()
exp_keys = {'before_far', 'before_near', 'sel', 'after_near', 'after_far'}
for k, v in user_data.items():
if k in exp_keys:
res[k] = escape_md_re.sub(r'\\\1', user_data[k])
else:
res[k] = user_data[k]
return res


@app_issue.route('/api/issue/', methods=['POST'])
def api_issue():
log_request(request, 'api-issue')
# prepare constants
headers = {'Content-Type': 'application/json; charset=utf-8'}
bad_payload = {
'success': False,
'num': None,
'url': None,
body_parts = []
line_prefix = ""
for item in template["body"]:
if isinstance(item, dict):
line_prefix = item["line_prefix"]
continue
rendered = item.format_map(escaped)
body_parts.append(line_prefix + ("\n" + line_prefix).join(rendered.split("\n")))

return {
"owner": template["owner"],
"repo": template["repo"],
"title": template["title"].format_map(escaped),
"body": "".join(body_parts).rstrip("\n"),
"labels": template["labels"],
"assignees": template["assignees"],
}
bad_request_response = (dump_to_json(bad_payload), 400, headers)
# get and validate user's data
valid, why = is_issue_request_valid(request)
if not valid:
log_request(request, 'api-issue-errors', why)
return bad_request_response
user_data = request.get_json()
user_data = escape_sel_context(user_data)
# get template
exp_keys = {'owner', 'repo', 'title', 'body', 'labels', 'assignees'}
issue_tmpl = get_config('issue_template', exp_keys)
# specify template
as_is_keys = {'owner', 'repo', 'labels', 'assignees'}
issue = {k: issue_tmpl[k] for k in as_is_keys}
issue['title'] = issue_tmpl['title'].format_map(user_data)
issue['body'] = ''
line_prefix = ''
for line in issue_tmpl['body']:
if isinstance(line, dict):
line_prefix = line['line_prefix']
else:
line = line.format_map(user_data)
line = ('\n' + line_prefix).join(line.split('\n'))
issue['body'] += line_prefix + line
issue['body'] = issue['body'].rstrip('\n')
# create issue
try:
num, url = gitHub.create_issue(**issue)
payload = {
'success': True,
'num': num,
'url': url,
}
except:
why = 'The exception raised in api_issue:\n' + format_exc()
app.logger.warning(why)
log_request(request, 'api-issue-errors', why)
return bad_request_response
return (dump_to_json(payload), 200, headers)


def create_issue_blueprint(github_client, issue_template):
blueprint = Blueprint("issue", __name__)

@blueprint.post("/api/github-gate/")
def github_gate():
return "ok"

@blueprint.post("/api/issue/")
def api_issue():
payload, error = validate_issue_request(request)
if error:
LOGGER.info("Rejected issue request: %s", error)
return jsonify(success=False, num=None, url=None), 400

try:
number, url = github_client.create_issue(
**render_issue(issue_template, payload)
)
except GithubException:
LOGGER.exception("GitHub rejected issue creation")
return jsonify(success=False, num=None, url=None), 502
except Exception:
LOGGER.exception("Unexpected issue creation failure")
return jsonify(success=False, num=None, url=None), 500

return jsonify(success=True, num=number, url=url)

return blueprint
Loading