From aa17b6040033fdabf1ff8f935edea543eb9f8bac Mon Sep 17 00:00:00 2001 From: Andrew Williams Date: Fri, 16 Aug 2024 12:11:50 +0100 Subject: [PATCH] Rework into a testable, modern app --- .github/workflows/tests.yaml | 37 +++++++++ .gitignore | 4 +- Dockerfile | 2 +- Makefile | 17 ++++ poetry.lock | 126 ++++++++++++++++++++++++----- pyproject.toml | 11 ++- simple_webfinger/app.py | 148 ++++++++++++++++++++++++----------- tests/__init__.py | 0 tests/conftest.py | 13 +++ tests/test_basic.py | 46 +++++++++++ 10 files changed, 335 insertions(+), 69 deletions(-) create mode 100644 .github/workflows/tests.yaml create mode 100644 Makefile create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_basic.py diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml new file mode 100644 index 0000000..06d43fa --- /dev/null +++ b/.github/workflows/tests.yaml @@ -0,0 +1,37 @@ +name: Run Tests + +on: + push: + branches: + - "main" + pull_request: +jobs: + pytest: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.12"] + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Test with pytest + run: | + make tests + + lint: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.12"] + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Lint with ruff + run: | + make lint diff --git a/.gitignore b/.gitignore index 2afb3e1..8f9b3c6 100644 --- a/.gitignore +++ b/.gitignore @@ -128,4 +128,6 @@ dmypy.json # Pyre type checker .pyre/ -config.yaml \ No newline at end of file +config.yaml + +.ruff_cache \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index c2a51f3..041a06f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -30,4 +30,4 @@ FROM base AS runtime COPY --from=builder /runtime /usr/local COPY . /app WORKDIR /app -CMD ["/usr/local/bin/uvicorn", "simple_webfinger.app:app", "--host", "0.0.0.0", "--port", "80", "--proxy-headers"] \ No newline at end of file +CMD ["/usr/local/bin/uwsgi", "-w", "simple_webfinger.app:create_app()", "--master", "--http", "0.0.0.0:80"] \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..f62e927 --- /dev/null +++ b/Makefile @@ -0,0 +1,17 @@ +.venv: + python3 -m pip install poetry + python3 -m poetry install --with github + +.PHONY: tests +tests: .venv + python3 -m poetry run pytest + +lint: .venv + python3 -m poetry run ruff check --output-format=github --select=E9,F63,F7,F82 --target-version=py37 . + python3 -m poetry run ruff check --output-format=github --target-version=py37 . + +serve-uwsgi: + SIMPLE_WEBFINGER_CONFIG_FILE="examples/example-config.yaml" python3 -m poetry run uwsgi -w "simple_webfinger.app:create_app()" --master --http 0.0.0.0:8000 + +serve: + SIMPLE_WEBFINGER_CONFIG_FILE="examples/example-config.yaml" FLASK_DEBUG=1 FLASK_APP="simple_webfinger.app:create_app()" python3 -m poetry run flask run diff --git a/poetry.lock b/poetry.lock index d91ae5a..6cccb05 100644 --- a/poetry.lock +++ b/poetry.lock @@ -59,14 +59,14 @@ async = ["asgiref (>=3.2)"] dotenv = ["python-dotenv"] [[package]] -name = "h11" -version = "0.14.0" -description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +name = "iniconfig" +version = "2.0.0" +description = "brain-dead simple config-ini parsing" optional = false python-versions = ">=3.7" files = [ - {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, - {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, + {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, + {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, ] [[package]] @@ -166,6 +166,102 @@ files = [ {file = "MarkupSafe-2.1.5.tar.gz", hash = "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b"}, ] +[[package]] +name = "packaging" +version = "24.1" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +files = [ + {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"}, + {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, +] + +[[package]] +name = "pluggy" +version = "1.5.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, + {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] + +[[package]] +name = "pytest" +version = "8.3.2" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pytest-8.3.2-py3-none-any.whl", hash = "sha256:4ba08f9ae7dcf84ded419494d229b48d0903ea6407b030eaec46df5e6a73bba5"}, + {file = "pytest-8.3.2.tar.gz", hash = "sha256:c132345d12ce551242c87269de812483f5bcc87cdbb4722e48487ba194f9fdce"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=1.5,<2" + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "pytest-flask" +version = "1.3.0" +description = "A set of py.test fixtures to test Flask applications." +optional = false +python-versions = ">=3.7" +files = [ + {file = "pytest-flask-1.3.0.tar.gz", hash = "sha256:58be1c97b21ba3c4d47e0a7691eb41007748506c36bf51004f78df10691fa95e"}, + {file = "pytest_flask-1.3.0-py3-none-any.whl", hash = "sha256:c0e36e6b0fddc3b91c4362661db83fa694d1feb91fa505475be6732b5bc8c253"}, +] + +[package.dependencies] +Flask = "*" +pytest = ">=5.2" +Werkzeug = "*" + +[package.extras] +docs = ["Sphinx", "sphinx-rtd-theme"] + +[[package]] +name = "pytest-github-actions-annotate-failures" +version = "0.2.0" +description = "pytest plugin to annotate failed tests with a workflow command for GitHub Actions" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pytest-github-actions-annotate-failures-0.2.0.tar.gz", hash = "sha256:844ab626d389496e44f960b42f0a72cce29ae06d363426d17ea9ae1b4bef2288"}, + {file = "pytest_github_actions_annotate_failures-0.2.0-py3-none-any.whl", hash = "sha256:8bcef65fed503faaa0524b59cfeccc8995130972dd7b008d64193cc41b9cde85"}, +] + +[package.dependencies] +pytest = ">=4.0.0" + +[[package]] +name = "pytest-mock" +version = "3.14.0" +description = "Thin-wrapper around the mock package for easier use with pytest" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pytest-mock-3.14.0.tar.gz", hash = "sha256:2719255a1efeceadbc056d6bf3df3d1c5015530fb40cf347c0f9afac88410bd0"}, + {file = "pytest_mock-3.14.0-py3-none-any.whl", hash = "sha256:0b72c38033392a5f4621342fe11e9219ac11ec9d375f8e2a0c164539e0d70f6f"}, +] + +[package.dependencies] +pytest = ">=6.2.5" + +[package.extras] +dev = ["pre-commit", "pytest-asyncio", "tox"] + [[package]] name = "pyyaml" version = "6.0.2" @@ -256,23 +352,15 @@ files = [ ] [[package]] -name = "uvicorn" -version = "0.30.6" -description = "The lightning-fast ASGI server." +name = "uwsgi" +version = "2.0.26" +description = "The uWSGI server" optional = false -python-versions = ">=3.8" +python-versions = "*" files = [ - {file = "uvicorn-0.30.6-py3-none-any.whl", hash = "sha256:65fd46fe3fda5bdc1b03b94eb634923ff18cd35b2f084813ea79d1f103f711b5"}, - {file = "uvicorn-0.30.6.tar.gz", hash = "sha256:4b15decdda1e72be08209e860a1e10e92439ad5b97cf44cc945fcbee66fc5788"}, + {file = "uwsgi-2.0.26.tar.gz", hash = "sha256:86e6bfcd4dc20529665f5b7777193cdc48622fb2c59f0a7f1e3dc32b3882e7f9"}, ] -[package.dependencies] -click = ">=7.0" -h11 = ">=0.8" - -[package.extras] -standard = ["colorama (>=0.4)", "httptools (>=0.5.0)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1)", "watchfiles (>=0.13)", "websockets (>=10.4)"] - [[package]] name = "werkzeug" version = "3.0.3" @@ -293,4 +381,4 @@ watchdog = ["watchdog (>=2.3)"] [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "bf72c0042968309e4d0310634824cd85e145175c492553fcecf232778a2443f3" +content-hash = "56cd10f1b431bfb9cf33aaac319786d442e495826a46d2ffedfe746d38cbab38" diff --git a/pyproject.toml b/pyproject.toml index 65e44c1..d3ca0bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,10 +10,19 @@ readme = "README.md" python = "^3.11" Flask = "^3.0.3" PyYAML = "^6.0.2" -uvicorn = "^0.30.6" +uwsgi = "^2.0.26" [tool.poetry.group.dev.dependencies] +pytest = "^8.0.0" ruff = "^0.6.0" +pytest-mock = "^3.12.0" +pytest-flask = "^1.3.0" + +[tool.poetry.group.github] +optional = true + +[tool.poetry.group.github.dependencies] +pytest-github-actions-annotate-failures = "^0.2.0" [build-system] requires = ["poetry-core"] diff --git a/simple_webfinger/app.py b/simple_webfinger/app.py index 48e96ed..02841e7 100644 --- a/simple_webfinger/app.py +++ b/simple_webfinger/app.py @@ -1,75 +1,129 @@ from urllib.parse import urlparse -from flask import Flask, request, abort import yaml - -app = Flask(__name__) - -with open('config.yaml', 'rb') as fobj: - data = yaml.load(fobj, yaml.SafeLoader) +import json +from flask import Flask, abort, request -def get_account_links(user): +def get_account_links(user: str, data: dict) -> list: links = [] - account_data = data['accounts'][user] + account_data = data["accounts"][user] # Append custom links - if 'links' in account_data: - links.extend(account_data['links']) + if "links" in account_data: + links.extend(account_data["links"]) - if 'mastodon' in account_data: - account, domain = account_data['mastodon'].split('@') - links.extend([ - {'rel': 'http://webfinger.net/rel/profile-page', 'type': 'text/html', 'href': 'https://{0}/@{1}'.format(domain, account)}, - {'rel': 'self', 'type': 'application/activity+json', 'href': 'https://{0}/users/{1}'.format(domain, account)}, - {'rel': 'http://ostatus.org/schema/1.0/subscribe', 'template': "https://{0}/authorize_interaction?uri={{uri}}".format(domain)} - ]) + if "mastodon" in account_data: + account, domain = account_data["mastodon"].split("@") + links.extend( + [ + { + "rel": "http://webfinger.net/rel/profile-page", + "type": "text/html", + "href": "https://{0}/@{1}".format(domain, account), + }, + { + "rel": "self", + "type": "application/activity+json", + "href": "https://{0}/users/{1}".format(domain, account), + }, + { + "rel": "http://ostatus.org/schema/1.0/subscribe", + "template": "https://{0}/authorize_interaction?uri={{uri}}".format( + domain + ), + }, + ] + ) # Append the OIDC link - if 'oidc_href' in data: - links.append({ - 'rel': 'http://openid.net/specs/connect/1.0/issuer', - 'href': data['oidc_href'], - }) + if "oidc_href" in data: + links.append( + { + "rel": "http://openid.net/specs/connect/1.0/issuer", + "href": data["oidc_href"], + } + ) return links -def filter_links(links, rel): +def filter_links(links: dict[str, str], rel: str) -> list: + """ + Filter links by rel provided. + """ + if isinstance(rel, str): + rel = [rel] new_links = [] for link in links: - if link['rel'] == rel: + if link["rel"] in rel: new_links.append(link) return new_links -@app.route("/.well-known/webfinger") -def webfinger(): - resource = request.args.get('resource') +def create_app(config={}): + app = Flask("simple_webfinger") + app.webfinger_config = { + "domain": None, + "accounts": {}, + } + app.config.from_prefixed_env("SIMPLE_WEBFINGER") + app.config.from_object(config) - # No resource requested, so return a HTTP 400 - if not resource: - abort(400) + if "CONFIG_FILE" in app.config: + with open(app.config["CONFIG_FILE"], "rb") as fobj: + app.webfinger_config = yaml.load(fobj, yaml.SafeLoader) - account, domain = urlparse(resource).path.split('@') + if not app.webfinger_config["domain"]: + app.logger.warning( + "No domain is configured for webfinger, this instance will not operate correctly." + ) - # If the request is not for the correct domain, or for an account that doesn't exist, return 404 - if domain != data['domain'] or account not in data['accounts']: + @app.route("/.well-known/webfinger") + def webfinger(): + resource = request.args.get("resource") + + # No resource requested, so return a HTTP 400 + if not resource: + abort(400) + + parsed_resource = urlparse(resource) + scheme = parsed_resource.scheme + account, domain = parsed_resource.path.split("@") + + # If the request is not for the correct domain, return 404 + if domain != app.webfinger_config["domain"]: + abort(404) + + # Handle acct resource requests + if scheme == "acct": + if account not in app.webfinger_config["accounts"]: + abort(404) + + account_data = app.webfinger_config["accounts"][account] + links = get_account_links(account, app.webfinger_config) + + # If we have a 'rel' value on the request, filter down to the requested rel + # https://datatracker.ietf.org/doc/html/rfc7033#section-4.3 + rel = request.args.get("rel") + if rel: + links = filter_links(links, rel) + + response = {"subject": resource, "links": links} + if "properties" in account_data and len(account_data['properties']): + response.update({"properties": account_data["properties"]}) + + return app.response_class( + response=json.dumps(response), + status=200, + mimetype="application/jrd+json", + ) + + # Anything else, 404 for now abort(404) - links = get_account_links(account) - - # If we have a 'rel' value on the request, filter down to the requested rel - # https://datatracker.ietf.org/doc/html/rfc7033#section-4.3 - rel = request.args.get('rel') - if rel: - links = filter_links(links, rel) - - return { - 'subject': resource, - 'links': links - } + return app -if __name__ == '__main__': - app.run(host='0.0.0.0', port=8000) +if __name__ == "__main__": + create_app().run(host="0.0.0.0", port=8000) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..5756478 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,13 @@ +import pytest +import yaml + +from simple_webfinger.app import create_app + + +@pytest.fixture(scope="module") +def app(): + app = create_app() + app.webfinger_config = yaml.load( + open("examples/example-config.yaml", "r"), yaml.SafeLoader + ) + yield app diff --git a/tests/test_basic.py b/tests/test_basic.py new file mode 100644 index 0000000..1fc1878 --- /dev/null +++ b/tests/test_basic.py @@ -0,0 +1,46 @@ +def test_index_route(client): + """ + Check that the index route is 404 + """ + response = client.get("/") + assert response.status_code == 404 + + +def test_webfinger_route(client): + """ + Check a basic GET to the webfinger route returns a 400 + """ + response = client.get("/.well-known/webfinger") + # We don't provide any arguments, so this should be a 400 + assert response.status_code == 400 + + +def test_domain(app, client): + """ + Check a correct call to the webfinger endpoint returns a valid response + """ + response = client.get("/.well-known/webfinger?resource=acct:nikdoof@doofnet.uk") + assert response.status_code == 200 + + +def test_invalid_domain(app, client): + """ + Check a invalid domain name results in a 404 + """ + response = client.get("/.well-known/webfinger?resource=acct:nikdoof@xxxx.uk") + assert response.status_code == 404 + + +def test_invalid_user(app, client): + """ + Check a invalid user results in a 404 + """ + response = client.get("/.well-known/webfinger?resource=acct:nikxxxdoof@doofnet.uk") + assert response.status_code == 404 + +def test_invalid_user_request(app, client): + """ + Check a invalid user request (without acct) results in a 404 + """ + response = client.get("/.well-known/webfinger?resource=nikdoof@doofnet.uk") + assert response.status_code == 404