feat: PMs via Mastodon Proxy (#21)

* feat: proxy pms

* working proxy PMs

* feat: support PMs for all SW

* working test
pull/23/head
Divided by Zer0 2023-09-14 00:26:03 +02:00 committed by GitHub
parent 7e07709421
commit cf12654102
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 284 additions and 82 deletions

View File

@ -5,3 +5,6 @@ FEDISEER_LEMMY_USERNAME="fediseer"
FEDISEER_LEMMY_PASSWORD="LemmyPassword"
ADMIN_API_KEY="Password"
secret_key="VerySecretKey"
MASTODON_INSTANCE=botsin.space # Use only when logging in to a mastodon proxy account
MASTODON_EMAIL=email@example.com # Use only when logging in to a mastodon proxy account
MASTODON_PASSWORD=VerySecretPass # Use only when logging in to a mastodon proxy account

2
.gitignore vendored
View File

@ -135,3 +135,5 @@ fediseer*.bz2
fediseer.db
private.pem
public.pem
pytooter*

View File

@ -83,11 +83,17 @@ class Models:
"message": fields.String(default='OK',required=True, description="The result of this operation."),
"new_key": fields.String(default=None,required=False, description="The new API key"),
})
self.input_instance_claim = api.model('ClaimInstanceInput', {
'admin': fields.String(required=True, min_length=1, description="The username of the admin who wants to register this domain", example="admin"),
'guarantor': fields.String(required=False, description="(Optional) The domain of the guaranteeing instance. They will receive a PM to validate you", example="admin"),
'pm_proxy': fields.String(required=False, enum=[e.name for e in enums.PMProxy], description="(Optional) If you do receive the PM from @fediseer@fediseer.com, set this to true to make the Fediseer PM your your API key via @fediseer@botsin.space. For this to work, ensure that botsin.space is not blocked in your instance and optimally follow @fediseer@botsin.space as well. If set, this will be used permanently for communication to your instance."),
})
self.input_api_key_reset = api.model('ApiKeyResetInput', {
'admin_username': fields.String(required=False, description="If a username is given, their API key will be reset. Otherwise the user's whose API key was provided will be reset. This allows can be initiated by other instance admins or the fediseer.", example="admin"),
'return_new_key': fields.Boolean(required=False, default=False, description="If True, the key will be returned as part of the response instead of PM'd. Fediseer will still PM a notification to the target admin account."),
'sysadmins': fields.Integer(required=False, default=None, min=0, max=100, description="Report how many system administrators this instance currently has."),
'moderators': fields.Integer(required=False, default=None, min=0, max=1000, description="Report how many instance moderators this instance currently has."),
'pm_proxy': fields.String(required=False, enum=[e.name for e in enums.PMProxy], description="(Optional) If you do receive the PM from @fediseer@fediseer.com, set this to true to make the Fediseer PM your your API key via @fediseer@botsin.space. For this to work, ensure that botsin.space is not blocked in your instance and optimally follow @fediseer@botsin.space as well. If set, this will be used permanently for communication to your instance."),
})
self.response_model_reports = api.model('ActivityReport', {
'source_domain': fields.String(description="The instance domain which initiated this activity", example="lemmy.dbzer0.com"),

View File

@ -11,7 +11,6 @@ from fediseer.utils import hash_api_key
from fediseer.messaging import activitypub_pm
from pythorhead import Lemmy
from fediseer.fediverse import get_admin_for_software, get_nodeinfo
from fediseer.consts import SUPPORTED_SOFTWARE
api = Namespace('v1', 'API Version 1' )

View File

@ -145,12 +145,15 @@ class Endorsements(Resource):
db.session.add(new_report)
db.session.commit()
if not database.has_recent_endorsement(target_instance.id):
try:
activitypub_pm.pm_admins(
message=f"Your instance has just been [endorsed](https://fediseer.com/faq#what-is-an-endorsement) by {instance.domain}",
domain=target_instance.domain,
software=target_instance.software,
instance=target_instance,
)
except:
pass
logger.info(f"{instance.domain} Endorsed {domain}")
return {"message":'Changed'}, 200
@ -236,11 +239,14 @@ class Endorsements(Resource):
)
db.session.add(new_report)
db.session.commit()
try:
activitypub_pm.pm_admins(
message=f"Oh no. {instance.domain} has just withdrawn the endorsement of your instance",
domain=target_instance.domain,
software=target_instance.software,
instance=target_instance,
)
except:
pass
logger.info(f"{instance.domain} Withdrew endorsement from {domain}")
return {"message":'Changed'}, 200

View File

@ -113,20 +113,26 @@ class Guarantees(Resource):
)
db.session.add(new_report)
db.session.commit()
try:
activitypub_pm.pm_admins(
message=f"Congratulations! Your instance has just been [guaranteed](https://fediseer.com/faq#what-is-a-guarantee) by {instance.domain}. \n\nThis is an automated PM by the [Fediseer](https://fediseer.com) service. Replies will not be read.\nPlease contact @db0@lemmy.dbzer0.com for further inquiries.",
domain=target_instance.domain,
software=target_instance.software,
instance=target_instance,
)
except:
pass
orphan_ids = database.get_guarantee_chain(target_instance.id)
for orphan in database.get_instances_by_ids(orphan_ids):
try:
activitypub_pm.pm_admins(
message=f"Phew! You guarantor chain has been repaired as {instance.domain} has guaranteed for {domain}.",
domain=orphan.domain,
software=orphan.software,
instance=orphan,
)
except:
pass
orphan.unset_as_orphan()
logger.info(f"{instance.domain} Guaranteed for {domain}")
return {"message":'Changed'}, 200

View File

@ -1,6 +1,7 @@
from fediseer.apis.v1.base import *
from fediseer.messaging import activitypub_pm
from fediseer.classes.user import User, Claim
from fediseer import enums
class Whitelist(Resource):
get_parser = reqparse.RequestParser()
@ -8,7 +9,7 @@ class Whitelist(Resource):
get_parser.add_argument("endorsements", required=False, default=0, type=int, help="Limit to this amount of endorsements of more", location="args")
get_parser.add_argument("guarantors", required=False, default=1, type=int, help="Limit to this amount of guarantors of more", location="args")
get_parser.add_argument("csv", required=False, type=bool, help="Set to true to return just the domains as a csv. Mutually exclusive with domains", location="args")
get_parser.add_argument("domains", required=False, type=bool, help="Set to true to return just the domains as a list. Mutually exclusive with csv", location="args")
get_parser.add_argument("domains", required=False, type=str, help="Set to true to return just the domains as a list. Mutually exclusive with csv", location="args")
@api.expect(get_parser)
@cache.cached(timeout=10, query_string=True)
@ -49,9 +50,10 @@ class WhitelistDomain(Resource):
put_parser.add_argument("Client-Agent", default="unknown:0:unknown", type=str, required=False, help="The client name and version.", location="headers")
put_parser.add_argument("admin", required=True, type=str, help="The username of the admin who wants to register this domain", location="json")
put_parser.add_argument("guarantor", required=False, type=str, help="(Optional) The domain of the guaranteeing instance. They will receive a PM to validate you", location="json")
put_parser.add_argument("pm_proxy", required=False, default=False, type=str, help="(Optional) If you do receive the PM from @fediseer@fediseer.com, set this to true to make the Fediseer PM your your API key via @fediseer@botsin.space. For this to work, ensure that botsin.space is not blocked in your instance and optimally follow @fediseer@botsin.space as well. If set, this will be used permanently for communication to your instance.", location="json")
@api.expect(put_parser)
@api.expect(put_parser,models.input_instance_claim, validate=True)
@api.marshal_with(models.response_model_instances, code=200, description='Instances')
@api.response(400, 'Bad Request', models.response_model_error)
def put(self, domain):
@ -76,7 +78,16 @@ class WhitelistDomain(Resource):
existing_claim = database.find_claim(f"@{self.args.admin}@{domain}")
if existing_claim:
raise e.Forbidden(f"You have already claimed this instance as this admin. Please use the PATCH method to reset your API key.")
api_key = activitypub_pm.pm_new_api_key(domain, self.args.admin, instance.software)
if self.args.pm_proxy is not None:
proxy = enums.PMProxy[self.args.pm_proxy]
if instance.pm_proxy != proxy:
instance.pm_proxy = proxy
api_key = activitypub_pm.pm_new_api_key(
domain=domain,
username=self.args.admin,
software=instance.software,
proxy=instance.pm_proxy,
)
if not api_key:
raise e.BadRequest("Failed to generate API Key")
new_user = User(
@ -93,12 +104,15 @@ class WhitelistDomain(Resource):
db.session.add(new_claim)
db.session.commit()
if guarantor_instance:
try:
activitypub_pm.pm_admins(
message=f"New instance {domain} was just registered with the Fediseer and have asked you to guarantee for them!",
domain=guarantor_instance.domain,
software=guarantor_instance.software,
instance=guarantor_instance,
)
except:
pass
return instance.get_details(),200
patch_parser = reqparse.RequestParser()
@ -108,6 +122,7 @@ class WhitelistDomain(Resource):
patch_parser.add_argument("return_new_key", default=False, required=False, type=bool, help="If True, the key will be returned as part of the response instead of PM'd. IT will still PM a notification to you.", location="json")
patch_parser.add_argument("sysadmins", default=None, required=False, type=int, help="How many sysadmins this instance has.", location="json")
patch_parser.add_argument("moderators", default=None, required=False, type=int, help="How many moderators this instance has.", location="json")
patch_parser.add_argument("pm_proxy", required=False, default=False, type=str, help="(Optional) If you do receive the PM from @fediseer@fediseer.com, set this to true to make the Fediseer PM your your API key via @fediseer@botsin.space. For this to work, ensure that botsin.space is not blocked in your instance and optimally follow @fediseer@botsin.space as well. If set, this will be used permanently for communication to your instance.", location="json")
@api.expect(patch_parser,models.input_api_key_reset, validate=True)
@ -133,6 +148,13 @@ class WhitelistDomain(Resource):
if self.args.moderators is not None and instance.moderators != self.args.moderators:
instance.moderators = self.args.moderators
changed = True
if self.args.pm_proxy is not None:
logger.debug(self.args.pm_proxy)
proxy = enums.PMProxy[self.args.pm_proxy]
if instance.pm_proxy != proxy:
activitypub_pm.pm_new_proxy_switch(proxy,instance.pm_proxy,instance,user.username)
instance.pm_proxy = proxy
changed = True
if self.args.admin_username:
requestor = None
if self.args.admin_username != user.username or user.username == "fediseer":
@ -148,9 +170,21 @@ class WhitelistDomain(Resource):
if self.args.return_new_key:
if requestor is None:
requestor = f"{user.username}@{requestor_instance.domain}"
new_key = activitypub_pm.pm_new_key_notification(domain, self.args.admin_username, instance.software, requestor=requestor)
new_key = activitypub_pm.pm_new_key_notification(
domain=domain,
username=self.args.admin_username,
software=instance.software,
requestor=requestor,
proxy=instance.pm_proxy,
)
else:
new_key = activitypub_pm.pm_new_api_key(domain, self.args.admin_username, instance.software, requestor=requestor)
new_key = activitypub_pm.pm_new_api_key(
domain=domain,
username=self.args.admin_username,
software=instance.software,
requestor=requestor,
proxy=instance.pm_proxy,
)
user.api_key = hash_api_key(new_key)
changed = True
db.session.commit()

View File

@ -8,6 +8,7 @@ from sqlalchemy.dialects.postgresql import UUID
from loguru import logger
from fediseer.flask import db, SQLITE_MODE
from fediseer import enums
uuid_column_type = lambda: UUID(as_uuid=True) if not SQLITE_MODE else db.String(36)
@ -88,6 +89,8 @@ class Instance(db.Model):
software = db.Column(db.String(50), unique=False, nullable=False, index=True)
sysadmins = db.Column(db.Integer, unique=False, nullable=True)
moderators = db.Column(db.Integer, unique=False, nullable=True)
pm_proxy = db.Column(Enum(enums.PMProxy), default=enums.PMProxy.NONE, nullable=False)
approvals = db.relationship("Endorsement", back_populates="approving_instance", cascade="all, delete-orphan", foreign_keys=[Endorsement.approving_id])
endorsements = db.relationship("Endorsement", back_populates="endorsed_instance", cascade="all, delete-orphan", foreign_keys=[Endorsement.endorsed_id])

View File

@ -1,6 +1,11 @@
FEDISEER_VERSION = "0.13.0"
FEDISEER_VERSION = "0.14.0"
SUPPORTED_SOFTWARE = [
"lemmy",
"mastodon",
"friendica",
"pleroma",
"akkoma",
"firefish",
"iceshrimp",
"misskey",
]

View File

@ -10,3 +10,7 @@ class ReportActivity(enum.Enum):
ADDED = 0
DELETED = 1
MODIFIED = 2
class PMProxy(enum.Enum):
NONE = 0
MASTODON = 1

View File

@ -3,19 +3,19 @@ from loguru import logger
from pythorhead import Lemmy
from fediseer.consts import FEDISEER_VERSION
def get_lemmy_admins(domain):
def get_lemmy_admins(domain,software):
requested_lemmy = Lemmy(f"https://{domain}")
try:
site = requested_lemmy.site.get()
except Exception as err:
logger.error(f"Error retrieving mastodon site info for {domain}: {err}")
logger.error(f"Error retrieving {software} site info for {domain}: {err}")
raise err
if not site:
logger.error(f"Error retrieving mastodon site info for {domain}")
raise Exception(f"Error retrieving mastodon site info for {domain}")
logger.error(f"Error retrieving {software} site info for {domain}")
raise Exception(f"Error retrieving {software} site info for {domain}")
return [a["person"]["name"] for a in site["admins"]]
def get_mastodon_admins(domain):
def get_mastodon_admins(domain,software):
site = None
try:
site = requests.get(f"https://{domain}/api/v2/instance")
@ -26,12 +26,65 @@ def get_mastodon_admins(domain):
return [site_json["contact"]["account"]["username"]]
except Exception as err:
if site is not None:
logger.error(f"Error retrieving mastodon site info for {domain}: {err}. Request text: {site.text()}")
logger.error(f"Error retrieving {software} site info for {domain}: {err}. Request text: {site.text()}")
else:
logger.error(f"Error retrieving mastodon site info for {domain}: {err}")
raise Exception(f"Error retrieving mastodon site info for {domain}: {err}")
logger.error(f"Error retrieving {software} site info for {domain}: {err}")
raise Exception(f"Error retrieving {software} site info for {domain}: {err}")
def get_unknown_admins(domain):
def get_misskey_admins(domain,software):
site = None
try:
site = requests.get(f"https://{domain}/api/v1/instance")
site_json = site.json()
if "contact_account" not in site_json or "username" not in site_json["contact_account"]:
logger.error(f"No admin contact is specified for {domain}.")
raise Exception(f"No admin contact is specified for {domain}.")
return [site_json["contact_account"]["username"]]
except Exception as err:
if site is not None:
logger.error(f"Error retrieving {software} site info for {domain}: {err}. Request text: {site.text()}")
else:
logger.error(f"Error retrieving {software} site info for {domain}: {err}")
raise Exception(f"Error retrieving {software} site info for {domain}: {err}")
def get_pleroma_admins(domain,software):
site = None
try:
site = requests.get(f"https://{domain}/api/v1/instance")
site_json = site.json()
if "email" not in site_json or site_json["email"] is None or site_json["email"] == '':
logger.error(f"No admin contact is specified for {domain}.")
raise Exception(f"No admin contact is specified for {domain}.")
admin_username = site_json["email"].split('@',1)[0]
return [admin_username]
except Exception as err:
if site is not None:
logger.error(f"Error retrieving {software} site info for {domain}: {err}. Request text: {site.text()}")
else:
logger.error(f"Error retrieving {software} site info for {domain}: {err}")
raise Exception(f"Error retrieving {software} site info for {domain}: {err}")
def discover_admins(domain,software):
site = None
try:
site = requests.get(f"https://{domain}/api/v1/instance")
site_json = site.json()
# Pleroma/Akkoma style
if "email" in site_json:
admin_username = site_json["email"].split('@',1)[0]
return [admin_username]
# Misskey/Firefish style
if "contact_account" in site_json:
return [site_json["contact_account"]["username"]]
# Mastodon style
if "contact" in site_json:
return [site_json["contact"]["account"]["username"]]
raise Exception(f"Site software '{software} does not match any of the known APIs")
except Exception as err:
logger.error(f"Error retrieving {software} site info for {domain}: {err}")
raise Exception(f"Error retrieving {software} site info for {domain}: {err}")
def get_unknown_admins(domain,software):
return []
def get_admin_for_software(software: str, domain: str):
@ -39,12 +92,17 @@ def get_admin_for_software(software: str, domain: str):
"lemmy": get_lemmy_admins,
"mastodon": get_mastodon_admins,
"friendica": get_mastodon_admins,
"pleroma": get_pleroma_admins,
"akkoma": get_pleroma_admins,
"firefish": get_misskey_admins,
"iceshrimp": get_misskey_admins,
"misskey": get_misskey_admins,
"unknown": get_unknown_admins,
"wildcard": get_unknown_admins,
}
if software not in software_map:
return []
return software_map[software](domain)
return discover_admins(domain,software)
return software_map[software](domain,software)
def get_nodeinfo(domain):

