finish basic relay functions

This commit is contained in:
Izalia Mae 2021-09-15 07:27:18 -04:00
parent 6ea8a4bcd9
commit 8bb5d7c2cf
13 changed files with 348 additions and 123 deletions

View file

@ -28,7 +28,7 @@ setupdev:
update-deps:
git reset HEAD --hard
git pull
$(PYTHON) -m pip install -r requirements.txt
$(PYTHON) -m pip install -U -r requirements.txt
run:
$(PYTHON) -m uncia

View file

@ -6,8 +6,7 @@ from izzylib import DotDict, Path, boolean, izzylog, logging
from os import environ as env
izzylog.set_config('level', 'VERBOSE')
logging.set_config('level', 'DEBUG')
logging.set_config('level', env.get('LOG_LEVEL', 'INFO'))
scriptpath = Path(__file__).resolve.parent

View file

@ -49,13 +49,19 @@ tables = {
],
'whitelist': [
Column('id'),
Column('actor', 'text', nullable=False, unique=True)
Column('domain', 'text', nullable=False, unique=True)
],
'ban': [
Column('id'),
Column('handle', 'text'),
Column('domain', 'text'),
Column('reason', 'text')
],
'actor_cache': [
Column('id'),
Column('url', 'text', nullable=False, unique=True),
Column('data', 'json', nullable=False),
Column('timestamp')
]
}

View file

@ -1,8 +1,8 @@
from izzylib import logging
def cmd_inbox(self, data):
instance = self.get.inbox(data)
def cmd_instance(self, data):
instance = self.get.instance(data)
if not instance:
logging.debug(f'db.get.inbox: instance does not exist: {data}')

View file

@ -1,4 +1,19 @@
from izzylib import DotDict
from izzylib import DotDict, logging
def cmd_actor(self, url):
cache = self.cache.actor_cache.fetch(url)
if cache:
return cache
row = self.fetch('actor_cache', url=url)
if not row:
return
self.cache.actor_cache.store(url, row)
return row
def cmd_ban_list(self, types='domain'):
@ -61,15 +76,17 @@ def cmd_config_all(self):
return data
def cmd_inbox(self, data):
for line in ['domain', 'inbox', 'actor']:
row = self.fetch('inbox', **{line: data})
def cmd_instance(self, data):
for field in ['domain', 'inbox', 'actor']:
row = self.fetch('inbox', **{field: data})
if row:
return row
break
return row
def cmd_inbox_list(self, value=None):
def cmd_instance_list(self, value=None):
data = []
if value not in [None, 'domain', 'inbox', 'actor']:
@ -86,11 +103,11 @@ def cmd_inbox_list(self, value=None):
return data
def cmd_instance(self, data):
for field in ['domain', 'inbox', 'actor']:
row = self.fetch('inbox', **{field: data})
def cmd_inbox(*args, **kwargs):
logging.warning('DeprecationWarning: session.get.inbox')
return cmd_instance(*args, **kwargs)
if row:
break
return row
def cmd_inbox_list(*args, **kwargs):
logging.warning('DeprecationWarning: session.get.inbox_list')
return cmd_instance_list(*args, **kwargs)

View file

@ -3,6 +3,29 @@ from izzylib import logging
from urllib.parse import urlparse
def cmd_actor(self, url, actor):
row = self.get.actor(url)
if row:
row = self.update(
row = row,
data = actor,
timestamp = datetime.now(),
return_row = True
)
else:
row = self.insert('actor_cache',
url = url,
data = actor,
timestamp = datetime.now(),
return_row = True
)
self.cache.actor_cache.store(url, row)
return row
def cmd_config(self, key, value):
row = self.fetch('config', key=key)

View file

@ -30,9 +30,29 @@ def cache_fetch(func):
return inner_func
@cache_fetch
def fetch_actor(url):
return client.json(url, activity=True).json
with db.session as s:
cached = s.get.actor(url)
if cached:
return cached.data
try:
response = client.json(url, activity=True)
data = response.json
except:
data = None
if response.status not in [200, 202]:
logging.verbose(f'Signing headers to fetch actor: {url}')
data = fetch_auth(url)
if not data:
return
s.put.actor(url, data)
return data
@cache_fetch
@ -45,7 +65,7 @@ def fetch_auth(url):
headers = {'accept': 'application/activity+json'}
)
return response.json()
return response.json
def get_inbox(actor):

View file

@ -4,6 +4,7 @@ from izzylib import logging
from . import __version__
from .database import db
from .messages import Message
exe = f'{sys.executable} -m uncia.manage'
@ -21,7 +22,10 @@ class Command:
args = arguments[1:] if len(arguments) > 1 else []
self.result = self[cmd](*args)
try:
self.result = self[cmd](*args)
except InvalidCommandError:
self.result = f'Not a valid command: {cmd}'
def __getitem__(self, key):
@ -67,15 +71,58 @@ python3 -m uncia.manage config [key] [value]:
return self.cmd_config(key, ' '.join(value))
def cmd_request(self, action=None, url=None):
with db.session as s:
if not action:
instances = []
for row in s.search('inbox'):
if row.followid:
instances.append(row.domain)
text = 'Awaiting Requests:\n'
text += '\n'.join([f'- {domain}' for domain in instances])
return text
else:
instance = s.get.instance(url)
if instance.followid:
if action == 'accept':
s.update(row=instance, followid=None)
text = f'Accepted {url}'
elif action == 'reject':
s.remove(row=instance)
text = f'Rejected {url}'
else:
return f'Not a valid request action: {action}'
Message('accept', instance.followid, instance.actor).send(instance.inbox)
return text
def cmd_accept(self, url):
cmd_request('accept', url)
def cmd_reject(self, url):
cmd_request('reject', url)
def cmd_remove(self, data):
with db.session as s:
if s.delete.inbox(data):
if s.delete.instance(data):
return f'Instance removed: {data}'
else:
return f'Instance does not exist: {data}'
class InvalidCommandError(Exception):
pass
if __name__ == '__main__':
args = sys.argv[1:] if len(sys.argv) > 1 else []
cmd = Command(args)

View file

@ -3,62 +3,92 @@ from urllib.parse import urlparse
from uuid import uuid4
from .config import config
from .functions import push_message
def accept(followid, instance):
message = DotDict({
'@context': 'https://www.w3.org/ns/activitystreams',
'type': 'Accept',
'to': [instance.actor],
'actor': f'https://{config.host}/actor',
'object': {
'type': 'Follow',
'id': followid,
'object': f'https://{config.host}/actor',
'actor': instance.actor
},
'id': f'https://{config.host}/activities/{str(uuid4())}',
})
return message
class Message:
def __init__(self, name, *args):
self.message = getattr(self, name)(*args)
def announce(object_id, inbox):
data = DotDict({
'@context': 'https://www.w3.org/ns/activitystreams',
'type': 'Announce',
'to': [f'https://{config.host}/followers'],
'actor': f'https://{config.host}/actor',
'object': object_id,
'id': f'https://{config.host}/activities/{str(uuid4())}'
})
return data
def send(self, inbox):
return push_message(inbox, self.message)
def note(user_handle, user_inbox, user_actor, actor, message):
actor_domain = urlparse(actor).netloc
user_domain = urlparse(user_inbox).netloc
data = DotDict({
"@context": "https://www.w3.org/ns/activitystreams",
"id": f"https://{config.host}/activities/{str(uuid4())}",
"type": "Create",
"actor": f"https://{config.host}/actor",
"object": {
def accept(self, followid, actor):
message = DotDict({
'@context': 'https://www.w3.org/ns/activitystreams',
'type': 'Accept',
'to': [actor],
'actor': f'https://{config.host}/actor',
'object': {
'type': 'Follow',
'id': followid,
'object': f'https://{config.host}/actor',
'actor': actor
},
'id': f'https://{config.host}/activities/{str(uuid4())}',
})
return message
def reject(self, followid, actor):
message = DotDict({
'@context': 'https://www.w3.org/ns/activitystreams',
'type': 'Reject',
'to': [actor],
'actor': f'https://{config.host}/actor',
'object': {
'type': 'Follow',
'id': followid,
'object': f'https://{config.host}/actor',
'actor': actor
},
'id': f'https://{config.host}/activities/{str(uuid4())}',
})
return message
def announce(self, object_id):
data = DotDict({
'@context': 'https://www.w3.org/ns/activitystreams',
'type': 'Announce',
'to': [f'https://{config.host}/followers'],
'actor': f'https://{config.host}/actor',
'object': object_id,
'id': f'https://{config.host}/activities/{str(uuid4())}'
})
return data
def note(self, user_handle, user_inbox, user_actor, actor, message):
actor_domain = urlparse(actor).netloc
user_domain = urlparse(user_inbox).netloc
data = DotDict({
"@context": "https://www.w3.org/ns/activitystreams",
"id": f"https://{config.host}/activities/{str(uuid4())}",
"type": "Note",
"published": ap_date(),
"attributedTo": f"https://{config.host}/actor",
"content": message,
'to': [user_inbox],
'tag': [{
'type': 'Mention',
'href': user_actor,
'name': f'@{user_handle}@{user_domain}'
}],
}
})
"type": "Create",
"actor": f"https://{config.host}/actor",
"object": {
"id": f"https://{config.host}/activities/{str(uuid4())}",
"type": "Note",
"published": ap_date(),
"attributedTo": f"https://{config.host}/actor",
"content": message,
'to': [user_inbox],
'tag': [{
'type': 'Mention',
'href': user_actor,
'name': f'@{user_handle}@{user_domain}'
}],
}
})
return data
return data

