Source code for swh.coarnotify.server.views
# Copyright (C) 2025 The Software Heritage developers
# See the AUTHORS file at the top-level directory of this distribution
# License: GNU Affero General Public License version 3, or any later version
# See top-level LICENSE file for more information
"""Views."""
from http import HTTPStatus
from django.db.models import QuerySet
from django.http import HttpResponse
from django.shortcuts import get_object_or_404, render
from pyld import jsonld
from rest_framework.decorators import api_view, permission_classes
from rest_framework.exceptions import (
NotAuthenticated,
ParseError,
PermissionDenied,
ValidationError,
)
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response
from .handlers import get_handler
from .models import InboundNotification, OutboundNotification
from .utils import (
inbox_headers,
unprocessable,
uuid_from_urn,
validate_context,
validate_required_keys,
validate_sender_inbox,
)
[docs]
class Receipt(Response):
"""A 201 HTTP Response containing the url of the created notification."""
def __init__(self, notification: InboundNotification, request: Request):
"""Init the Response.
Args:
notification: the notification we created
request: the HTTP request
"""
headers = {
"Location": request.build_absolute_uri(notification.get_absolute_url())
}
super().__init__(status=HTTPStatus.CREATED, headers=headers)
[docs]
class Discover(Response):
"""A 200 HTTP Response containing the url of our inbox."""
def __init__(self, request: Request):
"""Init the Response.
Args:
request: the HTTP request
"""
super().__init__(status=HTTPStatus.OK, headers=inbox_headers(request))
[docs]
class UserInbox(Response):
"""A 200 HTTP Response containing the url of our notifications sent by a user."""
def __init__(
self, request: Request, notifications: "QuerySet[InboundNotification]"
):
"""Init the Response.
Args:
request: the HTTP request
notifications: a queryset if notifications
"""
data = {
"@context": "http://www.w3.org/ns/ldp",
"@id": request.build_absolute_uri(),
"contains": [
request.build_absolute_uri(n.get_absolute_url()) for n in notifications
],
}
super().__init__(data=data, status=HTTPStatus.OK)
[docs]
def process_notification(request: Request) -> InboundNotification:
"""Process an inbound COAR Notification.
- structural payload validation
- route notification to the proper handler depending on its type(s)
Args:
request: an HTTP request
Raises:
ParseError: invalid jsonld
ValidationError: invalid ``@context`` or inbox url
BadRequest: a CN with this id has already been processed
UnprocessableException: the CN was deemed unprocessable
NotAuthenticated: missing request.user
Returns:
An HTTP response with an error code if the COAR Notification is structurally
invalid, or a simple JSON response with a message key containing the outcome
of the process (which is also send to the sender Inbox as a COAR Notification).
"""
if not request.user.is_authenticated:
raise NotAuthenticated()
# Validate structural integrity
validate_required_keys(request.data)
try:
payload = jsonld.compact(request.data, request.data["@context"])
except jsonld.JsonLdError:
# hard to extract a meaningful error message from pyld
raise ParseError("Unable to process json-ld")
validate_context(payload["@context"])
validate_sender_inbox(request, payload)
# Is it a reply ?
in_reply_to: OutboundNotification | None = None
if reply_urn := payload.get("inReplyTo"):
in_reply_to = OutboundNotification.objects.filter(
id=uuid_from_urn(reply_urn)
).first() # XXX should we reject the notification if in_reply_to is invalid ?
# Store CN
notification_id = uuid_from_urn(payload["id"])
if InboundNotification.objects.filter(id=notification_id).exists():
raise ValidationError(
f"A COAR Notification with the id {notification_id} has already "
"been handled."
)
notification = InboundNotification.objects.create(
id=notification_id,
in_reply_to=in_reply_to,
payload=payload,
raw_payload=request.data,
sender=request.user.organization,
)
# at this stage 1) the CN sender has been authenticated 2) the user's inbox match
# the inbox's url in the CN payload 3) the CN has been stored in the db so it
# should be safe to reply to it
# Validate swh's inbox
swh_inbox_url = request.build_absolute_uri() # XXX should it be an env var ?
if payload["target"]["inbox"] != swh_inbox_url:
error_message = (
f"Software Heritage inbox url {swh_inbox_url} does not match "
f"Target inbox {payload['target']['inbox']} in the notification"
)
unprocessable(notification, error_message)
return notification
# Find an handler to process this CN
if handler := get_handler(notification):
handler(notification)
return notification
[docs]
@api_view(["GET", "POST", "HEAD"])
def inbox(request) -> Response | HttpResponse:
"""Main inbox API endpoint.
The response returned depends on the method used by the client:
- HEAD: returns LDN inbox discovery headers
- GET:
- unauthenticated: an HTML response
- authenticated: user's inbox
- POST:
- unauthenticated: an exception
- authenticated: the result of the payload processing
Args:
request: an HTTP request
Raises:
PermissionDenied: unable to auth user
Returns:
An HTTP response
"""
if request.method == "HEAD":
return Discover(request)
if request.method == "GET" and not request.user.is_authenticated:
response = render(request, "index.html")
# include LDN inbox discovery headers
for k, v in inbox_headers(request).items():
response[k] = v
return response
if not request.user.is_authenticated:
raise PermissionDenied()
if request.method == "GET":
return UserInbox(
request,
InboundNotification.objects.filter(sender=request.user.organization),
)
notification = process_notification(request)
return Receipt(notification, request)
[docs]
@api_view(["GET"])
@permission_classes([IsAuthenticated])
def read_notification(request, pk) -> Response:
notification = get_object_or_404(InboundNotification, pk=pk)
if notification.sender != request.user.organization:
raise PermissionDenied()
return Response(notification.raw_payload)