diff --git a/overseer/apis/models/v1.py b/overseer/apis/models/v1.py index 8566d0a..053211a 100644 --- a/overseer/apis/models/v1.py +++ b/overseer/apis/models/v1.py @@ -25,6 +25,7 @@ class Models: 'csv': fields.String(description="The suspicious domains as a csv."), }) self.response_model_instances = api.model('InstanceDetails', { + 'id': fields.Integer(description="The instance id"), 'domain': fields.String(description="The instance domain"), 'open_registrations': fields.Boolean(description="The instance uptime pct. 100% and thousand of users is unlikely"), 'email_verify': fields.Boolean(description="The amount of local posts in that instance"), @@ -32,8 +33,8 @@ 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_Whitelist_get = api.model('Instances', { + self.response_model_model_Whitelist_get = api.model('WhitelistedInstances', { '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."), - }) + }) \ No newline at end of file diff --git a/overseer/apis/v1/__init__.py b/overseer/apis/v1/__init__.py index e456582..12d8744 100644 --- a/overseer/apis/v1/__init__.py +++ b/overseer/apis/v1/__init__.py @@ -1,5 +1,9 @@ import overseer.apis.v1.base as base +import overseer.apis.v1.endorsements as endorsements from overseer.apis.v1.base import api api.add_resource(base.Suspicions, "/instances") api.add_resource(base.Whitelist, "/whitelist") +api.add_resource(base.WhitelistDomain, "/whitelist/") +api.add_resource(endorsements.Endorsements, "/endorsements/") +api.add_resource(endorsements.Approvals, "/approvals/") diff --git a/overseer/apis/v1/base.py b/overseer/apis/v1/base.py index 1399e2c..3955eea 100644 --- a/overseer/apis/v1/base.py +++ b/overseer/apis/v1/base.py @@ -54,8 +54,8 @@ class Suspicions(Resource): class Whitelist(Resource): get_parser = reqparse.RequestParser() get_parser.add_argument("Client-Agent", default="unknown:0:unknown", type=str, required=False, help="The client name and version.", location="headers") - get_parser.add_argument("endorsements", required=False, default=1, type=int, help="Limit to this amount of endorsements of more", location="args") - get_parser.add_argument("domain", required=False, type=str, help="Filter by instance domain", location="args") + 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") @@ -67,7 +67,7 @@ class Whitelist(Resource): ''' self.args = self.get_parser.parse_args() instance_details = [] - for instance in database.get_all_instances(): + for instance in database.get_all_instances(self.args.endorsements,self.args.guarantors): instance_details.append(instance.get_details()) if self.args.csv: return {"csv": ",".join([instance["domain"] for instance in instance_details])},200 @@ -84,6 +84,7 @@ class Whitelist(Resource): @api.expect(put_parser) @api.marshal_with(models.response_model_instances, code=200, description='Instances') + @api.response(400, 'Bad Request', models.response_model_error) def put(self): '''Register a new instance to the overseer An instance account has to exist in the overseer lemmy instance @@ -117,15 +118,17 @@ class Whitelist(Resource): @api.expect(patch_parser) @api.marshal_with(models.response_model_instances, code=200, description='Instances', skip_none=True) + @api.response(401, 'Invalid API Key', models.response_model_error) + @api.response(403, 'Instance Not Registered', models.response_model_error) def patch(self): '''Regenerate API key for instance ''' self.args = self.patch_parser.parse_args() if not self.args.apikey: raise e.Unauthorized("You must provide the API key that was PM'd to your overctrl.dbzer0.com account") - instance = database.find_authenticated_instance(self.args.domain, self.args.apikey) + instance = database.find_instance_by_api_key(self.args.apikey) if not instance: - raise e.BadRequest(f"No Instance found matching provided API key and domain. Have you remembered to register it?") + raise e.Forbidden(f"No Instance found matching provided API key and domain. Have you remembered to register it?") if self.args.regenerate_key: new_key = pm_new_api_key(self.args.domain) instance.api_key = hash_api_key(new_key) @@ -140,6 +143,9 @@ class Whitelist(Resource): @api.expect(delete_parser) @api.marshal_with(models.response_model_simple_response, code=200, description='Instances', skip_none=True) + @api.response(400, 'Bad Request', models.response_model_error) + @api.response(401, 'Invalid API Key', models.response_model_error) + @api.response(403, 'Forbidden', models.response_model_error) def delete(self): '''Delete instance from overseer ''' @@ -154,4 +160,22 @@ class Whitelist(Resource): db.session.delete(instance) db.session.commit() logger.warning(f"{self.args.domain} deleted") - return {"message":'OK'}, 200 + return {"message":'Changed'}, 200 + + + +class WhitelistDomain(Resource): + get_parser = reqparse.RequestParser() + get_parser.add_argument("Client-Agent", default="unknown:0:unknown", type=str, required=False, help="The client name and version.", location="headers") + + @api.expect(get_parser) + @cache.cached(timeout=10, query_string=True) + @api.marshal_with(models.response_model_instances, code=200, description='Instances') + def get(self, domain): + '''Display info about a specific instance + ''' + self.args = self.get_parser.parse_args() + instance = database.find_instance_by_domain(domain) + if not instance: + raise e.NotFound(f"No Instance found matching provided domain. Have you remembered to register it?") + return instance.get_details(),200 \ No newline at end of file diff --git a/overseer/apis/v1/endorsements.py b/overseer/apis/v1/endorsements.py new file mode 100644 index 0000000..08f1958 --- /dev/null +++ b/overseer/apis/v1/endorsements.py @@ -0,0 +1,122 @@ +from overseer.apis.v1.base import * +from overseer.classes.instance import Endorsement + +class Approvals(Resource): + get_parser = reqparse.RequestParser() + get_parser.add_argument("Client-Agent", default="unknown:0:unknown", type=str, required=False, help="The client name and version.", location="headers") + 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") + + @api.expect(get_parser) + @cache.cached(timeout=10, query_string=True) + @api.marshal_with(models.response_model_model_Whitelist_get, code=200, description='Instances', skip_none=True) + @api.response(404, 'Instance not registered', models.response_model_error) + def get(self, domain): + '''Display all endorsements given by a specific domain + ''' + self.args = self.get_parser.parse_args() + instance = database.find_instance_by_domain(domain) + if not instance: + raise e.NotFound(f"No Instance found matching provided domain. Have you remembered to register it?") + instance_details = [] + for instance in database.get_all_endorsed_instances_by_approving_id(instance.id): + 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 + return {"instances": instance_details},200 + +class Endorsements(Resource): + get_parser = reqparse.RequestParser() + get_parser.add_argument("Client-Agent", default="unknown:0:unknown", type=str, required=False, help="The client name and version.", location="headers") + 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") + + @api.expect(get_parser) + @cache.cached(timeout=10, query_string=True) + @api.marshal_with(models.response_model_model_Whitelist_get, code=200, description='Instances', skip_none=True) + @api.response(404, 'Instance not registered', models.response_model_error) + def get(self, domain): + '''Display all endorsements given by a specific domain + ''' + self.args = self.get_parser.parse_args() + instance = database.find_instance_by_domain(domain) + if not instance: + raise e.NotFound(f"No Instance found matching provided domain. Have you remembered to register it?") + instance_details = [] + for instance in database.get_all_approving_instances_by_endorsed_id(instance.id): + 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 + return {"instances": instance_details},200 + + put_parser = reqparse.RequestParser() + put_parser.add_argument("apikey", type=str, required=True, help="The sending instance's API key.", location='headers') + put_parser.add_argument("Client-Agent", default="unknown:0:unknown", type=str, required=False, help="The client name and version.", location="headers") + + + @api.expect(put_parser) + @api.marshal_with(models.response_model_simple_response, code=200, description='Endorse Instance') + @api.response(400, 'Bad Request', models.response_model_error) + @api.response(401, 'Invalid API Key', models.response_model_error) + @api.response(403, 'Not Guaranteed', models.response_model_error) + @api.response(404, 'Instance not registered', models.response_model_error) + def put(self, domain): + '''Endorse an instance + ''' + self.args = self.put_parser.parse_args() + if not self.args.apikey: + raise e.Unauthorized("You must provide the API key that was PM'd to your overctrl.dbzer0.com account") + instance = database.find_instance_by_api_key(self.args.apikey) + if not instance: + raise e.NotFound(f"No Instance found matching provided API key and domain. Have you remembered to register it?") + if len(instance.guarantors) == 0: + raise e.Forbidden("Only guaranteed instances can endorse others.") + if instance.domain == domain: + raise e.BadRequest("Nice try, but you can't endorse yourself.") + target_instance = database.find_instance_by_domain(domain=domain) + if not target_instance: + raise e.BadRequest("Instance to endorse not found") + if database.get_endorsement(target_instance.id,instance.id): + return {"message":'OK'}, 200 + new_endorsement = Endorsement( + approving_id=instance.id, + endorsed_id=target_instance.id, + ) + db.session.add(new_endorsement) + db.session.commit() + logger.info(f"{instance.domain} Endorsed {domain}") + return {"message":'Changed'}, 200 + + + delete_parser = reqparse.RequestParser() + delete_parser.add_argument("apikey", type=str, required=True, help="The sending instance's API key.", location='headers') + delete_parser.add_argument("Client-Agent", default="unknown:0:unknown", type=str, required=False, help="The client name and version.", location="headers") + + @api.expect(delete_parser) + @api.marshal_with(models.response_model_simple_response, code=200, description='Withdraw Instance Endorsement') + @api.response(400, 'Bad Request', models.response_model_error) + @api.response(401, 'Invalid API Key', models.response_model_error) + @api.response(404, 'Instance not registered', models.response_model_error) + def delete(self,domain): + '''Withdraw an instance endorsement + ''' + self.args = self.delete_parser.parse_args() + if not self.args.apikey: + raise e.Unauthorized("You must provide the API key that was PM'd to your overctrl.dbzer0.com account") + instance = database.find_instance_by_api_key(self.args.apikey) + if not instance: + raise e.NotFound(f"No Instance found matching provided API key and domain. Have you remembered to register it?") + target_instance = database.find_instance_by_domain(domain=domain) + if not target_instance: + raise e.BadRequest("Instance from which to withdraw endorsement not found") + endorsement = database.get_endorsement(target_instance.id,instance.id) + if not endorsement: + return {"message":'OK'}, 200 + db.session.delete(endorsement) + db.session.commit() + logger.info(f"{instance.domain} Withdrew endorsement from {domain}") + return {"message":'Changed'}, 200 \ No newline at end of file diff --git a/overseer/classes/instance.py b/overseer/classes/instance.py index dd65f6e..d5045fc 100644 --- a/overseer/classes/instance.py +++ b/overseer/classes/instance.py @@ -56,6 +56,7 @@ class Instance(db.Model): def get_details(self): guarantor = self.get_guarantor() ret_dict = { + "id": self.id, "domain": self.domain, "open_registrations": self.open_registrations, "email_verify": self.email_verify, diff --git a/overseer/database/functions.py b/overseer/database/functions.py index 8333511..ead18bc 100644 --- a/overseer/database/functions.py +++ b/overseer/database/functions.py @@ -1,16 +1,61 @@ import time import uuid import json +from loguru import logger from datetime import datetime, timedelta from sqlalchemy import func, or_, and_, not_, Boolean from sqlalchemy.orm import noload from overseer.flask import db, SQLITE_MODE from overseer.utils import hash_api_key +from sqlalchemy.orm import joinedload +from overseer.classes.instance import Instance, Endorsement -from overseer.classes.instance import Instance +def get_all_instances(min_endorsements = 0, min_guarantors = 1): + query = db.session.query( + Instance + ).outerjoin( + Instance.endorsements, + Instance.guarantors, + ).options( + joinedload(Instance.guarantors), + joinedload(Instance.endorsements), + ).group_by( + Instance.id + ).having( + db.func.count(Instance.endorsements) >= min_endorsements, + ).having( + db.func.count(Instance.guarantors) >= min_guarantors, + ) + return query.all() -def get_all_instances(): - return db.session.query(Instance).all() + +def get_all_endorsed_instances_by_approving_id(approving_id): + query = db.session.query( + Instance + ).outerjoin( + Instance.endorsements, + ).options( + joinedload(Instance.endorsements), + ).filter( + Endorsement.approving_id == approving_id + ).group_by( + Instance.id + ) + return query.all() + +def get_all_approving_instances_by_endorsed_id(endorsed_id): + query = db.session.query( + Instance + ).outerjoin( + Instance.approvals, + ).options( + joinedload(Instance.approvals), + ).filter( + Endorsement.endorsed_id == endorsed_id + ).group_by( + Instance.id + ) + return query.all() def find_instance_by_api_key(api_key): @@ -23,4 +68,11 @@ def find_instance_by_domain(domain): def find_authenticated_instance(domain,api_key): instance = Instance.query.filter_by(domain=domain, api_key=hash_api_key(api_key)).first() - return instance \ No newline at end of file + return instance + +def get_endorsement(instance_id, endorsing_instance_id): + query = Endorsement.query.filter_by( + endorsed_id=instance_id, + approving_id=endorsing_instance_id, + ) + return query.first() \ No newline at end of file diff --git a/overseer/lemmy.py b/overseer/lemmy.py index 91f97e7..d779470 100644 --- a/overseer/lemmy.py +++ b/overseer/lemmy.py @@ -12,7 +12,7 @@ overseer_lemmy_user = overctrl_lemmy.user.get(username=os.getenv('OVERSEER_LEMMY 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." + pm_content = f"The API Key for domain {domain} is\n\n{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: