a
This commit is contained in:
parent
b798d5504d
commit
7a9d98b844
|
@ -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
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
)
|
||||
|
|
|
@ -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:])
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue