diff --git a/README.md b/README.md index 5221343..f17d302 100644 --- a/README.md +++ b/README.md @@ -57,3 +57,7 @@ uvicorn webfinger:app --port 7033 ### Post-run proxy `/.well-known/webfinger` to localhost:7033 + +## Development + +`pdm run uvicorn webfinger:app --reload` \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index c12e97c..f0e804b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "webfinger" -version = "1" +version = "2" description = "Simple WebFinger server that returns static resources. Written with Python and FastAPI." authors = [ {name = "a", email = "a@trwnh.com"}, diff --git a/webfinger/__init__.py b/webfinger/__init__.py index 5cd5fb5..bd12e09 100644 --- a/webfinger/__init__.py +++ b/webfinger/__init__.py @@ -1,62 +1,34 @@ from fastapi import FastAPI -from fastapi.responses import PlainTextResponse, JSONResponse -from fastapi.params import Query -import json -from pathlib import Path -from os import environ as env +import webfinger.lookup -app = FastAPI() +app = FastAPI( + title = "webfinger", + version = "2" +) -@app.get("/") -def lookup( - resource: str = Query(None), - rel: list[str] = Query(None) - ): - """ - Respond to a WebFinger query. - """ +app.include_router(webfinger.lookup.router) - # Basic info as a hint, if no resource given - if not resource: - return PlainTextResponse( - content="Query ?resource= for more information.", - status_code=400, - ) - - # otherwise, load that resource and return its data - dir = env.get("RESOURCE_DIR") or "resource" - filename = f"{dir}/{resource}.json" - path = Path(filename) - # TODO redirect to upstream webfinger server(s) if resource not found locally - # and if there is an upstream to try - if not path.is_file(): - return PlainTextResponse( - content=f"{resource} not found", - status_code=404, - ) +def cleanup_openapi(app: FastAPI) -> None: + """Cleanup inconsistencies in the OpenAPI documentation""" + openapi_schema = app.openapi() - # open the local file - with open(filename, "r") as file: - data: dict = json.loads(file.read()) + # Remove 422 Validation Error from Webfinger lookup (RFC7033 uses 400 Bad Request) + webfinger_responses: dict = openapi_schema["paths"]["/.well-known/webfinger"]["get"]["responses"] + webfinger_responses.pop("422") - # filter links if rel is provided - links = [link for link in data.get('links', [])] - if rel: - links = [link for link in links if link.get('rel', '') in rel] + # Remove 422 Validation Error from schemas + schemas: dict = openapi_schema["components"]["schemas"] + schemas.pop("ValidationError") + schemas.pop("HTTPValidationError") - # construct json response - content = { - "subject": resource, - "aliases": [alias for alias in data.get('aliases')], - "properties": data.get('properties'), - "links": links, - } - content = {k: v for k, v in content.items() if v} # remove null values - headers = { - "Content-Type": "application/jrd+json" - } - return JSONResponse(content=content, headers=headers) + # Mark resource param as required + lookup_params: list[dict] = openapi_schema["paths"]["/.well-known/webfinger"]["get"]["parameters"] + resource_param = [param for param in lookup_params if param["name"] == "resource"][0] + resource_param["required"] = True + + +cleanup_openapi(app) if __name__ == "__main__": import uvicorn diff --git a/webfinger/io.py b/webfinger/io.py new file mode 100644 index 0000000..de3b062 --- /dev/null +++ b/webfinger/io.py @@ -0,0 +1,19 @@ +import json +from os import environ as env +from pathlib import Path + +def get_document(resource: str) -> dict | None: + # Get a filename for the resource document. + dir = env.get("RESOURCE_DIR") or "resource" + filename = f"{dir}/{resource}.json" + + # Check that the file exists. + path = Path(filename) + if not path: + return None + + # Open the file and load the JSON as a dictionary. + with open(filename, "r") as file: + data: dict = json.loads(file.read()) + + return data \ No newline at end of file diff --git a/webfinger/lookup.py b/webfinger/lookup.py new file mode 100644 index 0000000..d859c90 --- /dev/null +++ b/webfinger/lookup.py @@ -0,0 +1,163 @@ +from fastapi import APIRouter, Response +from fastapi.responses import PlainTextResponse, JSONResponse +from fastapi.params import Query + +from pydantic import BaseModel + +from webfinger.io import get_document + + +router = APIRouter( + prefix="/.well-known/webfinger" +) + + +## String literals + +MEDIA_TYPE = "application/jrd+json" + +EXAMPLE_RESOURCE = r''' +{ + "aliases": ["https://trwnh.com/actors/7057bc10-db1c-4ebe-9e00-22cf04be4e5e", "https://trwnh.com/~a"], + "links": [ + { + "rel": "self", + "type": "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"", + "href": "https://trwnh.com/actors/7057bc10-db1c-4ebe-9e00-22cf04be4e5e" + }, + { + "rel": "https://webfinger.net/rel/profile-page/", + "type": "text/html", + "href": "https://trwnh.com/~a" + } + ] +} +''' + +NO_RESOURCE_HINT = "Query ?resource={resource} to obtain information about that resource." + + +## Pydantic models for OpenAPI schema + +class Link(BaseModel): + rel: str + type: str | None + href: str | None + titles: str | None + properties: str | None + + class Config: + schema_extra = { + "example": '''{ + "rel": "https://webfinger.net/rel/profile-page/", + "type": "text/html", + "href": "https://trwnh.com/~a" + }''' + } + +class Resource(BaseModel): + subject: str + aliases: list[str] | None + links: list[Link] | None + + class Config: + schema_extra = { + "example": EXAMPLE_RESOURCE + } + +class WebfingerResponse(Response): + media_type = MEDIA_TYPE + + +## Example responses for OpenAPI schema + +RESPONSES = { + 400: { + "description": "Bad Request. No resource was provided.", + "content": { + "text/plain": { + "example": NO_RESOURCE_HINT + } + }, + }, + 404: { + "description": "Not Found. Could not find a document for the provided resource.", + "content": { + "text/plain": { + "example": "{resource} not found" + } + }, + }, + 200: { + "description": "The resource you requested was found, and has the returned resource document.", + "model": Resource, + "content": { + MEDIA_TYPE: { + "example": EXAMPLE_RESOURCE + } + } + }, +} + + +## The lookup method as described in RFC7033 + +@router.get( + "", + summary = "Lookup a Webfinger resource", + description = "Query the Webfinger service for a resource URI and obtain its associated resource document", + tags = ["RFC7033"], + response_model = Resource, + response_class = WebfingerResponse, + responses = {**RESPONSES}, + +) +async def lookup( + resource: str = Query( + None, + description = "The subject whose document you are looking up. If not provided, you will get a 400 Bad Request." + ), + rel: list[str] = Query( + None, + description = "If provided, filter the links array for this rel-value only. This parameter may be included multiple times." + ) + ): + """ + Respond to a WebFinger query. + """ + + # If no resource is given, then show a basic hint. + if not resource: + return PlainTextResponse( + content=NO_RESOURCE_HINT, + status_code=400, + ) + + # Otherwise, try to read the resource document. + data = get_document(resource) + + # If the document could not be read, then it likely didn't exist. + if data is None: + return PlainTextResponse( + content=f"{resource} not found", + status_code=404, + ) + + # Obtain values from the resource document. + aliases = [alias for alias in data.get('aliases', [])] + links = [link for link in data.get('links', [])] + properties = data.get('properties', {}) + + # If optional rel is provided, then filter only for links with that rel-type. + if rel: + links = [link for link in links if link.get('rel', '') in rel] + + # Construct JSON response and return it. + content = { + "subject": resource, + "aliases": aliases, + "properties": properties, + "links": links, + } + content = {k: v for k, v in content.items() if v} # remove null values + return JSONResponse(content=content, media_type=MEDIA_TYPE) \ No newline at end of file