diff --git a/.env_template b/.env_template new file mode 100644 index 0000000..8830186 --- /dev/null +++ b/.env_template @@ -0,0 +1,7 @@ +POSTGRES_URI="postgresql://postgres:ChangeMe@postgres.example.tld/overseer" +USE_SQLITE=0 +OVERSEER_LEMMY_DOMAIN="overctrl.example.tld" +OVERSEER_LEMMY_USERNAME="overseer" +OVERSEER_LEMMY_PASSWORD="LemmyPassword" +ADMIN_API_KEY="Password" +secret_key="VerySecretKey" \ No newline at end of file diff --git a/overseer/apis/models/v1.py b/overseer/apis/models/v1.py index 1c20b60..18fafda 100644 --- a/overseer/apis/models/v1.py +++ b/overseer/apis/models/v1.py @@ -29,7 +29,7 @@ class Models: 'endorsements': fields.Integer(description="The amount of endorsements this instance has received"), 'guarantor': fields.String(description="The domain of the instance which guaranteed this instance."), }) - self.response_model_model_Instances_get = api.model('Instances', { + self.response_model_model_Whitelist_get = api.model('Instances', { 'instances': fields.List(fields.Nested(self.response_model_instances)), 'domains': fields.List(fields.String(description="The instance domains as a list.")), 'csv': fields.String(description="The instance domains as a csv."), diff --git a/overseer/apis/v1/base.py b/overseer/apis/v1/base.py index 223db19..0467e85 100644 --- a/overseer/apis/v1/base.py +++ b/overseer/apis/v1/base.py @@ -1,10 +1,15 @@ +import os from flask import request from flask_restx import Namespace, Resource, reqparse -from overseer.flask import cache +from overseer.flask import cache, db from overseer.observer import retrieve_suspicious_instances from loguru import logger from overseer.classes.instance import Instance from overseer.database import functions as database +from overseer import exceptions as e +from overseer.utils import hash_api_key +from overseer.lemmy import pm_new_api_key +from pythorhead import Lemmy api = Namespace('v1', 'API Version 1' ) @@ -12,6 +17,9 @@ from overseer.apis.models.v1 import Models models = Models(api) +handle_bad_request = api.errorhandler(e.BadRequest)(e.handle_bad_requests) +handle_forbidden = api.errorhandler(e.Forbidden)(e.handle_bad_requests) + # Used to for the flask limiter, to limit requests per url paths def get_request_path(): # logger.info(dir(request)) @@ -50,21 +58,69 @@ class Whitelist(Resource): 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") @api.expect(get_parser) - @logger.catch(reraise=True) @cache.cached(timeout=10, query_string=True) - @api.marshal_with(models.response_model_model_Instances_get, code=200, description='Instances', skip_none=True) + @api.marshal_with(models.response_model_model_Whitelist_get, code=200, description='Instances', skip_none=True) def get(self): '''A List with the details of all instances and their endorsements ''' self.args = self.get_parser.parse_args() instance_details = [] for instance in database.get_all_instances(): - logger.debug(instance) instance_details.append(instance.get_details()) if self.args.csv: return {"csv": ",".join([instance["domain"] for instance in instance_details])},200 if self.args.domains: return {"domains": [instance["domain"] for instance in instance_details]},200 - logger.debug(instance_details) return {"instances": instance_details},200 + + put_parser = reqparse.RequestParser() + 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("domain", required=False, type=str, help="The instance domain. It MUST be alredy registered in https://overctrl.dbzer0.com", location="json") + put_parser.add_argument("guarantor", required=False, type=str, help="(Optiona) The domain of the guaranteeing instance. They will receive a PM to validate you", location="json") + + + @api.expect(put_parser) + @api.marshal_with(models.response_model_instances, code=200, description='Instances') + def put(self): + '''A List with the details of all instances and their endorsements + ''' + self.args = self.put_parser.parse_args() + existing_instance = Instance.query.filter_by(domain=self.args.domain).first() + if existing_instance: + return existing_instance.get_details(),200 + requested_lemmy = Lemmy(f"https://{self.args.domain}") + site = requested_lemmy.site.get() + api_key = pm_new_api_key(self.args.domain) + if not api_key: + raise e.BadRequest("Failed to generate API Key") + new_instance = Instance( + domain=self.args.domain, + api_key=hash_api_key(api_key), + open_registrations=site["site_view"]["local_site"]["registration_mode"] == "open", + email_verify=site["site_view"]["local_site"]["require_email_verification"], + software=requested_lemmy.nodeinfo['software']['name'], + ) + new_instance.create() + return new_instance.get_details(),200 + + patch_parser = reqparse.RequestParser() + patch_parser.add_argument("Authorization: apikey", type=str, required=True, help="The sending instance's API key.", location='headers') + patch_parser.add_argument("Client-Agent", default="unknown:0:unknown", type=str, required=False, help="The client name and version.", location="headers") + patch_parser.add_argument("domain", required=False, type=str, help="The instance domain. It MUST be alredy registered in https://overctrl.dbzer0.com", location="json") + patch_parser.add_argument("guarantor", required=False, type=str, help="(Optiona) The domain of the guaranteeing instance. They will receive a PM to validate you", location="json") + + + @api.expect(put_parser) + @logger.catch(reraise=True) + @api.marshal_with(models.response_model_instances, code=200, description='Instances', skip_none=True) + def patch(self): + '''A List with the details of all instances and their endorsements + ''' + self.args = self.patch_parser.parse_args() + self.apikey = self.args["Authorization: apikey"] + if not self.apikey: + raise e.BadRequest("You must provide the API key that was PM'd to your overctrl.dbzer0.com account") + existing_instance = Instance.query.filter_by(domain=self.args.domain).first() + if existing_instance: + return existing_instance.get_details,200 diff --git a/overseer/classes/__init__.py b/overseer/classes/__init__.py index d8ab436..82a7f18 100644 --- a/overseer/classes/__init__.py +++ b/overseer/classes/__init__.py @@ -12,15 +12,16 @@ with OVERSEER.app_context(): db.create_all() - admin_domain = os.getenv("ADMIN_DOMAIN") + admin_domain = os.getenv("OVERSEER_LEMMY_DOMAIN") admin = db.session.query(Instance).filter_by(domain=admin_domain).first() if not admin: admin = Instance( id=0, domain=admin_domain, - api_key=hash_api_key(os.getenv("ADMIN_PASSWORD")), + api_key=hash_api_key(os.getenv("ADMIN_API_KEY")), open_registrations=False, email_verify=False, + software="lemmy", ) admin.create() guarantee = Guarantee( diff --git a/overseer/classes/instance.py b/overseer/classes/instance.py index 853aaf5..dd65f6e 100644 --- a/overseer/classes/instance.py +++ b/overseer/classes/instance.py @@ -35,13 +35,14 @@ class Instance(db.Model): __tablename__ = "instances" id = db.Column(db.Integer, primary_key=True) - domain = db.Column(db.String(255), unique=True, nullable=False) + domain = db.Column(db.String(255), unique=True, nullable=False, index=True) api_key = db.Column(db.String(100), unique=True, nullable=False, index=True) created = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) updated = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) open_registrations = db.Column(db.Boolean, unique=False, nullable=False, index=True) email_verify = db.Column(db.Boolean, unique=False, nullable=False, index=True) + software = db.Column(db.String(50), unique=False, nullable=False, index=True) 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]) @@ -53,16 +54,19 @@ class Instance(db.Model): db.session.commit() def get_details(self): + guarantor = self.get_guarantor() ret_dict = { "domain": self.domain, "open_registrations": self.open_registrations, "email_verify": self.email_verify, "endorsements": len(self.endorsements), "approvals": len(self.approvals), - "guarantor": self.get_guarantor().domain, + "guarantor": guarantor.domain if guarantor else None, } return ret_dict def get_guarantor(self): + if len(self.guarantors) == 0: + return None guarantee = self.guarantors[0] return Instance.query.filter_by(id=guarantee.guarantor_id).first() diff --git a/overseer/exceptions.py b/overseer/exceptions.py new file mode 100644 index 0000000..8a30a1e --- /dev/null +++ b/overseer/exceptions.py @@ -0,0 +1,23 @@ +from werkzeug import exceptions as wze +from loguru import logger + +class BadRequest(wze.BadRequest): + def __init__(self, message, log=None): + self.specific = message + self.log = log + +class Forbidden(wze.Forbidden): + def __init__(self, message, log=None): + self.specific = message + self.log = log + +class Locked(wze.Locked): + def __init__(self, message, log=None): + self.specific = message + self.log = log + +def handle_bad_requests(error): + '''Namespace error handler''' + if error.log: + logger.warning(error.log) + return({'message': error.specific}, error.code) diff --git a/overseer/lemmy.py b/overseer/lemmy.py new file mode 100644 index 0000000..91f97e7 --- /dev/null +++ b/overseer/lemmy.py @@ -0,0 +1,24 @@ +from pythorhead import Lemmy +from loguru import logger +import os +import secrets +import overseer.exceptions as e + +overctrl_lemmy = Lemmy(f"https://{os.getenv('OVERSEER_LEMMY_DOMAIN')}") +_login = overctrl_lemmy.log_in(os.getenv('OVERSEER_LEMMY_USERNAME'),os.getenv('OVERSEER_LEMMY_PASSWORD')) +if not _login: + raise Exception("Failed to login to overctrl") +overseer_lemmy_user = overctrl_lemmy.user.get(username=os.getenv('OVERSEER_LEMMY_USERNAME')) + +def pm_new_api_key(domain: str): + api_key = secrets.token_urlsafe(16) + pm_content = f"The API Key for domain {domain} is {api_key}.\n\nUse this to perform operations on the overseer." + domain_username = domain.replace(".", "_") + domain_user = overctrl_lemmy.user.get(username=domain_username) + if not domain_user: + raise e.BadRequest(f"Could not find domain user '{domain_username}'") + pm = overctrl_lemmy.private_message(pm_content,domain_user["person_view"]["person"]["id"]) + if not pm: + raise e.BadRequest("API Key PM failed") + return api_key + diff --git a/requirements.txt b/requirements.txt index 51a6a9f..7998a0a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,13 +6,11 @@ Flask-Caching waitress~=2.1.2 requests >= 2.27 Markdown~=3.4.1 -flask-dance[sqla] -blinker python-dotenv loguru python-dateutil~=2.8.2 -redis~=4.3.5 flask_sqlalchemy==3.0.2 SQLAlchemy~=1.4.44 psycopg2-binary regex +pythorhead>=0.6.0 \ No newline at end of file