mirror of
https://github.com/nikdoof/simple-webfinger.git
synced 2025-12-10 17:12:16 +00:00
Rework into a testable, modern app
This commit is contained in:
37
.github/workflows/tests.yaml
vendored
Normal file
37
.github/workflows/tests.yaml
vendored
Normal 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
4
.gitignore
vendored
@@ -128,4 +128,6 @@ dmypy.json
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
config.yaml
|
||||
config.yaml
|
||||
|
||||
.ruff_cache
|
||||
@@ -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
17
Makefile
Normal 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
126
poetry.lock
generated
@@ -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"
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
0
tests/__init__.py
Normal file
13
tests/conftest.py
Normal file
13
tests/conftest.py
Normal 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
46
tests/test_basic.py
Normal 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
|
||||
Reference in New Issue
Block a user