This commit is contained in:
Izalia Mae 2021-11-22 05:52:34 -05:00
parent b798d5504d
commit 7a9d98b844
7 changed files with 340 additions and 127 deletions

View file

@ -25,6 +25,10 @@ object_types = [
'Profile', 'Relationship', 'Tombstone', 'Video'
]
url_keys = [
'attributedTo', 'url', 'href', 'object', 'id', 'actor', 'partOf', 'target'
]
def parse_privacy_level(to: list=[], cc: list=[]):
if to == [pubstr] and len(cc) == 1:
@ -48,58 +52,46 @@ def generate_privacy_fields(privacy='public'):
class Object(DotDict):
@property
def privacy_level(self):
return parse_privacy_level(self.get('to', []), self.get('cc', []))
def __setitem__(self, key, value):
if type(key) == str and key in url_keys:
value = Url(value)
elif key == 'object' and isinstance(key, dict):
value = Object(value)
super().__setitem__(key, value)
@property
def shared_inbox(self):
try: return self.endpoints.shared_inbox
except KeyError: pass
@property
def pubkey(self):
try: return self.publicKey.publicKeyPem
except KeyError: pass
@property
def handle(self):
return self.get('preferredUsername')
@property
def display_name(self):
return self.get('name')
@classmethod
def new_activity(cls, *args, **kwargs):
return Activity.new(*args, **kwargs)
@classmethod
def new_note(cls, id, url, actor, content, **kwargs):
assert False not in map(isinstance, [id, actor, url], [Url])
date = kwargs.get('date', DateString.now('activitypub'))
if kwargs.get('date'):
date = DateString.from_datetime(kwargs['date'], 'activitypub')
else:
date = DateString.now('activitypub')
return cls({
"@context": [
"https://www.w3.org/ns/activitystreams",
{
"ostatus": "http://ostatus.org#",
"atomUri": "ostatus:atomUri",
"inReplyToAtomUri": "ostatus:inReplyToAtomUri",
"conversation": "ostatus:conversation",
"sensitive": "as:sensitive",
"toot": "http://joinmastodon.org/ns#",
"votersCount": "toot:votersCount",
"litepub": "http://litepub.social/ns#",
"directMessage": "litepub:directMessage"
#"votersCount": "toot:votersCount",
#"litepub": "http://litepub.social/ns#",
#"directMessage": "litepub:directMessage"
}
],
"id": id,
"type": "Note",
"summary": kwargs.get('summary'),
"inReplyTo": kwargs.get('replyto'),
#"inReplyTo": kwargs.get('replyto'),
"published": date,
"url": url,
"attributedTo": actor,
@ -110,31 +102,130 @@ class Object(DotDict):
f'{actor}/followers'
],
"sensitive": kwargs.get('sensitive', False),
"atomUri": id,
"inReplyToAtomUri": kwargs.get('replyto_id'),
"conversation": f'tag:{actor.host},{date.dump_to_string("activitypub-date")}:objectId=490:objectType=Conversation',
"content": content,
"contentMap": {
"en": content
},
"attachment": [],
"tag": [],
"replies": {
"id": f"{id}/replies",
"type": "Collection",
"first": {
"type": "CollectionPage",
"next": f"{id}/replies?only_other_accounts=true&page=true",
"partOf": f"{id}/replies",
"items": []
"content": f'{content}',
#"contentMap": {
#"en": content
#},
#"attachment": [],
#"tag": [],
#"replies": {
#"id": f"{id}/replies",
#"type": "Collection",
#"first": {
#"type": "CollectionPage",
#"next": f"{id}/replies?only_other_accounts=true&page=true",
#"partOf": f"{id}/replies",
#"items": []
#}
#}
})
@classmethod
def new_actor(cls, actor, handle, pubkey, published=None, table={}, full=True, **kwargs):
actor_type = kwargs.get('type', 'Person').title()
assert actor_type in actor_types
actor = Url(actor)
data = cls({
'@context': [
'https://www.w3.org/ns/activitystreams',
'https://w3id.org/security/v1',
{
'schema': 'http://schema.org',
'toot': 'https://joinmastodon.org/ns#',
'manuallyApprovesFollowers': 'as:manuallyApprovesFollowers',
'PropertyValue': 'schema:PropertyValue',
'value': 'schema:value',
#'IdentityProof': 'toot:IdentityProof',
'discoverable': 'toot:discoverable',
#'Device': 'toot:Device',
#'Ed25519Signature': 'toot:Ed25519Signature',
#'Ed25519Key': 'toot:Ed25519Key',
#'Curve25519Key': 'toot:Curve25519Key',
#'EncryptedMessage': 'toot:EncryptedMessage',
#'publicKeyBase64': 'toot:publicKeyBase64',
#'deviceId': 'toot:deviceId',
#'messageFranking': 'toot:messageFranking',
'messageType': 'toot:messageType',
#'cipherText': 'toot:cipherText',
#'suspended': 'toot:suspended',
'Emoji': 'toot:Emoji',
"alsoKnownAs": {
"@id": "as:alsoKnownAs",
"@type": "@id"
},
"movedTo": {
"@id": "as:movedTo",
"@type": "@id"
},
"claim": {
"@type": "@id",
"@id": "toot:claim"
},
"fingerprintKey": {
"@type": "@id",
"@id": "toot:fingerprintKey"
},
"focalPoint": {
"@container": "@list",
"@id": "toot:focalPoint"
}
}
],
'id': actor,
'type': actor_type,
'inbox': kwargs.get('inbox', f'{actor}'),
'preferredUsername': handle,
'name': kwargs.get('display_name', handle),
'summary': kwargs.get('bio'),
'url': kwargs.get('url', actor),
'manuallyApprovesFollowers': kwargs.get('locked', False),
'discoverable': kwargs.get('discoverable', False),
'published': published or DateString.now('activitypub'),
'publicKey': {
'id': f'{actor}#main-key',
'owner': actor,
'publicKeyPem': pubkey
},
'tag': [],
'attachment': [],
'endpoints': {
'sharedInbox': kwargs.get('shared_inbox', f'https://{actor.host}/inbox')
}
})
for key, value in table.items():
data.attachment.append(PropertyValue(key, value))
if kwargs.get('avatar_url'):
data.icon = Object.new_image(kwargs.get('avatar_url'), kwargs.get('avatar_type'))
if full:
data.featured = f'{actor}/collections/featured'
data.tags = f'{actor}/collections/tags'
data.following = f'{actor}/following'
data.followers = f'{actor}/followers'
data.outbox = f'{actor}/outbox'
data['@context'][2].update({
"featured": {
"@id": "toot:featured",
"@type": "@id"
},
"featuredTags": {
"@id": "toot:featuredTags",
"@type": "@id"
}
})
return data
# not complete
@classmethod
def new_actor(cls, actor, handle, pubkey, published=None, table={}, full=True, **kwargs):
def new_actor_old(cls, actor, handle, pubkey, published=None, table={}, full=True, **kwargs):
actor_type = kwargs.get('type', 'Person').title()
assert actor_type in actor_types
@ -249,17 +340,13 @@ class Object(DotDict):
@classmethod
def new_activity(cls, id: str, type: str, actor_src: Union[str, dict], object: Union[str, dict], to: list=[pubstr], cc: list=[]):
assert type in activity_types
cls({
def new_follow(cls, id, actor, target):
return cls({
'@context': 'https://www.w3.org/ns/activitystreams',
'type': type,
'to': to,
'cc': cc,
'object': object,
'id': id,
'actor': actor_src
'type': 'Follow',
'actor': actor,
'object': target
})
@ -274,6 +361,100 @@ class Object(DotDict):
})
@property
def privacy_level(self):
return parse_privacy_level(self.get('to', []), self.get('cc', []))
@property
def shared_inbox(self):
try: return self.endpoints.shared_inbox
except KeyError: pass
@property
def pubkey(self):
try: return self.publicKey.publicKeyPem
except KeyError: pass
@property
def handle(self):
return self.get('preferredUsername')
@property
def display_name(self):
return self.get('name')
@property
def type(self):
return self['type'].capitalize()
class Activity(Object):
@classmethod
def new(cls, id: str, type: str, actor_src: Union[str, dict], object: Union[str, dict], to: list=[pubstr], cc: list=[]):
assert type in activity_types
activity = cls({
'@context': 'https://www.w3.org/ns/activitystreams',
'id': id,
'object': object,
'type': type,
'actor': actor_src
})
if to:
activity.to = to
if cc:
activity.cc = cc
return activity
class Collection(Object):
@classmethod
def new_replies(cls, id):
{
"id": f"{id}/replies",
"type": "Collection",
"first": {
"type": "CollectionPage",
"next": f"{id}/replies?only_other_accounts=true&page=true",
"partOf": f"{id}/replies",
"items": []
}
}
### sub-objects ###
class PropertyValue(DotDict):
def __init__(self, key, value):
super().__init__({
'type': 'PropertyValue',
'name': key,
'value': value
})
def __setitem__(self, key, value):
key = key.lower()
assert key in ['type', 'name', 'value']
assert type(value) == str
super().__setitem__(key, value)
def set_pair(self, key, value):
self.name = key
self.value = value
class Media(Object):
@classmethod
def new(cls, type, url, mime=None):
@ -299,64 +480,18 @@ class Media(Object):
return cls.new('Audio', url, mime)
class Activity(DotDict):
@property
def privacy_level(self):
return parse_privacy_level(self.get('to', []), self.get('cc', []))
class Emoji(DotDict):
@classmethod
def new_activity(cls, id: str, type: str, actor_src: Union[str, dict], object: Union[str, dict], to: list=[pubstr], cc: list=[]):
assert type in activity_types
cls({
'@context': 'https://www.w3.org/ns/activitystreams',
def new(cls, id, name, image):
return cls({
'id': id,
'object': object,
'type': type,
'to': to,
'cc': cc,
'actor': actor_src
'type': Emoji,
'name': f':{name}:',
'icon': image
})
class Collection(Object):
@classmethod
def new_replies(cls, id):
{
"id": f"{id}/replies",
"type": "Collection",
"first": {
"type": "CollectionPage",
"next": f"{id}/replies?only_other_accounts=true&page=true",
"partOf": f"{id}/replies",
"items": []
}
}
class PropertyValue(DotDict):
def __init__(self, key, value):
super().__init__({
'type': 'PropertyValue',
'name': key,
'value': value
})
def __setitem__(self, key, value):
key = key.lower()
assert key in ['type', 'name', 'value']
assert type(value) == str
super().__setitem__(key, value)
def set_pair(self, key, value):
self.name = key
self.value = value
### Not activitypub objects, but related ###
class Nodeinfo(DotDict):
@property

View file

@ -1,12 +1,9 @@
http_methods = ['CONNECT', 'DELETE', 'GET', 'HEAD', 'OPTIONS', 'PATCH', 'POST', 'PUT', 'TRACE']
applications = {}
def get_app(name='default'):
try:
return applications.get(name)
except KeyError:
return set_app(Application(appname=name))
def get_app(name='default'):
return applications[name]
def set_app(app):

View file

@ -33,6 +33,7 @@ class ApplicationBase:
self.db = None
self.router = Router(trim_last_slash=True)
self.middleware = DotDict({'request': [], 'response': []})
self.routes = {}
for view in views:
self.add_view(view)
@ -61,6 +62,19 @@ class ApplicationBase:
def add_route(self, handler, path, method='GET'):
self.router.bind(handler, path, methods=[method.upper()])
self.routes[f'{method.upper()}:{path}'] = handler
def compare_routes(self, route, path, method='GET'):
try:
return self.get_route(path, method) == self.routes[f'{method.upper()}:{route}']
except:
return False
async def run_handler(self, request, response, path, method=None, **kwargs):
handler = self.get_route(path, method or request.method)
return await handler.target(request, response, **kwargs)
def add_view(self, view):
@ -115,9 +129,10 @@ class ApplicationBase:
async def handle_request(self, request, response, path=None):
if request.host not in self.cfg.hosts and not request.path.startswith('/framework'):
raise error.NotFound(f'Host not handled on this server: {request.host}')
raise error.BadRequest(f'Host not handled on this server: {request.host}')
handler = self.get_route(path or request.path, request.method)
request._params = handler.params
await self.handle_middleware(request)

View file

@ -82,10 +82,10 @@ class Config(BaseConfig):
elif key == 'tpl_context' and not getattr(value, '__call__', None):
raise TypeError(f'{key} must be a callable')
elif key == 'request_class' and not isinstance(value, Request):
elif key == 'request_class' and not issubclass(value, Request):
raise TypeError(f'{key} must be a subclass of izzylib.http_server_async.Request')
elif key == 'response_class' and not isinstance(value, Response):
elif key == 'response_class' and not issubclass(value, Response):
raise TypeError(f'{key} must be a subclass of izzylib.http_server_async.Response')
return value

View file

@ -17,8 +17,8 @@ LocalTime = datetime.now(UtcTime).astimezone().tzinfo
class Request:
__slots__ = [
'_body', '_form', '_reader', '_method', '_app', 'address',
'path', 'version', 'headers', 'cookies',
'_body', '_form', '_reader', '_method', '_app', '_params',
'address', 'path', 'version', 'headers', 'cookies',
'query', 'raw_query'
]
@ -32,6 +32,7 @@ class Request:
self._body = b''
self._form = DotDict()
self._method = None
self._params = None
self.headers = Headers()
self.cookies = Cookies()
@ -41,6 +42,7 @@ class Request:
self.path = None
self.version = None
self.raw_query = None
self.log = True
def __getitem__(self, key):
@ -84,6 +86,11 @@ class Request:
return self.headers.getone('User-Agent', 'no agent')
@property
def accept(self):
return self.headers.getone('Accept', '')
@property
def content_type(self):
return self.headers.getone('Content-Type', '')
@ -127,6 +134,11 @@ class Request:
self._method = data.upper()
@property
def params(self):
return self._params
async def read(self, length=2048, timeout=None):
try: return await asyncio.wait_for(self._reader.read(length), timeout or self.app.cfg.timeout)
except: return
@ -205,9 +217,9 @@ class Request:
raise ImportError('Failed to import verify_headers from izzylib.http_signatures.')
return verify_headers(
headers = {k: self.headers.getone(k) for k in request.headers.keys()},
headers = {k: self.headers.getone(k) for k in self.headers.keys()},
method = self.method,
path = self.path,
actor = actor,
body = await self.body
body = await self.body()
)

View file

@ -34,10 +34,10 @@ def parse_signature(signature: str):
key, value = part.split('=', 1)
sig[key.lower()] = value.replace('"', '')
sig.actor = Url(sig.keyid.split('#')[0])
sig.headers = sig.headers.split()
sig.domain = Url(sig.keyid).host
sig.domain = sig.actor.host
sig.top_domain = '.'.join(extract(sig.domain)[1:])
sig.actor = sig.keyid.split('#')[0]
return sig
@ -55,7 +55,7 @@ def verify_headers(headers: dict, method: str, path: str, actor: dict, body=None
headers = {k.lower(): headers[k] for k in headers}
headers['(request-target)'] = f'{method.lower()} {path}'
signature = parse_signature(headers.get('signature'))
signature = Signature(headers.get('signature'))
digest = headers.get('digest')
missing_headers = [k for k in headers if k in ['date', 'host'] if headers.get(k) == None]
@ -181,3 +181,54 @@ def verify_string(string, enc_string, alg='SHA256', fail=False):
else:
return False
class Signature(str):
__parts = {}
def __init__(self, signature: str):
if not signature:
raise AssertionError('Missing signature header')
split_sig = signature.split(',')
for part in split_sig:
key, value = part.split('=', 1)
value = value.replace('"', '')
self.__parts[key.lower()] = value.split() if key == 'headers' else value
def __new__(cls, signature: str):
return str.__new__(cls, signature)
def __new2__(cls, signature: str):
data = str.__new__(cls, signature)
data.__init__(signature)
return
def __getattr__(self, key):
return self.__parts[key]
@property
def sig(self):
return self.__parts['signature']
@property
def actor(self):
return Url(self.keyid.split('#')[0])
@property
def domain(self):
return self.actor.host
@property
def top_domain(self):
return '.'.join(extract(self.domain)[1:])

View file

@ -667,6 +667,9 @@ class Url(str):
def __init__(self, url):
parsed = urlparse(url)
if not all([parsed.scheme, parsed.netloc]):
raise ValueError('Not a valid url')
self.proto = parsed.scheme
self.host = parsed.netloc
self.port = self.protocols.get(self.proto) if not parsed.port else None