View file

@ -3,6 +3,7 @@ from izzylib.http_requests_client import parse_signature, verify_request
from izzylib.http_server import MiddlewareBase
from .database import db
from .functions import fetch_actor
auth_paths = [
@ -11,6 +12,25 @@ auth_paths = [
'/admin'
]
# Instances that just shouldn't exist and/or are known to harass others
blocked_instances = [
'kiwifarms.cc',
'neckbeard.xyz',
'gameliberty.club',
'shitposter.club',
'freespeechextremist.com',
'smuglo.li',
'yggdrasil.social',
'gleasonator.com',
'ligma.pro',
'fedi.absturztau.be',
'blob.cat',
'social.i2p.rocks',
'lolicon.rocks',
'pawoo.net',
'baraag.net'
]
class AuthCheck(MiddlewareBase):
attach = 'request'
@ -27,12 +47,16 @@ class AuthCheck(MiddlewareBase):
request.ctx.instance = None
if request.ctx.signature:
if any([s.get.ban(domain=request.ctx.signature.domain), s.get.ban(domain=request.ctx.signature.top_domain)]):
return response.text(f'BEGONE!', status=403)
if request.ctx.signature.top_domain in blocked_instances:
return response.text(f'This teapot kills fascists', status=418)
request.ctx.instance = s.get.inbox(request.ctx.signature.domain)
if any(map(s.get.ban, [None], [request.ctx.signature.domain, request.ctx.signature.top_domain])):
return response.text(f'no', status=403)
if request.path == '/inbox' and request.method.lower() == 'post':
request.ctx.instance = s.get.instance(request.ctx.signature.domain)
request.ctx.actor = fetch_actor(request.ctx.signature.actor)
if request.path in ['/inbox', '/actor'] and request.method.lower() == 'post':
try:
data = request.data.json
@ -41,7 +65,7 @@ class AuthCheck(MiddlewareBase):
return response.text(f'Invalid data', status=400)
try:
validated = await verify_request(request)
validated = await verify_request(request, actor=request.ctx.actor)
except AssertionError as e:
logging.debug(f'Failed sig check: {e}')

View file

@ -1,8 +1,15 @@
from izzylib import logging
import json
from izzylib import logging
from tldextract import extract
from urllib.parse import urlparse
from . import messages
from .database import db
from .functions import fetch_actor, get_inbox, push_message
from .functions import fetch_actor, fetch_auth, get_inbox, push_message
from .messages import Message
relayed_objects = []
class ProcessData:
@ -13,48 +20,104 @@ class ProcessData:
self.instance = request.ctx.instance
self.type = data.type.lower()
self.data = data
self.actor = fetch_actor(data.actor)
try:
self.actor = fetch_actor(self.signature.actor)
except json.decoder.JSONDecodeError:
self.actor = None
#print(self.request.Headers.to_json(4))
#print(self.request.data.json.to_json(4))
if not self.actor:
logging.verbose(f'Failed to fetch actor on instance follow: {actor.data}')
self.new_response = response.json('Failed to fetch actor.', status=400)
logging.verbose(f'Failed to fetch actor: {data.actor}')
self.new_response = response.json('Failed to fetch actor.', status=401)
return
self.new_response = getattr(self, f'cmd_{self.type}')()
def cmd_follow(self):
if self.instance and not self.instance.followid:
return
if self.actor.type.lower() != 'application':
#return self.response.json('No', status=403)
data = [
get_inbox(self.actor),
self.actor.id
]
Message('reject', self.data.id, self.actor.id).send(self.actor.inbox)
logging.debug(f'Rejected non-application actor: {self.actor.id}')
return
with db.session as s:
req_app = s.get.config('require_approval')
if req_app:
data.append(self.data.id)
if not (self.instance and not self.instance.followid):
data = [
get_inbox(self.actor),
self.actor.id
]
instance = s.put.instance(*data)
if req_app:
data.append(self.data.id)
if not instance:
logging.error(f'Something messed up when inserting "{self.signature.domain}" into the database')
return self.response.json('Internal error', status=500)
self.instance = s.put.instance(*data)
if not req_app:
accept_msg = messages.accept(self.data.id, instance)
resp = push_message(instance.inbox, accept_msg)
if not self.instance:
logging.error(f'Something messed up when inserting "{self.signature.domain}" into the database')
return self.response.json('Internal error', status=500)
if resp.status not in [200, 202]:
raise ValueError(f'Error when pushing to "{instance.inbox}"')
message = Message('accept', self.data.id, self.instance.actor)
resp = message.send(self.instance.inbox)
if resp.status not in [200, 202]:
raise ValueError(f'Error when pushing to "{self.instance.inbox}"')
logging.verbose(f'Instance joined the relay: {self.instance.domain}')
def cmd_undo(self):
pass
if self.actor.type.lower() != 'application':
return
object = fetch_auth(self.data.object) if isinstance(self.data.object, str) else self.data.object
if object.type != 'Follow':
return
with db.session as s:
s.delete.instance(self.signature.actor)
logging.debug(f'Removed instance from relay: {self.instance.domain}')
def cmd_announce(self):
pass
object = fetch_auth(self.data.object) if isinstance(self.data.object, str) else self.data.object
obj_actor = fetch_auth(object.attributedTo)
if not obj_actor:
logging.verbose('Failed to fetch actor:', obj_actor)
# I'm pretty sure object.attributedTo is a string, but leaving this here just in case
#obj_actor = fetch_auth(object.attributedTo) if isinstance(object.attributedTo, str) else object.attributedTo
if object.id in relayed_objects:
logging.verbose('Already relayed object:', object.id)
return
if obj_actor:
obj_handle = obj_actor.preferredUsername
obj_domain = urlparse(object.id).netloc
obj_domain_top = extract(obj_domain).registered_domain
with db.session as s:
if any(map(s.get.ban, [None], [obj_domain, obj_domain_top])) or s.get.ban(obj_handle, obj_domain):
logging.verbose('Refusing to relay object from banned domain or user:', object.id)
return
msg = Message('announce', object.id)
for instance in [row for row in s.get.instance_list() if row.domain not in [obj_domain, self.instance.domain]]:
response = msg.send(instance.inbox)
if response.status not in [200, 202]:
logging.verbose(f'Failed to send object announce to {instance.domain}: {object.id}')
logging.debug(f'Server error {response.status}: {response.text}')
else:
logging.verbose(f'Send "{object.id}" to {instance.domain}')

View file

@ -19,13 +19,10 @@ def template_context(context):
return context
def HttpRequest(Request):
pass
with db.session as s:
app = Application(
name = s.get.config('name'),
title = s.get.config('name'),
name = 'UnciaRelay',
version = __version__,
listen = config.listen,
port = config.port,
@ -33,7 +30,6 @@ with db.session as s:
workers = config.workers,
git_repo = 'https://git.barkshark.xyz/izaliamae/uncia',
proto = 'https',
#request_class = HttpRequest,
tpl_search = [path.frontend],
tpl_context = template_context,
class_views = [getattr(views, view) for view in dir(views) if view.startswith('Uncia')]

View file

@ -16,7 +16,7 @@ class UnciaHome(View):
instances = []
with db.session as s:
for row in s.get.inbox_list():
for row in s.get.instance_list():
if not row.followid:
instances.append({
'domain': row.domain,
@ -106,24 +106,24 @@ class UnciaActor(View):
'https://www.w3.org/ns/activitystreams',
{'manuallyApprovesFollowers': 'as:manuallyApprovesFollowers'},
],
'id': f'https://{config.host}/actor',
#'followers': f'https://{host}/followers',
#'following': f'https://{host}/following',
'name': cfg.name,
'summary': cfg.description,
'preferredUsername': 'relay',
'type': 'Application',
'inbox': f'https://{config.host}/inbox',
'url': f'https://{config.host}/actor',
'manuallyApprovesFollowers': cfg.require_approval,
'endpoints': {
'sharedInbox': f"https://{config.host}/inbox"
},
#'followers': f'https://{host}/followers',
#'following': f'https://{host}/following',
'inbox': f'https://{config.host}/inbox',
'name': cfg.name,
'type': 'Application',
'id': f'https://{config.host}/actor',
'manuallyApprovesFollowers': cfg.require_approval,
'publicKey': {
'id': f'https://{config.host}/actor#main-key',
'owner': f'https://{config.host}/actor',
'publicKeyPem': cfg.pubkey
},
'summary': 'Relay Actor',
'preferredUsername': 'relay',
'url': f'https://{config.host}/actor'
}
}
return response.json(data)
@ -142,7 +142,7 @@ class UnciaNodeinfo(View):
async def get(self, request, response):
with db.session as s:
instances = s.get.inbox_list('domain')
instances = s.get.instance_list('domain')
domainbans = [row.domain for row in s.get.ban_list('domain')]
userbans = [f"{row.handle}@{row.domain}" for row in s.get.ban_list('user')]