[WIP] Start a new ActivityPub module
This commit is contained in:
parent
070e39bdfe
commit
d4bf73756f
6 changed files with 1239 additions and 0 deletions
0
little_boxes/__init__.py
Normal file
0
little_boxes/__init__.py
Normal file
1042
little_boxes/activitypub.py
Normal file
1042
little_boxes/activitypub.py
Normal file
File diff suppressed because it is too large
Load diff
49
little_boxes/errors.py
Normal file
49
little_boxes/errors.py
Normal file
|
@ -0,0 +1,49 @@
|
|||
"""Errors raised by this package."""
|
||||
from typing import Optional
|
||||
from typing import Dict
|
||||
from typing import Any
|
||||
|
||||
|
||||
class Error(Exception):
|
||||
"""HTTP-friendly base error, with a status code, a message and an optional payload."""
|
||||
status_code = 400
|
||||
|
||||
def __init__(self, message: str, status_code: Optional[int] = None, payload: Optional[Dict[str, Any]] = None) -> None:
|
||||
Exception.__init__(self)
|
||||
self.message = message
|
||||
if status_code is not None:
|
||||
self.status_code = status_code
|
||||
self.payload = payload
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
rv = dict(self.payload or ())
|
||||
rv['message'] = self.message
|
||||
return rv
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f'{self.__class__.__qualname__}({self.message!r}, payload={self.payload!r}, status_code={self.status_code})'
|
||||
|
||||
|
||||
class ActorBlockedError(Error):
|
||||
"""Raised when an activity from a blocked actor is received."""
|
||||
|
||||
|
||||
class NotFromOutboxError(Error):
|
||||
"""Raised when an activity targets an object from the inbox when an object from the oubox was expected."""
|
||||
|
||||
|
||||
class ActivityNotFoundError(Error):
|
||||
"""Raised when an activity is not found."""
|
||||
status_code = 404
|
||||
|
||||
|
||||
class BadActivityError(Error):
|
||||
"""Raised when an activity could not be parsed/initialized."""
|
||||
|
||||
|
||||
class RecursionLimitExceededError(BadActivityError):
|
||||
"""Raised when the recursion limit for fetching remote object was exceeded (likely a collection)."""
|
||||
|
||||
|
||||
class UnexpectedActivityTypeError(BadActivityError):
|
||||
"""Raised when an another activty was expected."""
|
39
little_boxes/remote_object.py
Normal file
39
little_boxes/remote_object.py
Normal file
|
@ -0,0 +1,39 @@
|
|||
import logging
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
from urllib.parse import urlparse
|
||||
from Crypto.PublicKey import RSA
|
||||
|
||||
from .urlutils import check_url
|
||||
from .errors import ActivityNotFoundError
|
||||
from .errors import UnexpectedActivityTypeError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DefaultRemoteObjectFetcher(object):
|
||||
"""Not meant to be used on production, a caching layer, and DB shortcut fox inbox/outbox should be hooked."""
|
||||
|
||||
def __init__(self):
|
||||
self._user_agent = 'Little Boxes (+https://github.com/tsileo/little_boxes)'
|
||||
|
||||
def fetch(self, iri):
|
||||
check_url(iri)
|
||||
|
||||
resp = requests.get(actor_url, headers={
|
||||
'Accept': 'application/activity+json',
|
||||
'User-Agent': self._user_agent,
|
||||
})
|
||||
|
||||
if resp.status_code == 404:
|
||||
raise ActivityNotFoundError(f'{actor_url} cannot be fetched, 404 not found error')
|
||||
|
||||
resp.raise_for_status()
|
||||
|
||||
return resp.json()
|
||||
|
||||
OBJECT_FETCHER = DefaultRemoteObjectFetcher()
|
||||
|
||||
def set_object_fetcher(object_fetcher: Any):
|
||||
OBJECT_FETCHER = object_fetcher
|
47
little_boxes/urlutils.py
Normal file
47
little_boxes/urlutils.py
Normal file
|
@ -0,0 +1,47 @@
|
|||
import logging
|
||||
import os
|
||||
import socket
|
||||
import ipaddress
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from . import strtobool
|
||||
from .errors import Error
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class InvalidURLError(Error):
|
||||
pass
|
||||
|
||||
|
||||
def is_url_valid(url: str) -> bool:
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ['http', 'https']:
|
||||
return False
|
||||
|
||||
# XXX in debug mode, we want to allow requests to localhost to test the federation with local instances
|
||||
debug_mode = strtobool(os.getenv('MICROBLOGPUB_DEBUG', 'false'))
|
||||
if debug_mode:
|
||||
return True
|
||||
|
||||
if parsed.hostname in ['localhost']:
|
||||
return False
|
||||
|
||||
try:
|
||||
ip_address = socket.getaddrinfo(parsed.hostname, parsed.port or 80)[0][4][0]
|
||||
except socket.gaierror:
|
||||
logger.exception(f'failed to lookup url {url}')
|
||||
return False
|
||||
|
||||
if ipaddress.ip_address(ip_address).is_private:
|
||||
logger.info(f'rejecting private URL {url}')
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def check_url(url: str) -> None:
|
||||
if not is_url_valid(url):
|
||||
raise InvalidURLError(f'"{url}" is invalid')
|
||||
|
||||
return None
|
62
little_boxes/utils.py
Normal file
62
little_boxes/utils.py
Normal file
|
@ -0,0 +1,62 @@
|
|||
"""Contains some ActivityPub related utils."""
|
||||
from typing import Optional
|
||||
from typing import Dict
|
||||
from typing import List
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
from .errors import RecursionLimitExceededError
|
||||
from .errors import UnexpectedActivityTypeError
|
||||
from .remote_object import OBJECT_FETCHER
|
||||
|
||||
|
||||
def parse_collection(
|
||||
payload: Optional[Dict[str, Any]] = None,
|
||||
url: Optional[str] = None,
|
||||
level: int = 0,
|
||||
) -> List[Any]:
|
||||
"""Resolve/fetch a `Collection`/`OrderedCollection`."""
|
||||
if level > 3:
|
||||
raise RecursionLimitExceededError('recursion limit exceeded')
|
||||
|
||||
# Go through all the pages
|
||||
headers = {'Accept': 'application/activity+json'}
|
||||
if user_agent:
|
||||
headers['User-Agent'] = user_agent
|
||||
|
||||
out: List[Any] = []
|
||||
if url:
|
||||
payload = OBJECT_FETCHER.fetch(url)
|
||||
if not payload:
|
||||
raise ValueError('must at least prove a payload or an URL')
|
||||
|
||||
if payload['type'] in ['Collection', 'OrderedCollection']:
|
||||
if 'orderedItems' in payload:
|
||||
return payload['orderedItems']
|
||||
if 'items' in payload:
|
||||
return payload['items']
|
||||
if 'first' in payload:
|
||||
if 'orderedItems' in payload['first']:
|
||||
out.extend(payload['first']['orderedItems'])
|
||||
if 'items' in payload['first']:
|
||||
out.extend(payload['first']['items'])
|
||||
n = payload['first'].get('next')
|
||||
if n:
|
||||
out.extend(parse_collection(url=n, level=level+1))
|
||||
return out
|
||||
|
||||
while payload:
|
||||
if payload['type'] in ['CollectionPage', 'OrderedCollectionPage']:
|
||||
if 'orderedItems' in payload:
|
||||
out.extend(payload['orderedItems'])
|
||||
if 'items' in payload:
|
||||
out.extend(payload['items'])
|
||||
n = payload.get('next')
|
||||
if n is None:
|
||||
break
|
||||
payload = OBJECT_FETCHER.fetch(n)
|
||||
else:
|
||||
raise UnexpectedActivityTypeError('unexpected activity type {}'.format(payload['type']))
|
||||
|
||||
return out
|
Loading…
Reference in a new issue