View File

@ -11,13 +11,16 @@ import secrets
import markdown
import fediseer.exceptions as e
from pythorhead import Lemmy
from mastodon import Mastodon
from loguru import logger
from fediseer.database import functions as database
from fediseer.consts import SUPPORTED_SOFTWARE, FEDISEER_VERSION
from fediseer.fediverse import get_admin_for_software
from fediseer import enums
class ActivityPubPM:
private_key = None
mastodon = None
def __init__(self):
with open('private.pem', 'rb') as file:
private_key_data = file.read()
@ -34,6 +37,11 @@ class ActivityPubPM:
},
}
self.mastodon = Mastodon(
access_token = 'pytooter_usercred.secret',
api_base_url = f"https://{os.environ['MASTODON_INSTANCE']}"
)
def send_pm_to_right_software(self, message, username, domain, software):
software_map = {
"lemmy": self.send_lemmy_pm,
@ -41,7 +49,9 @@ class ActivityPubPM:
"friendica": self.send_mastodon_pm,
"fediseer": self.send_fediseer_pm,
}
if software in software_map:
return software_map[software](message, username, domain)
raise e.BadRequest("This software does not have direct PM implemented. Please retry using a MASTODON pm_proxy setting.")
def send_fediseer_pm(self, message, username, domain):
document = copy.deepcopy(self.document_core)
@ -115,12 +125,15 @@ class ActivityPubPM:
response = requests.post(url, data=document, headers=headers)
return response.ok
def pm_new_api_key(self, domain: str, username: str, software: str, requestor = None):
def pm_new_api_key(self, domain: str, username: str, software: str, requestor = None, proxy = None):
api_key = secrets.token_urlsafe(16)
if requestor:
pm_content = f"user '{requestor}' has initiated an API Key reset for your domain {domain} on the [Fediseer](https://fediseer.com)\n\nThe new API key is\n\n{api_key}\n\n**Please purge this message after storing the API key**"
pm_content = f"user '{requestor}' has initiated an API Key reset for your domain {domain} on the [Fediseer](https://fediseer.com)\n\nThe new API key is\n\n{api_key}\n\n**Please purge this message after storing the API key or use the Fediseer API to generate a new API key without PM**"
else:
pm_content = f"Your API Key for domain {domain} is\n\n{api_key}\n\nUse this to perform operations on the [Fediseer](https://fediseer.com).\n\n**Please purge this message after storing the API key or use the Fediseer API to generate a new API key without PM**"
if proxy == enums.PMProxy.MASTODON:
self.mastodon_proxy_pm(pm_content,username,domain)
else:
pm_content = f"Your API Key for domain {domain} is\n\n{api_key}\n\nUse this to perform operations on the [Fediseer](https://fediseer.com).\n\n**Please purge this message after storing the API key**"
if not self.send_pm_to_right_software(
message=pm_content,
username=username,
@ -130,10 +143,13 @@ class ActivityPubPM:
raise e.BadRequest("API Key PM failed")
return api_key
def pm_new_key_notification(self, domain: str, username: str, software: str, requestor: str):
def pm_new_key_notification(self, domain: str, username: str, software: str, requestor: str, proxy = None):
api_key = secrets.token_urlsafe(16)
pm_content = f"user '{requestor}' has initiated an API Key reset for your domain {domain} on the [Fediseer](https://fediseer.com)\n\nThe new API key was provided in the response already\n"
logger.info(f"user '{requestor}' reset the API key for {username}@{domain} on the response.")
if proxy == enums.PMProxy.MASTODON:
self.mastodon_proxy_pm(pm_content,username,domain)
else:
if not self.send_pm_to_right_software(
message=pm_content,
username=username,
@ -143,21 +159,48 @@ class ActivityPubPM:
raise e.BadRequest("API Key PM failed")
return api_key
def pm_new_proxy_switch(self, new_proxy: enums.PMProxy, old_proxy: enums.PMProxy, instance: str, requestor: str):
if new_proxy == enums.PMProxy.NONE:
pm_content = f"user '{requestor}' has switched the fediseer messaging for {instance.domain} to not use a proxy (was {old_proxy.name})."
else:
pm_content = f"user '{requestor}' has switched the fediseer messaging for {instance.domain} to use a {new_proxy.name} proxy (was {old_proxy.name})."
logger.info(f"user '{requestor}' changed instance pm_proxy setting from {old_proxy} to {new_proxy} for {instance.domain}.")
admins = [a.username for a in database.find_admins_by_instance(instance)]
for admin_username in admins:
if instance.domain == "lemmy.dbzer0.com" and admin_username != 'db0': # Debug
logger.debug(f"skipping admin {admin_username} for debug")
continue
for proxy in [new_proxy,old_proxy]:
if proxy == enums.PMProxy.MASTODON:
self.mastodon_proxy_pm(pm_content,admin_username,instance.domain)
else:
if not self.send_pm_to_right_software(
message=pm_content,
username=admin_username,
domain=instance.domain,
software=instance.software
):
raise e.BadRequest("API Key PM failed")
def pm_admins(self, message: str, domain: str, software: str, instance):
if software not in SUPPORTED_SOFTWARE:
return None
proxy = None
admins = database.find_admins_by_instance(instance)
if not admins:
try:
admins = get_admin_for_software(software, domain)
except Exception as err:
if software not in SUPPORTED_SOFTWARE:
logger.warning(f"Failed to figure out admins from {software}: {domain}")
raise e.BadRequest(f"Failed to retrieve admin list: {err}")
else:
admins = [a.username for a in admins]
proxy = instance.pm_proxy
if not admins:
raise e.BadRequest(f"Could not determine admins for {domain}")
for admin_username in admins:
if proxy == enums.PMProxy.MASTODON:
self.mastodon_proxy_pm(message,admin_username,domain)
else:
if not self.send_pm_to_right_software(
message=message,
username=admin_username,
@ -167,4 +210,13 @@ class ActivityPubPM:
raise e.BadRequest("Admin PM Failed")
def mastodon_proxy_pm(self, message, username, domain):
try:
self.mastodon.status_post(
status=f"@{username}@{domain} {message}",
visibility="direct",
)
except Exception as err:
raise e.BadRequest(f"PM via Mastodon Proxy Failed: {err}")
activitypub_pm = ActivityPubPM()

View File

@ -0,0 +1,21 @@
import os
from mastodon import Mastodon
from dotenv import load_dotenv
load_dotenv()
Mastodon.create_app(
'fediseer',
api_base_url = f"https://{os.environ['MASTODON_INSTANCE']}",
to_file = 'pytooter_clientcred.secret'
)
mastodon = Mastodon(
client_id = 'pytooter_clientcred.secret',
api_base_url = f"https://{os.environ['MASTODON_INSTANCE']}"
)
mastodon.log_in(
os.environ['MASTODON_EMAIL'],
os.environ['MASTODON_PASSWORD'],
to_file = 'pytooter_usercred.secret'
)

View File

@ -17,4 +17,4 @@ pythorhead>=0.8.2
bleach
boto3
pybadges
mastodon.py

View File

@ -0,0 +1,3 @@
CREATE TYPE pmproxy AS ENUM ('MASTODON');
ALTER TYPE pmproxy ADD VALUE 'NONE';
ALTER TABLE instances ADD COLUMN pm_proxy pmproxy default 'NONE';