From 7a9d98b844c444e214e0954e1cf79a074d0b7ba5 Mon Sep 17 00:00:00 2001 From: Izalia Mae Date: Mon, 22 Nov 2021 05:52:34 -0500 Subject: [PATCH] a --- izzylib/activitypub.py | 359 ++++++++++++++++------- izzylib/http_server_async/__init__.py | 7 +- izzylib/http_server_async/application.py | 17 +- izzylib/http_server_async/config.py | 4 +- izzylib/http_server_async/request.py | 20 +- izzylib/http_signatures.py | 57 +++- izzylib/misc.py | 3 + 7 files changed, 340 insertions(+), 127 deletions(-) diff --git a/izzylib/activitypub.py b/izzylib/activitypub.py index 07573f4..d184b08 100644 --- a/izzylib/activitypub.py +++ b/izzylib/activitypub.py @@ -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 diff --git a/izzylib/http_server_async/__init__.py b/izzylib/http_server_async/__init__.py index 185e1ce..72d8453 100644 --- a/izzylib/http_server_async/__init__.py +++ b/izzylib/http_server_async/__init__.py @@ -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): diff --git a/izzylib/http_server_async/application.py b/izzylib/http_server_async/application.py index 585d09c..f7f0cc2 100644 --- a/izzylib/http_server_async/application.py +++ b/izzylib/http_server_async/application.py @@ -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) diff --git a/izzylib/http_server_async/config.py b/izzylib/http_server_async/config.py index 01969f1..a2874b2 100644 --- a/izzylib/http_server_async/config.py +++ b/izzylib/http_server_async/config.py @@ -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 diff --git a/izzylib/http_server_async/request.py b/izzylib/http_server_async/request.py index 3e5201f..80a2df8 100644 --- a/izzylib/http_server_async/request.py +++ b/izzylib/http_server_async/request.py @@ -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() ) diff --git a/izzylib/http_signatures.py b/izzylib/http_signatures.py index 4a16a95..fa5f668 100644 --- a/izzylib/http_signatures.py +++ b/izzylib/http_signatures.py @@ -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:]) diff --git a/izzylib/misc.py b/izzylib/misc.py index 651b733..e3f1edf 100644 --- a/izzylib/misc.py +++ b/izzylib/misc.py @@ -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