Skip to content

Commit

Permalink
Added initial authz support.
Browse files Browse the repository at this point in the history
Added initial JSON Schema validation.
Updated delivery code.
Fixed issues with MediaTypeRoute.
  • Loading branch information
steve-bate committed Sep 15, 2024
1 parent 5cd19d8 commit f7ff705
Show file tree
Hide file tree
Showing 11 changed files with 471 additions and 35 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -163,3 +163,4 @@ cython_debug/

.ruff_cache
.DS_Store
.report.json
19 changes: 15 additions & 4 deletions firm_server/config.py
Original file line number Diff line number Diff line change
@@ -1,35 +1,46 @@
import typing
from dataclasses import dataclass
from dataclasses import dataclass, field

import dacite
import yaml

from firm_server.exceptions import ServerException


@dataclass
@dataclass(frozen=True)
class FileStoreConfig:
path: str
remote_subdir: str = "remote"
tenants_subdir: str = "tenants"
private_subdir: str = "private"


@dataclass
@dataclass(frozen=True)
class RdfStoreConfig:
path: str


@dataclass
@dataclass(frozen=True)
class StoreDriverConfigs:
rdf: RdfStoreConfig | None = None
filesystem: FileStoreConfig | None = None


@dataclass(frozen=True)
class ValidationConfig:
root_schema: str = "schema:activities"
schema_dirs: list[str] = field(default_factory=list)
package_names: list[str] = field(default_factory=list)


@dataclass
class ServerConfig:
tenants: list[str]
store: StoreDriverConfigs
validation: ValidationConfig = ValidationConfig()

def is_local(self, uri: str) -> bool:
return any(uri.startswith(tenant) for tenant in self.tenants)


def load_config(config_in: typing.IO | str) -> ServerConfig:
Expand Down
73 changes: 51 additions & 22 deletions firm_server/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,31 @@

import httpx
import mimeparse
from firm.auth.authorization import CoreAuthorizationService
from firm.auth.bearer_token import BearerTokenAuthenticator
from firm.auth.chained import AuthenticatorChain
from firm.auth.http_signature import HttpSigAuthenticator, HttpSignatureAuth
from firm.interfaces import (
FIRM_NS,
DeliveryService,
HttpException,
HttpRequest,
HttpResponse,
JSONObject,
JsonResponse,
PlainTextResponse,
ResourceStore,
Validator,
)
from firm.services.activitypub import ActivityPubService, ActivityPubTenant
from firm.services.nodeinfo import nodeinfo_index, nodeinfo_version
from firm.services.webfinger import webfinger
from firm.util import AP_PUBLIC_URIS, AS2_CONTENT_TYPES
from firm_jsonschema.validation import create_validator
from firm_ld.search import IndexedResource, SearchEngine
from firm_ld.sparql import create_sparql_endpoint
from firm_ld.store import RdfResourceStore
from jsonschema.exceptions import ValidationError
from starlette.exceptions import HTTPException
from starlette.middleware import Middleware
from starlette.middleware.authentication import AuthenticationMiddleware
Expand Down Expand Up @@ -77,18 +83,15 @@ def is_local(prefix: str, uri: str):


def is_public(uri: str):
return uri in [
"https://www.w3.org/ns/activitystreams#Public",
"as:Public",
"Public",
]
return uri in AP_PUBLIC_URIS


# TODO Reconsider design of FirmDeliveryService (abstract class?)
class FirmDeliveryService:
class FirmDeliveryService(DeliveryService):
_RECIPIENT_PROPS = ["to", "cc", "bto", "bcc"]

def __init__(self, store: ResourceStore):
def __init__(self, config: ServerConfig, store: ResourceStore):
self._config = config
self._store = store

async def _resolve_inboxes(self, recipient_uris: Iterable[str]) -> set[str]:
Expand Down Expand Up @@ -174,9 +177,18 @@ async def deliver(self, activity: JSONObject) -> None:
elif isinstance(r, list):
recipient_uris.update(r)
inboxes = await self._resolve_inboxes(recipient_uris)
message = await self._serialize(activity)
for inbox in inboxes:
await self._post(inbox, message=message, auth=auth)
message = None
for inbox_uri in inboxes:
if self._config.is_local(inbox_uri):
inbox = await self._store.get(inbox_uri)
items = inbox.get("orderedItems", [])
items.insert(0, activity["id"])
inbox["orderedItems"] = items
await self._store.put(inbox)
else:
if message is None:
message = await self._serialize(activity)
await self._post(inbox_uri, message=message, auth=auth)


class MimeTypeRoute(Route):
Expand All @@ -192,14 +204,16 @@ def _get_header(scope: Scope, name: bytes) -> str | None:
return ""

