Rework into a testable, modern app

This commit is contained in:
2024-08-16 12:11:50 +01:00
parent 5862b259d1
commit aa17b60400
10 changed files with 335 additions and 69 deletions

37
.github/workflows/tests.yaml vendored Normal file
View File

@@ -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

4
.gitignore vendored
View File

@@ -128,4 +128,6 @@ dmypy.json
# Pyre type checker
.pyre/
config.yaml
config.yaml
.ruff_cache

View File

@@ -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"]
CMD ["/usr/local/bin/uwsgi", "-w", "simple_webfinger.app:create_app()", "--master", "--http", "0.0.0.0:80"]

17
Makefile Normal file
View File

@@ -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

126
poetry.lock generated
View File

@@ -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"

View File

@@ -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"]

View File

@@ -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)

0
tests/__init__.py Normal file
View File

13
tests/conftest.py Normal file
View File

@@ -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

46
tests/test_basic.py Normal file
View File

@@ -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