trwnh.com/f3l/app/server.py

164 lines
4.5 KiB
Python
Raw Permalink Normal View History

from typing import Iterable, Set
from fastapi import FastAPI, Request, Response, status, HTTPException
from fastapi.responses import PlainTextResponse
import httpx
from starlette.exceptions import HTTPException as StarletteHTTPException
from uuid_extensions import uuid7str
import mimeparse
from mimeparse import parse_mime_type
import json
app = FastAPI()
SUPPORTED_CONTENT_TYPES = [
'application/activity+json',
'application/ld+json',
'text/turtle',
]
THE_ACTOR_NAMESPACE = 'http://localhost:8000/'
# Override JSON error responses to plain text instead
@app.exception_handler(StarletteHTTPException)
async def http_exception_handler(request, exc):
return PlainTextResponse(
content=str(exc.detail),
status_code=exc.status_code,
headers=exc.headers,
)
async def get_inboxes_from_resources(resources: Iterable[str]) -> Set[str]:
"""
Discover an ldp:inbox for every resource passed in.
"""
pass
async def get_inbox_from_resource(resource: str) -> str:
"""
Discover an ldp:inbox for a resource.
"""
try:
async with httpx.AsyncClient() as client:
if not '#' in resource:
response = await client.head(resource)
links = response.links
print(links)
return
response = await client.get(resource)
except:
pass
@app.post("/outbox")
async def post_outbox(request: Request) -> Response:
"""
Accept a notification payload and deliver to ActivityPub targets,
per the S2S "Federated Server" conformance profile.
"""
try:
# Validate Content-Type
(
mime_maintype,
mime_subtype,
mime_params
) = parse_mime_type(
request.headers.get('Content-Type'),
)
mime_type = mime_maintype + '/' + mime_subtype
if mime_type not in SUPPORTED_CONTENT_TYPES:
raise HTTPException(
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
headers={
'Accept-Post': ', '.join(SUPPORTED_CONTENT_TYPES)
},
detail=(
'HTTP Error 415: Unsupported Media Type\n'
f'This endpoint does not requests of the type {mime_type}\n\n'
'The supported types are:\n\n'
f'{'\n'.join(SUPPORTED_CONTENT_TYPES)}'
),
)
# Handle notification payload
IS_ACTIVITYSTREAMS = (
mime_type == 'application/activity+json'
or (
mime_type == 'application/ld+json'
and mime_params.get('profile') == 'https://www.w3.org/ns/activitystreams'
)
)
if IS_ACTIVITYSTREAMS:
"""
We can use shorthand terms because it is guaranteed
to be compacted against AS2 context at least.
"""
body = await request.body()
activity = json.loads(body)
# Get delivery targets
as_to = activity.get('to', [])
as_cc = activity.get('cc', [])
as_audience = activity.get('audience', [])
as_bto = activity.get('bto', [])
as_bcc = activity.get('bcc', [])
# TODO: Expand collections into actors
# Deduplicate the final recipients list
delivery_targets = set(as_to + as_cc + as_audience + as_bto + as_bcc)
# Find their inboxes
inboxes = await get_inboxes_from_resources(delivery_targets)
else:
"""
We should use full IRIs and treat this as RDF.
"""
# TODO: do this
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail=(
'HTTP Error 501: Not Implemented\n'
'I still need to add support for non-AS2 payloads...'
),
)
# Prepare Location header for response and persist activity to storage
path = f'activities/{uuid7str()}'
location = THE_ACTOR_NAMESPACE+path
headers = {
'Location': location
}
# Respond with 201 Created + Location header
return PlainTextResponse(
content=f'Created activity at {location}',
status_code=status.HTTP_201_CREATED,
headers=headers,
)
except mimeparse.MimeTypeParseException as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
'HTTP Error 400: Bad Request.\n'
'Could not parse the Content-Type of the request.\n\n'
f'{e}'
),
)
except json.JSONDecodeError as e:
line_where_the_error_occurs = str(body, encoding='utf-8').split('\n')[e.lineno-1]
before_the_error = line_where_the_error_occurs[max(0,e.colno-11):e.colno]
after_the_error = line_where_the_error_occurs[e.colno:min(e.colno+10,len(line_where_the_error_occurs))]
where_the_error_occurs = before_the_error + after_the_error + '\n' + '.'*(len(before_the_error)-1) + '^'
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
'HTTP Error 400: Bad Request.\n'
'Content-Type was detected as JSON-based, '
'but request body could not be parsed as JSON.\n\n'
f'{e}\n'
f'{where_the_error_occurs}'
),
)
@app.post("/inbox")
async def post_inbox(request: Request):
pass