def _matches_mimetype(self, scope: Scope) -> bool:
if accepted_types := self._get_header(scope, b"accept"):
return self._mimetypes is None or mimeparse.best_match(
self._mimetypes, accepted_types
)
if scope["method"] in ["GET", "HEAD"]:
if accepted_types := self._get_header(scope, b"accept"):
return self._mimetypes is None or mimeparse.best_match(
self._mimetypes, accepted_types
)
raise HTTPException(400, "No accept header")
elif content_type := self._get_header(scope, b"content-type"):
return self._mimetypes is None or content_type in self._mimetypes
else:
raise HTTPException(400, "No accept or content-type header")
raise HTTPException(400, "No content-type header")

def matches(self, scope: Scope) -> tuple[Match, Scope]:
return (
Expand Down Expand Up @@ -251,24 +265,39 @@ def _search(request: HttpRequest) -> HttpResponse:
return _search


class JsonSchemaValidator(Validator):
def __init__(self, config: ServerConfig):
self._validator = create_validator(
root_schema=config.validation.root_schema,
schema_dirs=config.validation.schema_dirs,
package_names=["firm_server.schemas"] + config.validation.package_names,
)

def validate(self, obj: JSONObject) -> None:
try:
self._validator.validate(obj)
except ValidationError as e:
raise HttpException(400, e.message)


def get_routes(store: ResourceStore, config: ServerConfig):
validator = JsonSchemaValidator(config)
activitypub_service = ActivityPubService(
[
ActivityPubTenant(
prefix,
store,
FirmDeliveryService(store),
prefix=prefix,
store=store,
authorizer=CoreAuthorizationService(prefix, store),
delivery_service=FirmDeliveryService(config, store),
validator=validator,
)
for prefix in config.tenants
]
)
activitypub_route = MimeTypeRoute(
"/{path:path}",
endpoint=_adapt_endpoint(activitypub_service.process_request, store),
mimetypes=[
"application/activity+json",
'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
],
mimetypes=AS2_CONTENT_TYPES,
methods=["GET", "POST"],
middleware=[
Middleware(
Expand Down
36 changes: 36 additions & 0 deletions firm_server/schemas/activities-schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "schema:activities",
"anyOf": [
{
"$ref": "schema:create"
},
{
"properties": {
"type": {
"anyOf": [
{
"type": "string",
"enum": [
"Follow",
"Reject",
"Accept",
"Undo"
]
},
{
"type": "string",
"format": "uri",
"description": "A URI identifying the type of activity"
},
{
"type": "string",
"pattern": ".*:.*",
"description": "A prefixed activity type name"
}
]
}
}
}
]
}
40 changes: 40 additions & 0 deletions firm_server/schemas/actor-schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "schema:actor",
"$ref": "schema:object",
"description": "An Actor. https://www.w3.org/TR/activitystreams-core/#actors",
"type": "object",
"properties": {
"id": {
"type": "string",
"format": "uri"
},
"type": {
"oneOf": [
{
"const": "Person"
},
{
"const": "Group"
},
{
"const": "Service"
}
]
},
"inbox": {
"type": "string",
"format": "uri"
},
"outbox": {
"type": "string",
"format": "uri"
}
},
"required": [
"id",
"type",
"inbox",
"outbox"
]
}
12 changes: 12 additions & 0 deletions firm_server/schemas/base-activity-schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "schema:activity",
"$ref": "schema:object",
"description": "An Activity. https://www.w3.org/TR/activitystreams-core/#activities",
"type": "object",
"properties": {
"actor": {
"$ref": "schema:object#/definitions/UriOrListOfUris"
}
}
}
47 changes: 47 additions & 0 deletions firm_server/schemas/create-schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "schema:create",
"description": "An Activity. https://www.w3.org/TR/activitystreams-core/#activities",
"type": "object",
"allOf": [
{
"$ref": "schema:base-activity"
},
{
"properties": {
"type": {
"anyOf": [
{
"const": "Create"
},
{
"type": "array",
"items": {
"type": "string"
},
"contains": {
"const": "Create"
}
}
]
},
"object": {
"anyOf": [
{
"$ref": "schema:object"
},
{
"type": "array",
"items": {
"$ref": "schema:object"
}
}
]
}
}
}
],
"required": [
"object"
]
}
56 changes: 56 additions & 0 deletions firm_server/schemas/object-schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "schema:object",
"description": "An Object. Restriction of https://www.w3.org/TR/activitystreams-core/#asobject",
"type": "object",
"definitions": {
"UriOrObject": {
"anyOf": [
{
"type": "string",
"format": "uri"
},
{
"type": "object"
}
]
},
"UriOrListOfUris": {
"anyOf": [
{
"type": "string",
"format": "uri"
},
{
"type": "array",
"items": {
"type": "string",
"format": "uri"
}
}
]
}
},
"properties": {
"id": {
"type": "string",
"format": "uri"
},
"type": {
"anyOf": [
{
"type": "string"
},
{
"type": "array",
"items": {
"type": "string"
}
}
]
}
},
"required": [
"type"
]
}
Loading

0 comments on commit f7ff705

Please sign in to comment.