From 7818c6cb6512206e225a0f86ab3bd18cf2f75dcc Mon Sep 17 00:00:00 2001 From: db0 Date: Wed, 21 Jun 2023 19:37:34 +0200 Subject: [PATCH 01/11] Initial working DB instance --- overseer/apis/models/v1.py | 16 +- overseer/apis/v1/__init__.py | 3 +- overseer/apis/v1/base.py | 35 ++- overseer/classes/__init__.py | 31 +++ overseer/classes/instance.py | 68 +++++ overseer/classes/news.py | 449 +++++++++++++++++++++++++++++++++ overseer/database/__init__.py | 0 overseer/database/functions.py | 12 + overseer/flask.py | 4 +- overseer/utils.py | 103 ++++++++ 10 files changed, 715 insertions(+), 6 deletions(-) create mode 100644 overseer/classes/__init__.py create mode 100644 overseer/classes/instance.py create mode 100644 overseer/classes/news.py create mode 100644 overseer/database/__init__.py create mode 100644 overseer/database/functions.py create mode 100644 overseer/utils.py diff --git a/overseer/apis/models/v1.py b/overseer/apis/models/v1.py index 8aa5520..1c20b60 100644 --- a/overseer/apis/models/v1.py +++ b/overseer/apis/models/v1.py @@ -14,9 +14,23 @@ class Models: 'active_users_monthly': fields.Integer(description="The amount of active users monthly."), 'signup': fields.Boolean(default=False,description="True when subscriptions are open, else False"), 'activity_suspicion': fields.Float(description="Local Comments+Posts per User. Higher is worse"), + 'activity_suspicion': fields.Float(description="Local Comments+Posts per User. Higher is worse"), }) - self.response_model_model_SusInstances_get = api.model('SuspiciousInstancesDomainList', { + self.response_model_model_Suspicions_get = api.model('SuspiciousInstances', { 'instances': fields.List(fields.Nested(self.response_model_suspicious_instances)), 'domains': fields.List(fields.String(description="The suspicious domains as a list.")), 'csv': fields.String(description="The suspicious domains as a csv."), }) + self.response_model_instances = api.model('InstanceDetails', { + '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"), + 'approvals': fields.Integer(description="The amount of endorsements this instance has given out"), + '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', { + '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/__init__.py b/overseer/apis/v1/__init__.py index 02c3678..7bb35b8 100644 --- a/overseer/apis/v1/__init__.py +++ b/overseer/apis/v1/__init__.py @@ -1,4 +1,5 @@ import overseer.apis.v1.base as base from overseer.apis.v1.base import api -api.add_resource(base.SusInstances, "/instances") +api.add_resource(base.Suspicions, "/suspicions") +api.add_resource(base.Instances, "/instances") diff --git a/overseer/apis/v1/base.py b/overseer/apis/v1/base.py index 84a3354..f0c5de6 100644 --- a/overseer/apis/v1/base.py +++ b/overseer/apis/v1/base.py @@ -3,6 +3,8 @@ from flask_restx import Namespace, Resource, reqparse from overseer.flask import cache from overseer.observer import retrieve_suspicious_instances from loguru import logger +from overseer.classes.instance import Instance +from overseer.database import functions as database api = Namespace('v1', 'API Version 1' ) @@ -16,7 +18,7 @@ def get_request_path(): return f"{request.remote_addr}@{request.method}@{request.path}" -class SusInstances(Resource): +class Suspicions(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("activity_suspicion", required=False, default=20, type=int, help="How many users per local post+comment to consider suspicious", location="args") @@ -26,7 +28,7 @@ class SusInstances(Resource): @api.expect(get_parser) @logger.catch(reraise=True) @cache.cached(timeout=10, query_string=True) - @api.marshal_with(models.response_model_model_SusInstances_get, code=200, description='Suspicious Instances', skip_none=True) + @api.marshal_with(models.response_model_model_Suspicions_get, code=200, description='Suspicious Instances', skip_none=True) def get(self): '''A List with the details of all suspicious instances ''' @@ -37,3 +39,32 @@ class SusInstances(Resource): if self.args.domains: return {"domains": [instance["domain"] for instance in sus_instances]},200 return {"instances": sus_instances},200 + + +class Instances(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("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) + @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) + 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 + diff --git a/overseer/classes/__init__.py b/overseer/classes/__init__.py new file mode 100644 index 0000000..d8ab436 --- /dev/null +++ b/overseer/classes/__init__.py @@ -0,0 +1,31 @@ +import os +from loguru import logger +from overseer.argparser import args +from importlib import import_module +from overseer.flask import db, OVERSEER +from overseer.utils import hash_api_key + +# Importing for DB creation +from overseer.classes.instance import Instance, Guarantee + +with OVERSEER.app_context(): + + db.create_all() + + admin_domain = os.getenv("ADMIN_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")), + open_registrations=False, + email_verify=False, + ) + admin.create() + guarantee = Guarantee( + guarantor_id = admin.id, + guaranteed_id = admin.id, + ) + db.session.add(guarantee) + db.session.commit() \ No newline at end of file diff --git a/overseer/classes/instance.py b/overseer/classes/instance.py new file mode 100644 index 0000000..853aaf5 --- /dev/null +++ b/overseer/classes/instance.py @@ -0,0 +1,68 @@ +import uuid +import os + +import dateutil.relativedelta +from datetime import datetime +from sqlalchemy import Enum, UniqueConstraint +from sqlalchemy.dialects.postgresql import UUID + +from loguru import logger +from overseer.flask import db, SQLITE_MODE + +uuid_column_type = lambda: UUID(as_uuid=True) if not SQLITE_MODE else db.String(36) + + +class Guarantee(db.Model): + __tablename__ = "guarantees" + id = db.Column(db.Integer, primary_key=True) + guarantor_id = db.Column(db.Integer, db.ForeignKey("instances.id", ondelete="CASCADE"), nullable=False) + guarantor_instance = db.relationship("Instance", back_populates="guarantees", foreign_keys=[guarantor_id]) + guaranteed_id = db.Column(db.Integer, db.ForeignKey("instances.id", ondelete="CASCADE"), unique=True, nullable=False) + guaranteed_instance = db.relationship("Instance", back_populates="guarantors", foreign_keys=[guaranteed_id]) + + +class Endorsement(db.Model): + __tablename__ = "endorsements" + __table_args__ = (UniqueConstraint('approving_id', 'endorsed_id', name='endoresements_approving_id_endorsed_id'),) + id = db.Column(db.Integer, primary_key=True) + approving_id = db.Column(db.Integer, db.ForeignKey("instances.id", ondelete="CASCADE"), nullable=False) + approving_instance = db.relationship("Instance", back_populates="approvals", foreign_keys=[approving_id]) + endorsed_id = db.Column(db.Integer, db.ForeignKey("instances.id", ondelete="CASCADE"), nullable=False) + endorsed_instance = db.relationship("Instance", back_populates="endorsements", foreign_keys=[endorsed_id]) + + +class Instance(db.Model): + __tablename__ = "instances" + + id = db.Column(db.Integer, primary_key=True) + domain = db.Column(db.String(255), unique=True, nullable=False) + 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) + + 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]) + guarantees = db.relationship("Guarantee", back_populates="guarantor_instance", cascade="all, delete-orphan", foreign_keys=[Guarantee.guarantor_id]) + guarantors = db.relationship("Guarantee", back_populates="guaranteed_instance", cascade="all, delete-orphan", foreign_keys=[Guarantee.guaranteed_id]) + + def create(self): + db.session.add(self) + db.session.commit() + + def get_details(self): + 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, + } + return ret_dict + + def get_guarantor(self): + guarantee = self.guarantors[0] + return Instance.query.filter_by(id=guarantee.guarantor_id).first() diff --git a/overseer/classes/news.py b/overseer/classes/news.py new file mode 100644 index 0000000..6d6044e --- /dev/null +++ b/overseer/classes/news.py @@ -0,0 +1,449 @@ +from datetime import datetime + +class News: + + HORDE_NEWS = [ + { + "date_published": "2023-06-01", + "newspiece": + "LoRas support has now been merged into the main worker branch! " + "All kudos to [Jug](https://github.com/jug-dev/) and [Tazlin](https://github.com/tazlin/) for their invaluable efforts! " + "Read the [devlog](https://dbzer0.com/blog/the-ai-horde-now-seamlessly-provides-all-civitai-loras/)", + "tags": ["devlog", "lora", "text2img"], + "importance": "Workers", + }, + { + "date_published": "2023-05-30", + "newspiece": + "Early support for LoRa has been added to the AI Horde with a few workers providing it. " + "UIs are still adding it, with [Lucid Creations](https://dbzer0.itch.io/lucid-creations/devlog/537949/1170-loras), ArtBot and the Krita plugin already supporting it." + "Try it out and let us know how it works for you.", + "tags": ["UI", "lora", "text2img"], + "importance": "Information", + }, + { + "date_published": "2023-05-25", + "newspiece": + "I wanted to point out a very cool voice-2-text-2-voice AI Horde integration: [ProtoReplicant](https://github.com/OpenReplicant/ProtoReplicant). " + "It converts your voice into text which it then sends to an LLM model, and finally converts the resulting text into voice and plays it back." + "Here's the new [Discord integration channel](https://discordapp.com/channels/781145214752129095/1111189841120596008)", + "tags": ["UI", "voice", "llm"], + "importance": "Integration", + }, + { + "date_published": "2023-05-22", + "newspiece": + "A new AI Horde integration has been created. A Telegram bot by the name of [Imaginarium](https://t.me/ImaginariumAIbot). " + "Here's the new [Discord integration channel](https://discordapp.com/channels/781145214752129095/1109825249933000714)", + "tags": ["bot", "telegram"], + "importance": "Integration", + }, + { + "date_published": "2023-05-14", + "newspiece": + "The AI Horde has finally moved to the [hordelib](https://pypi.org/project/hordelib/) library. " + "Which is powered by the [ComfyUI](https://github.com/comfyanonymous/ComfyUI) inference backend. " + "[Read the Devlog](https://dbzer0.com/blog/the-ai-horde-worker-moves-to-a-completely-new-inference-backend/)!", + "tags": ["devlog", "backend", "Jug", "Tazlin", "dreamer", "alchemist"], + "importance": "Information", + }, + { + "date_published": "2023-05-11", + "newspiece": + "With the upcoming deployment of the [hordelib](https://pypi.org/project/hordelib/)-based worker. " + "[Jug](https://github.com/jug-dev/) has looked into creating a more efficient model to determine generation kudos " + "instead of reusing the numbers I hallucinated one day. " + "He used what we know best and we trained an explicit model to calculate kudos, based on the performance of his own GPU on the comfy branch " + "This new calculation should be much more accurate in terms of things like controlnet and resolution impact. " + "The good news is that the new comfy branch this seems to reduce kudos costs for high resolutions accross the board. " + "Note: Due to the current worker (based on nataili) being slightly lower quality at the benefit of speed, and thus getting a boost due to the new kudos model, " + "we have implemented a 25% reduction for its rewards to bring it up to line with its actual performance.", + "tags": ["kudos", "dreamer", "Jug"], + "importance": "Workers", + }, + { + "date_published": "2023-05-09", + "newspiece": + "A new feature appeared on the Horde. " + "You can now create [API keys you can share with others](https://dbzer0.com/blog/key-sharing/) to use your own priority.", + "tags": ["apikey", "shared key"], + "importance": "Information", + }, + { + "date_published": "2023-05-05", + "newspiece": + "You can now run an AI Horde worker inside a docker container. " + "http://ghcr.io/db0/ai-horde-worker:main " + "Our README [contains information on how to configure it](https://github.com/db0/AI-Horde-Worker/blob/main/README.md#docker) " + "All kudos to [Gus Puffy#8887](https://github.com/guspuffygit)", + "tags": ["docker", "dreamer"], + "importance": "Workers", + }, + { + "date_published": "2023-04-23", + "newspiece": + "The Command Line Interface for the AI Horde has now been extended to support Image Generation, Text Generation and Image Alchemy. " + "It has been split into three files and is now available in its own repository: " + "https://github.com/db0/AI-Horde-CLI", + "tags": ["cli"], + "importance": "Information", + }, + { + "date_published": "2023-04-16", + "newspiece": + "The AI Horde has received its first patreon sponsorship " + "Many thanks to [pawkygame VR](https://discord.gg/Zbe63QTU9X) for their support!", + "tags": ["sponsor", "patreon"], + "importance": "Information", + }, + { + "date_published": "2023-03-23", + "newspiece": + "Inpainting is re-enabled that to the work of [ResidentChief](https://github.com/ResidentChief)! " + "Now also have support for multiple inpainting models.", + "tags": ["inpainting", "ResidentChief"], + "importance": "Information", + }, + { + "date_published": "2023-03-19", + "newspiece": + "The AI Horde Interrogator Worker has now been renamed to 'Alchemist' " + "The Horde alchemist can now run all the post-processors, along with all the interrogation forms. " + "This means that if you have an existing image you wish to face-fix or upscale, you can just do that " + "by requesting it via alchemy. " + "For now, the alchemist does not support extracting ControlNet intermediate images, but this will be coming soon. " + "The endpoints remain as `api/v2interrogation/` for now but I plan to rename them in v3.", + "tags": ["upscale", "post-processing", "alchemy"], + "importance": "Information", + }, + { + "date_published": "2023-03-15", + "newspiece": + "the AI Horde now supports the DDIM sampler and the RealESRGAN_x4plus_anime_6B upscaler! " + "Keep in mind that you cannot use two upscalers at the same time. " + "All kudos to [ResidentChief](https://github.com/ResidentChief)!", + "tags": ["upscale", "post-processing", "ResidentChief", "samplers"], + "importance": "Information", + }, + { + "date_published": "2023-03-13", + "newspiece": + "A new option `replacement_filter` is available for image generations. " + "When set to True and a potential CSAM prompt is detected, " + "all underage context will be transparently replaced or removed " + "and some extra negative prompts will be added to the negative prompt." + "When set to False (default) or the prompt size is over 500 chars " + "The previous behaviour will be used, where the prompt is rejected and an IP timeout will be put in place. " + "This feature should make sending text generations to be turned into images a less frustrating experience.", + "tags": ["csam", "text2text", "text2img"], + "importance": "Information", + }, + { + "date_published": "2023-03-10", + "newspiece": "We now have an AI-driven anti-CSAM filter as well. Read about it on [the main developer's blog](https://dbzer0.com/blog/ai-powered-anti-csam-filter-for-stable-diffusion/).", + "tags": ["csam"], + "importance": "Information", + }, + { + "date_published": "2023-03-03", + "newspiece": "The Horde Ratings are back in action. Go to your typical UI and rate away!", + "tags": ["ratings"], + "importance": "Information", + }, + { + "date_published": "2023-02-23", + "newspiece": "KoboldAI Horde has been merged into Stable Horde as a unified AI Horde!", + "tags": ["text2text", "ai horde"], + "importance": "Information", + }, + { + "date_published": "2023-02-21", + "newspiece": ( + 'The Horde now supports ControlNet on all models! All kudos go to [hlky](https://github.com/hlky) who again weaved the dark magic!' + ), + "tags": ["controlnet", "img2img", "hlky"], + "importance": "Information" + }, + { + "date_published": "2023-02-14", + "newspiece": ( + 'You can now use an almost unlimited prompt size thanks to the work of ResidentChief!' + ), + "tags": ["text2img", "img2img", "ResidentChief"], + "importance": "Information" + }, + { + "date_published": "2023-02-09", + "newspiece": ( + 'You can now select to generate a higher-sized image using hires_fix, which uses the composition of stable diffusion at 512x512 which tends to be more consistent.' + ), + "tags": ["text2img", "img2img", "ResidentChief"], + "importance": "Information" + }, + { + "date_published": "2023-02-03", + "newspiece": ( + 'The horde now supports pix2pix. All you have to do is use img2img as normal and select the pix2pix model!' + ), + "tags": ["img2img", "ResidentChief"], + "importance": "Information" + }, + { + "date_published": "2023-01-24", + "newspiece": ( + 'We now support sending tiling requests! Send `"tiling":true` into your payload params to request an image that seamlessly tiles.' + ), + "tags": ["text2img", "img2img", "ResidentChief"], + "importance": "Information" + }, + { + "date_published": "2023-01-23", + "newspiece": ( + "I have tightened the rules around NSFW models. As they seem to be straying into 'unethical' territory even when not explicitly prompted, " + "I am forced to tighten the safety controls around them. From now on, otherwise generic terms for young people like `girl` ,`boy` etc " + "Cannot be used on those models. Please either use terms like `woman` or `man` or switch to a non-NSFW model instead." + ), + "tags": ["countermeasures", "nsfw"], + "importance": "Information" + }, + { + "date_published": "2023-01-23", + "newspiece": ( + "The horde now has a [Blender Plugin](https://github.com/benrugg/AI-Render)!" + ), + "tags": ["plugin", "blender"], + "importance": "Information" + }, + { + "date_published": "2023-01-18", + "newspiece": ( + "We now have a [New Discord Bot](https://github.com/ZeldaFan0225/Stable_Horde_Discord), courtesy of Zelda_Fan#0225. Check out [their other bot](https://slashbot.de/) as well! " + "Only downside is that if you were already logged in to the old bot, you will need to /login again." + ), + "importance": "Information" + }, + { + "date_published": "2023-01-18", + "newspiece": ( + "The prompts now support weights! Use them like so `(sub prompt:1.1)` where 1.1 corresponds to +10% weight " + "You can tweak upwards more like `1.25` or downwards like `0.7`, but don't go above +=30%" + ), + "importance": "Information" + }, + { + "date_published": "2023-01-12", + "newspiece": ( + "We plan to be replacing our official discord bot with [new a new codebase](https://github.com/ZeldaFan0225/Stable_Horde_Discord) based on the work of Zelda_Fan#0225. " + "Once we do, be aware that the controls will be slightly different and you will have to log-in again with your API key." + ), + "importance": "Upcoming" + }, + { + "date_published": "2023-01-11", + "newspiece": ( + "The Stable Horde has its first browser extension! " + "[GenAlt](https://chrome.google.com/webstore/detail/genalt-generated-alt-text/ekbmkapnmnhhgfmjdnchgmcfggibebnn) is an accessibility plugin to help people with bad eyesight always find alt text for images." + "The extension relies on the Stable Horde's newly added image interrogation capabilities to generate captions which are then serves as the image's alt text." + ), + "importance": "Information" + }, + { + "date_published": "2023-01-04", + "newspiece": "We are proud to announce that we have [initiated a collaboration with LAION](https://dbzer0.com/blog/a-collaboration-begins-between-stable-horde-and-laion/) to help them improve their dataset!", + "importance": "Information" + }, + { + "date_published": "2023-01-06", + "newspiece": ( + "The amount of kudos consumed when generating images [has been slightly adjusted](https://dbzer0.com/blog/sharing-is-caring/). " + "To simulate the resource costs of the horde, each image generation request will now burn +3 kudos. Those will not go to the generating worker! " + "However we also have a new opt-in feature: You can choose to share your text2img generations with [LAION](https://laion.ai/). " + "If you do, this added cost will be just +1 kudos. " + "We have also updated our Terms of Service to make this more obvious." + ), + "importance": "Information" + }, + { + "date_published": "2023-01-05", + "newspiece": "[Worker now have a WebUI](https://dbzer0.com/blog/the-ai-horde-worker-has-a-control-ui/) which they can use to configure themselves. Use it by running `worker-webui.sh/cmd`", + "importance": "Workers" + }, + { + "date_published": "2023-01-04", + "newspiece": "[You can now interrogate images](https://dbzer0.com/blog/image-interrogations-are-now-available-on-the-stable-horde/) (AKA img2txt) to retrieve information about them such as captions and whether they are NSFW. Check the api/v2/interrogate endpoint documentation.", + "importance": "Information" + }, + { + "date_published": "2023-01-01", + "newspiece": "Stable Horde can now be used on the automatic1111 Web UI via [an external script](https://github.com/natanjunges/stable-diffusion-webui-stable-horde)", + "importance": "Information" + }, + { + "date_published": "2022-12-30", + "newspiece": "Stable Horde now supports depth2img! To use it you need to send a source image and select the `Stable Difffusion 2 Depth` model", + "importance": "Information" + }, + { + "date_published": "2022-12-28", + "newspiece": "Stable Horde workers can now opt-in to loading post-processors. Check your bridge_data.py for options. This should help workers who started being more unstable due to the PP requirements.", + "importance": "Workers" + }, + { + "date_published": "2022-12-24", + "newspiece": "Stable Horde has now support for [CodeFormer](https://shangchenzhou.com/projects/CodeFormer/). Simply use 'CodeFormers' for your postprocessor (case sensitive). This will fix any faces in the image. Be aware that due to the processing cost of this model, the kudos requirement will be 50% higher! Note: The inbuilt upscaler has been disabled", + "importance": "Information" + }, + { + "date_published": "2022-12-08", + "newspiece": "The Stable Horde workers now support dynamically swapping models. This means that models will always switch to support the most in demand models every minute, allowing us to support demand much better!", + "importance": "Information" + }, + { + "date_published": "2022-11-28", + "newspiece": "The Horde has undertaken a massive code refactoring to allow me to move to a proper SQL DB. This will finally allow me to scale the frontend systems horizontally and allow for way more capacity!", + "importance": "Information" + }, + { + "date_published": "2022-11-24", + "newspiece": "Due to the massive increase in demand from the Horde, we have to limit the amount of concurrent anonymous requests we can serve. We will revert this once our infrastructure can scale better.", + "importance": "Crisis" + }, + { + "date_published": "2022-11-24", + "newspiece": "Stable Diffusion 2.0 has been released and now it is available on the Horde as well.", + "importance": "Information" + }, + { + "date_published": "2022-11-22", + "newspiece": "A new Stable Horde Bot has been deployed, this time for Mastodon. You can find [the stablehorde_generator}(https://sigmoid.social/@stablehorde_generator) as well as our [official Stable Horde account](https://sigmoid.social/@stablehorde) on sigmoid.social", + "importance": "Information" + }, + { + "date_published": "2022-11-22", + "newspiece": "We now have [support for the Unreal Engine](https://github.com/Mystfit/Unreal-StableDiffusionTools/releases/tag/v0.5.0) via a community-provided plugin", + "importance": "Information" + }, + { + "date_published": "2022-11-18", + "newspiece": "The stable horde [now supports post-processing](https://www.patreon.com/posts/post-processing-74815675) on images automatically", + "importance": "Information" + }, + { + "date_published": "2022-11-05", + "newspiece": "Due to suddenly increased demand, we have adjusted how much requests accounts can request before needing to have the kudos upfront. More than 50 steps will require kudos and the max resolution will be adjusted based on the current horde demand.", + "importance": "Information" + }, + { + "date_published": "2022-11-05", + "newspiece": "Workers can now [join teams](https://www.patreon.com/posts/teams-74247978) to get aggregated stats.", + "importance": "Information" + }, + { + "date_published": "2022-11-02", + "newspiece": "The horde can now generate images up to 3072x3072 and 500 steps! However you need to already have the kudos to burn to do so!", + "importance": "Information" + }, + { + "date_published": "2022-10-29", + "newspiece": "Inpainting is now available on the stable horde! Many kudos to [blueturtle](https://github.com/blueturtleai) for the support!", + "importance": "Information" + }, + { + "date_published": "2022-10-25", + "newspiece": "Another [Discord Bot for Stable Horde integration](https://github.com/ZeldaFan0225/Stable_Horde_Discord) has appeared!", + "importance": "Information" + }, + { + "date_published": "2022-10-24", + "newspiece": "The Stable Horde Client has been renamed to [Lucid Creations](https://dbzer0.itch.io/lucid-creations) and has a new version and UI out which supports multiple models and img2img!", + "importance": "Information" + }, + { + "date_published": "2022-10-22", + "newspiece": "We have [a new npm SDK](https://github.com/ZeldaFan0225/stable_horde) for integrating into the Stable Horde.", + "importance": "Information" + }, + { + "date_published": "2022-10-22", + "newspiece": "Krita and GIMP plugins now support img2img", + "importance": "Information" + }, + { + "date_published": "2022-10-21", + "newspiece": "Image 2 Image is now available for everyone!", + "importance": "Information" + }, + { + "date_published": "2022-10-20", + "newspiece": "Stable Diffusion 1.5 is now available!", + "importance": "Information" + }, + { + "date_published": "2022-10-17", + "newspiece": "We now have [a Krita plugin](https://github.com/blueturtleai/krita-stable-diffusion).", + "importance": "Information" + }, + { + "date_published": "2022-10-17", + "newspiece": "Img2img on the horde is now on pilot for trusted users.", + "importance": "Information" + }, + { + "date_published": "2022-10-16", + "newspiece": "Yet [another Web UI](https://tinybots.net/artbot) has appeared.", + "importance": "Information" + }, + { + "date_published": "2022-10-11", + "newspiece": "A [new dedicated Web UI](https://aqualxx.github.io/stable-ui/) has entered the scene!", + "importance": "Information" + }, + { + "date_published": "2022-10-10", + "newspiece": "You can now contribute a worker to the horde [via google colab](https://colab.research.google.com/github/harrisonvanderbyl/ravenbot-ai/blob/master/Horde.ipynb). Just fill-in your API key and run!", + "importance": "Information" + }, + { + "date_published": "2022-10-06", + "newspiece": "We have a [new installation video](https://youtu.be/wJrp5lpByCc) for both the Stable Horde Client and the Stable horde worker.", + "importance": "Information" + }, { + "date_published": "2023-01-23", + "newspiece": "All workers must start sending the `bridge_agent` key in their job pop payloads. See API documentation.", + "importance": "Workers" + }, + { + "date_published": "2022-10-10", + "newspiece": "The [discord rewards bot](https://www.patreon.com/posts/new-kind-of-73097166) has been unleashed. Reward good contributions to the horde directly from the chat!", + "importance": "Information" + }, + { + "date_published": "2022-10-13", + "newspiece": "KoboldAI Has been upgraded to the new countermeasures", + "tags": ["countermeasures", "ai horde"], + "importance": "Information", + }, + { + "date_published": "2022-10-09", + "newspiece": "The horde now includes News functionality. Also [In the API!](/api/v2/status/news)", + "importance": "Information" + }, + ] + + def get_news(self): + '''extensible function from gathering nodes from extensing classes''' + return(self.HORDE_NEWS) + + def sort_news(self, raw_news): + # unsorted_news = [] + # for piece in raw_news: + # piece_dict = { + # "date": datetime.strptime(piece["piece"], '%y-%m-%d'), + # "piece": piece["news"], + # } + # unsorted_news.append(piece_dict) + sorted_news = sorted(raw_news, key=lambda p: datetime.strptime(p["date_published"], '%Y-%m-%d'), reverse=True) + return(sorted_news) + + def sorted_news(self): + return(self.sort_news(self.get_news())) diff --git a/overseer/database/__init__.py b/overseer/database/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/overseer/database/functions.py b/overseer/database/functions.py new file mode 100644 index 0000000..0dd52c5 --- /dev/null +++ b/overseer/database/functions.py @@ -0,0 +1,12 @@ +import time +import uuid +import json +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.classes.instance import Instance + +def get_all_instances(): + return db.session.query(Instance).all() diff --git a/overseer/flask.py b/overseer/flask.py index c41644b..44dea2a 100644 --- a/overseer/flask.py +++ b/overseer/flask.py @@ -13,9 +13,9 @@ SQLITE_MODE = os.getenv("USE_SQLITE", "0") == "1" if SQLITE_MODE: logger.warning("Using SQLite for database") - OVERSEER.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///horde.db" + OVERSEER.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///overseer.db" else: - OVERSEER.config["SQLALCHEMY_DATABASE_URI"] = f"postgresql://postgres:{os.getenv('POSTGRES_PASS')}@{os.getenv('POSTGRES_URL')}" + OVERSEER.config["SQLALCHEMY_DATABASE_URI"] = os.getenv('POSTGRES_URI') OVERSEER.config['SQLALCHEMY_ENGINE_OPTIONS'] = { "pool_size": 50, "max_overflow": -1, diff --git a/overseer/utils.py b/overseer/utils.py new file mode 100644 index 0000000..6fca757 --- /dev/null +++ b/overseer/utils.py @@ -0,0 +1,103 @@ +import uuid +import bleach +import secrets +import hashlib +import os +import random +import regex as re +import json +from datetime import datetime +import dateutil.relativedelta +from loguru import logger +from overseer.flask import SQLITE_MODE + + +random.seed(random.SystemRandom().randint(0, 2**32 - 1)) + + +def count_digits(number): + digits = 1 + while number > 10: + number = number / 10 + digits += 1 + return digits + +class ConvertAmount: + + def __init__(self,amount,decimals = 1): + self.digits = count_digits(amount) + self.decimals = decimals + if self.digits < 4: + self.amount = round(amount, self.decimals) + self.prefix = '' + self.char = '' + elif self.digits < 7: + self.amount = round(amount / 1000, self.decimals) + self.prefix = 'kilo' + self.char = 'K' + elif self.digits < 10: + self.amount = round(amount / 1000000, self.decimals) + self.prefix = 'mega' + self.char = 'M' + elif self.digits < 13: + self.amount = round(amount / 1000000000, self.decimals) + self.prefix = 'giga' + self.char = 'G' + else: + self.amount = round(amount / 1000000000000, self.decimals) + self.prefix = 'tera' + self.char = 'T' + +def get_db_uuid(): + if SQLITE_MODE: + return str(uuid.uuid4()) + else: + return uuid.uuid4() + +def generate_client_id(): + return secrets.token_urlsafe(16) + +def sanitize_string(text): + santxt = bleach.clean(text).lstrip().rstrip() + return santxt + +def hash_api_key(unhashed_api_key): + salt = os.getenv("secret_key", "s0m3s3cr3t") # Note default here, just so it can run without env file + hashed_key = hashlib.sha256(salt.encode() + unhashed_api_key.encode()).hexdigest() + # logger.warning([os.getenv("secret_key", "s0m3s3cr3t"), hashed_key,unhashed_api_key]) + return hashed_key + + +def hash_dictionary(dictionary): + # Convert the dictionary to a JSON string + json_string = json.dumps(dictionary, sort_keys=True) + # Create a hash object + hash_object = hashlib.sha256(json_string.encode()) + # Get the hexadecimal representation of the hash + hash_hex = hash_object.hexdigest() + return hash_hex + +def get_expiry_date(): + return datetime.utcnow() + dateutil.relativedelta.relativedelta(minutes=+20) + +def get_random_seed(start_point=0): + '''Generated a random seed, using a random number unique per node''' + return random.randint(start_point, 2**32 - 1) + +def count_parentheses(s): + open_p = False + count = 0 + for c in s: + if c == "(": + open_p = True + elif c == ")" and open_p: + open_p = False + count += 1 + return count + +def validate_regex(regex_string): + try: + re.compile(regex_string, re.IGNORECASE) + except: + return False + return True \ No newline at end of file From 39c5e5c423f8bf41208a4b7f082bdd11c888368c Mon Sep 17 00:00:00 2001 From: db0 Date: Wed, 21 Jun 2023 19:44:33 +0200 Subject: [PATCH 02/11] back to classics --- overseer/apis/v1/__init__.py | 4 ++-- overseer/apis/v1/base.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/overseer/apis/v1/__init__.py b/overseer/apis/v1/__init__.py index 7bb35b8..e456582 100644 --- a/overseer/apis/v1/__init__.py +++ b/overseer/apis/v1/__init__.py @@ -1,5 +1,5 @@ import overseer.apis.v1.base as base from overseer.apis.v1.base import api -api.add_resource(base.Suspicions, "/suspicions") -api.add_resource(base.Instances, "/instances") +api.add_resource(base.Suspicions, "/instances") +api.add_resource(base.Whitelist, "/whitelist") diff --git a/overseer/apis/v1/base.py b/overseer/apis/v1/base.py index f0c5de6..223db19 100644 --- a/overseer/apis/v1/base.py +++ b/overseer/apis/v1/base.py @@ -41,7 +41,7 @@ class Suspicions(Resource): return {"instances": sus_instances},200 -class Instances(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") From 40aa422656ac1deacc60178b8d793a24dc693f38 Mon Sep 17 00:00:00 2001 From: db0 Date: Thu, 22 Jun 2023 02:04:45 +0200 Subject: [PATCH 03/11] PM API key --- .env_template | 7 ++++ overseer/apis/models/v1.py | 2 +- overseer/apis/v1/base.py | 66 +++++++++++++++++++++++++++++++++--- overseer/classes/__init__.py | 5 +-- overseer/classes/instance.py | 8 +++-- overseer/exceptions.py | 23 +++++++++++++ overseer/lemmy.py | 24 +++++++++++++ requirements.txt | 4 +-- 8 files changed, 126 insertions(+), 13 deletions(-) create mode 100644 .env_template create mode 100644 overseer/exceptions.py create mode 100644 overseer/lemmy.py 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 From 75ddffb0c1858e66ec03351fa41b20bcf5a4c251 Mon Sep 17 00:00:00 2001 From: db0 Date: Thu, 22 Jun 2023 02:32:08 +0200 Subject: [PATCH 04/11] feat: patch --- overseer/apis/v1/base.py | 31 +++++++++++++++++++------------ overseer/database/functions.py | 14 ++++++++++++++ overseer/exceptions.py | 10 ++++++++++ 3 files changed, 43 insertions(+), 12 deletions(-) diff --git a/overseer/apis/v1/base.py b/overseer/apis/v1/base.py index 0467e85..11762ff 100644 --- a/overseer/apis/v1/base.py +++ b/overseer/apis/v1/base.py @@ -19,6 +19,8 @@ models = Models(api) handle_bad_request = api.errorhandler(e.BadRequest)(e.handle_bad_requests) handle_forbidden = api.errorhandler(e.Forbidden)(e.handle_bad_requests) +handle_unauthorized = api.errorhandler(e.Unauthorized)(e.handle_bad_requests) +handle_not_found = api.errorhandler(e.NotFound)(e.handle_bad_requests) # Used to for the flask limiter, to limit requests per url paths def get_request_path(): @@ -83,7 +85,9 @@ class Whitelist(Resource): @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 + '''Register a new instance to the overseer + An instance account has to exist in the overseer lemmy instance + That account will recieve the new API key via PM ''' self.args = self.put_parser.parse_args() existing_instance = Instance.query.filter_by(domain=self.args.domain).first() @@ -105,22 +109,25 @@ class Whitelist(Resource): 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("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") + patch_parser.add_argument("regenerate_key", required=False, type=bool, help="If True, will PM a new api key to this instance", location="json") - @api.expect(put_parser) - @logger.catch(reraise=True) + @api.expect(patch_parser) @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 + '''Regenerate API key for instance ''' 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 + 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) + if not instance: + raise e.BadRequest(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) + db.session.commit() + return instance.get_details(),200 diff --git a/overseer/database/functions.py b/overseer/database/functions.py index 0dd52c5..8333511 100644 --- a/overseer/database/functions.py +++ b/overseer/database/functions.py @@ -5,8 +5,22 @@ 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 overseer.classes.instance import Instance def get_all_instances(): return db.session.query(Instance).all() + + +def find_instance_by_api_key(api_key): + instance = Instance.query.filter_by(api_key=hash_api_key(api_key)).first() + return instance + +def find_instance_by_domain(domain): + instance = Instance.query.filter_by(domain=domain).first() + return instance + +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 diff --git a/overseer/exceptions.py b/overseer/exceptions.py index 8a30a1e..e567643 100644 --- a/overseer/exceptions.py +++ b/overseer/exceptions.py @@ -11,6 +11,16 @@ class Forbidden(wze.Forbidden): self.specific = message self.log = log +class Unauthorized(wze.Unauthorized): + def __init__(self, message, log=None): + self.specific = message + self.log = log + +class NotFound(wze.NotFound): + 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 From c21caa6d08cc883845241668463b3c69260d0793 Mon Sep 17 00:00:00 2001 From: db0 Date: Thu, 22 Jun 2023 02:37:25 +0200 Subject: [PATCH 05/11] delete instance --- overseer/apis/models/v1.py | 3 +++ overseer/apis/v1/base.py | 24 ++++++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/overseer/apis/models/v1.py b/overseer/apis/models/v1.py index 18fafda..8566d0a 100644 --- a/overseer/apis/models/v1.py +++ b/overseer/apis/models/v1.py @@ -5,6 +5,9 @@ class Models: self.response_model_error = api.model('RequestError', { 'message': fields.String(description="The error message for this status code."), }) + self.response_model_simple_response = api.model('SimpleResponse', { + "message": fields.String(default='OK',required=True, description="The result of this operation."), + }) self.response_model_suspicious_instances = api.model('SuspiciousInstances', { 'domain': fields.String(description="The instance domain"), 'uptime_alltime': fields.Float(description="The instance uptime pct. 100% and thousand of users is unlikely"), diff --git a/overseer/apis/v1/base.py b/overseer/apis/v1/base.py index 11762ff..1399e2c 100644 --- a/overseer/apis/v1/base.py +++ b/overseer/apis/v1/base.py @@ -131,3 +131,27 @@ class Whitelist(Resource): instance.api_key = hash_api_key(new_key) db.session.commit() return instance.get_details(),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") + delete_parser.add_argument("domain", required=False, type=str, help="The instance domain. It MUST be alredy registered in https://overctrl.dbzer0.com", location="json") + + + @api.expect(delete_parser) + @api.marshal_with(models.response_model_simple_response, code=200, description='Instances', skip_none=True) + def delete(self): + '''Delete instance from overseer + ''' + 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) + if not instance: + raise e.BadRequest(f"No Instance found matching provided API key and domain. Have you remembered to register it?") + if self.args.domain == os.getenv('OVERSEER_LEMMY_DOMAIN'): + raise e.Forbidden("Cannot delete overseer control instance") + db.session.delete(instance) + db.session.commit() + logger.warning(f"{self.args.domain} deleted") + return {"message":'OK'}, 200 From 51bd6e83bfd0bc8052c042429f25f5240833386c Mon Sep 17 00:00:00 2001 From: db0 Date: Thu, 22 Jun 2023 11:30:51 +0200 Subject: [PATCH 06/11] Add/Remove endorsements --- overseer/apis/models/v1.py | 5 +- overseer/apis/v1/__init__.py | 4 + overseer/apis/v1/base.py | 36 +++++++-- overseer/apis/v1/endorsements.py | 122 +++++++++++++++++++++++++++++++ overseer/classes/instance.py | 1 + overseer/database/functions.py | 60 ++++++++++++++- overseer/lemmy.py | 2 +- 7 files changed, 217 insertions(+), 13 deletions(-) create mode 100644 overseer/apis/v1/endorsements.py 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: From 30bce74a69166d5ae741c817c4c2f5919b2479cc Mon Sep 17 00:00:00 2001 From: db0 Date: Thu, 22 Jun 2023 15:40:28 +0200 Subject: [PATCH 07/11] guarantees --- overseer/apis/v1/__init__.py | 8 +- overseer/apis/v1/base.py | 131 +--------------------------- overseer/apis/v1/endorsements.py | 7 ++ overseer/apis/v1/guarantees.py | 143 +++++++++++++++++++++++++++++++ overseer/apis/v1/whitelist.py | 135 +++++++++++++++++++++++++++++ overseer/classes/instance.py | 18 +++- overseer/database/functions.py | 72 +++++++++++++++- overseer/lemmy.py | 14 +-- 8 files changed, 384 insertions(+), 144 deletions(-) create mode 100644 overseer/apis/v1/guarantees.py create mode 100644 overseer/apis/v1/whitelist.py diff --git a/overseer/apis/v1/__init__.py b/overseer/apis/v1/__init__.py index 12d8744..9c3e348 100644 --- a/overseer/apis/v1/__init__.py +++ b/overseer/apis/v1/__init__.py @@ -1,9 +1,13 @@ import overseer.apis.v1.base as base +import overseer.apis.v1.whitelist as whitelist import overseer.apis.v1.endorsements as endorsements +import overseer.apis.v1.guarantees as guarantees 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(whitelist.Whitelist, "/whitelist") +api.add_resource(whitelist.WhitelistDomain, "/whitelist/") api.add_resource(endorsements.Endorsements, "/endorsements/") api.add_resource(endorsements.Approvals, "/approvals/") +api.add_resource(guarantees.Guarantors, "/guarantors/") +api.add_resource(guarantees.Guarantees, "/guarantees/") diff --git a/overseer/apis/v1/base.py b/overseer/apis/v1/base.py index 3955eea..844a3c1 100644 --- a/overseer/apis/v1/base.py +++ b/overseer/apis/v1/base.py @@ -8,7 +8,7 @@ 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 overseer.lemmy import pm_new_api_key, pm_instance from pythorhead import Lemmy api = Namespace('v1', 'API Version 1' ) @@ -50,132 +50,3 @@ class Suspicions(Resource): return {"domains": [instance["domain"] for instance in sus_instances]},200 return {"instances": sus_instances},200 - -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=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") - - @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) - 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(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 - 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("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') - @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 - That account will recieve the new API key via PM - ''' - 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("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("regenerate_key", required=False, type=bool, help="If True, will PM a new api key to this instance", location="json") - - - @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_instance_by_api_key(self.args.apikey) - if not instance: - 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) - db.session.commit() - return instance.get_details(),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") - delete_parser.add_argument("domain", required=False, type=str, help="The instance domain. It MUST be alredy registered in https://overctrl.dbzer0.com", location="json") - - - @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 - ''' - 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) - if not instance: - raise e.BadRequest(f"No Instance found matching provided API key and domain. Have you remembered to register it?") - if self.args.domain == os.getenv('OVERSEER_LEMMY_DOMAIN'): - raise e.Forbidden("Cannot delete overseer control instance") - db.session.delete(instance) - db.session.commit() - logger.warning(f"{self.args.domain} deleted") - 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 index 08f1958..a3ae03f 100644 --- a/overseer/apis/v1/endorsements.py +++ b/overseer/apis/v1/endorsements.py @@ -77,7 +77,12 @@ class Endorsements(Resource): raise e.Forbidden("Only guaranteed instances can endorse others.") if instance.domain == domain: raise e.BadRequest("Nice try, but you can't endorse yourself.") + unbroken_chain, chainbreaker = database.has_unbroken_chain(instance.id) + if not unbroken_chain: + raise e.Forbidden(f"Guarantee chain for this instance has been broken. Chain ends at {chainbreaker.domain}!") target_instance = database.find_instance_by_domain(domain=domain) + if len(target_instance.guarantors) == 0: + raise e.Forbidden("Not Guaranteed instances can be endorsed. Please guarantee for them, or find someone who will.") if not target_instance: raise e.BadRequest("Instance to endorse not found") if database.get_endorsement(target_instance.id,instance.id): @@ -88,6 +93,7 @@ class Endorsements(Resource): ) db.session.add(new_endorsement) db.session.commit() + pm_instance(target_instance.domain, f"Your instance has just been endorsed by {instance.domain}") logger.info(f"{instance.domain} Endorsed {domain}") return {"message":'Changed'}, 200 @@ -118,5 +124,6 @@ class Endorsements(Resource): return {"message":'OK'}, 200 db.session.delete(endorsement) db.session.commit() + pm_instance(target_instance.domain, f"Oh now. {instance.domain} has just withdrawn the endorsement of your instance") logger.info(f"{instance.domain} Withdrew endorsement from {domain}") return {"message":'Changed'}, 200 \ No newline at end of file diff --git a/overseer/apis/v1/guarantees.py b/overseer/apis/v1/guarantees.py new file mode 100644 index 0000000..5c2604d --- /dev/null +++ b/overseer/apis/v1/guarantees.py @@ -0,0 +1,143 @@ +from overseer.apis.v1.base import * +from overseer.classes.instance import Guarantee, Endorsement + +class Guarantors(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 guarantees 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 guaranteed in database.get_all_guaranteed_instances_by_guarantor_id(instance.id): + instance_details.append(guaranteed.get_details()) + if self.args.csv: + return {"csv": ",".join([guaranteed["domain"] for guaranteed in instance_details])},200 + if self.args.domains: + return {"domains": [guaranteed["domain"] for guaranteed in instance_details]},200 + return {"instances": instance_details},200 + +class Guarantees(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 instances guaranteeing for this 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 guarantor in database.get_all_guarantor_instances_by_guaranteed_id(instance.id): + instance_details.append(guarantor.get_details()) + if self.args.csv: + return {"csv": ",".join([guarantor["domain"] for guarantor in instance_details])},200 + if self.args.domains: + return {"domains": [guarantor["domain"] for guarantor in instance_details]},200 + logger.debug(database.get_guarantor_chain(instance.id)) + 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, 'Instance Not Guaranteed or Tartget instance Guaranteed by others', 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 guarantee others.") + unbroken_chain, chainbreaker = database.has_unbroken_chain(instance.id) + if not unbroken_chain: + raise e.Forbidden(f"Guarantee chain for this instance has been broken. Chain ends at {chainbreaker.domain}!") + target_instance = database.find_instance_by_domain(domain=domain) + if not target_instance: + raise e.BadRequest("Instance to endorse not found") + if database.get_guarantee(target_instance.id,instance.id): + return {"message":'OK'}, 200 + gdomain = target_instance.get_guarantor_domain() + if gdomain: + raise e.Forbidden("Target instance already guaranteed by {gdomain}") + new_guarantee = Guarantee( + guaranteed_id=target_instance.id, + guarantor_id=instance.id, + ) + db.session.add(new_guarantee) + # Guaranteed instances get their automatic first endorsement + new_endorsement = Endorsement( + approving_id=instance.id, + endorsed_id=target_instance.id, + ) + db.session.add(new_endorsement) + db.session.commit() + pm_instance(target_instance.domain, f"Congratulations! Your instance has just been guaranteed by {instance.domain}. This also comes with your first endorsement.") + logger.info(f"{instance.domain} Guaranteed for {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 guarantee + ''' + 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") + # If API key matches the target domain, we assume they want to remove the guarantee added to them to allow another domain to guarantee them + if instance.id == target_instance.id: + guarantee = instance.get_guarantee() + else: + guarantee = database.get_guarantee(target_instance.id,instance.id) + if not guarantee: + return {"message":'OK'}, 200 + # Removing a guarantee removes the endorsement + endorsement = database.get_endorsement(target_instance.id,instance.id) + if endorsement: + db.session.delete(endorsement) + db.session.delete(guarantee) + db.session.commit() + pm_instance(target_instance.domain, f"Attention! You guarantor instance {instance.domain} has withdrawn their backing.\n\nIMPORTANT: All your endorsements and guarantees will be deleted unless you manage to find a new guarantor within 24hours!") + logger.info(f"{instance.domain} Withdrew guarantee from {domain}") + return {"message":'Changed'}, 200 \ No newline at end of file diff --git a/overseer/apis/v1/whitelist.py b/overseer/apis/v1/whitelist.py new file mode 100644 index 0000000..8372ec4 --- /dev/null +++ b/overseer/apis/v1/whitelist.py @@ -0,0 +1,135 @@ +from overseer.apis.v1.base import * + +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=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") + + @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) + 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(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 + if self.args.domains: + return {"domains": [instance["domain"] for instance in instance_details]},200 + return {"instances": instance_details},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 + + + 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("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') + @api.response(400, 'Bad Request', models.response_model_error) + def put(self, domain): + '''Register a new instance to the overseer + An instance account has to exist in the overseer lemmylemmy instance + That account will recieve the new API key via PM + ''' + self.args = self.put_parser.parse_args() + existing_instance = Instance.query.filter_by(domain=domain).first() + if existing_instance: + return existing_instance.get_details(),200 + if domain.endswith("test.dbzer0.com"): + requested_lemmy = Lemmy(f"https://{domain}") + requested_lemmy._requestor.nodeinfo = {"software":{"name":"lemmy"}} + site = {"site_view":{"local_site":{"require_email_verification": True,"registration_mode":"open"}}} + else: + requested_lemmy = Lemmy(f"https://{domain}") + site = requested_lemmy.site.get() + if not site: + raise e.BadRequest(f"Error encountered while polling domain {domain}. Please check it's running correctly") + api_key = pm_new_api_key(domain) + if not api_key: + raise e.BadRequest("Failed to generate API Key") + new_instance = Instance( + domain=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("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("regenerate_key", required=False, type=bool, help="If True, will PM a new api key to this instance", location="json") + + + @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, domain): + '''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_instance_by_api_key(self.args.apikey) + if not instance: + 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(domain) + instance.api_key = hash_api_key(new_key) + db.session.commit() + return instance.get_details(),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='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, domain): + '''Delete instance from overseer + ''' + 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(domain, 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?") + if domain == os.getenv('OVERSEER_LEMMY_DOMAIN'): + raise e.Forbidden("Cannot delete overseer control instance") + db.session.delete(instance) + db.session.commit() + logger.warning(f"{domain} deleted") + return {"message":'Changed'}, 200 + diff --git a/overseer/classes/instance.py b/overseer/classes/instance.py index d5045fc..66f3cd4 100644 --- a/overseer/classes/instance.py +++ b/overseer/classes/instance.py @@ -54,7 +54,6 @@ class Instance(db.Model): db.session.commit() def get_details(self): - guarantor = self.get_guarantor() ret_dict = { "id": self.id, "domain": self.domain, @@ -62,12 +61,23 @@ class Instance(db.Model): "email_verify": self.email_verify, "endorsements": len(self.endorsements), "approvals": len(self.approvals), - "guarantor": guarantor.domain if guarantor else None, + "guarantor": self.get_guarantor_domain(), } return ret_dict - def get_guarantor(self): + + def get_guarantee(self): if len(self.guarantors) == 0: return None - guarantee = self.guarantors[0] + return self.guarantors[0] + + def get_guarantor(self): + guarantee = self.get_guarantee() + if not guarantee: + return None + return guarantee.guarantor_instance return Instance.query.filter_by(id=guarantee.guarantor_id).first() + + def get_guarantor_domain(self): + guarantor = self.get_guarantor() + return guarantor.domain if guarantor else None \ No newline at end of file diff --git a/overseer/database/functions.py b/overseer/database/functions.py index ead18bc..dda9012 100644 --- a/overseer/database/functions.py +++ b/overseer/database/functions.py @@ -8,7 +8,7 @@ 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, Endorsement, Guarantee def get_all_instances(min_endorsements = 0, min_guarantors = 1): query = db.session.query( @@ -57,6 +57,34 @@ def get_all_approving_instances_by_endorsed_id(endorsed_id): ) return query.all() +def get_all_guaranteed_instances_by_guarantor_id(guarantor_id): + query = db.session.query( + Instance + ).outerjoin( + Instance.guarantors, + ).options( + joinedload(Instance.guarantors), + ).filter( + Guarantee.guarantor_id == guarantor_id + ).group_by( + Instance.id + ) + return query.all() + +def get_all_guarantor_instances_by_guaranteed_id(guaranteed_id): + query = db.session.query( + Instance + ).outerjoin( + Instance.guarantees, + ).options( + joinedload(Instance.guarantees), + ).filter( + Guarantee.guaranteed_id == guaranteed_id + ).group_by( + Instance.id + ) + return query.all() + def find_instance_by_api_key(api_key): instance = Instance.query.filter_by(api_key=hash_api_key(api_key)).first() @@ -75,4 +103,44 @@ def get_endorsement(instance_id, endorsing_instance_id): endorsed_id=instance_id, approving_id=endorsing_instance_id, ) - return query.first() \ No newline at end of file + return query.first() + +def get_guarantee(instance_id, guarantor_id): + query = Guarantee.query.filter_by( + guaranteed_id=instance_id, + guarantor_id=guarantor_id, + ) + return query.first() + +def get_guarantor_chain(instance_id): + guarantors = set() + chainbreaker = None + query = Guarantee.query.filter_by( + guaranteed_id=instance_id, + ) + guarantor = query.first() + if not guarantor: + return set(),instance_id + guarantors.add(guarantor.guarantor_id) + if guarantor.guarantor_id != 0: + higher_guarantors, chainbreaker = get_guarantor_chain(guarantor.guarantor_id) + guarantors = higher_guarantors | guarantors + return guarantors,chainbreaker + +def has_unbroken_chain(instance_id): + guarantors, chainbreaker = get_guarantor_chain(instance_id) + if chainbreaker: + chainbreaker = Instance.query.filter_by(id=chainbreaker).first() + return 0 in guarantors,chainbreaker + +def get_guarantee_chain(instance_id): + query = Guarantee.query.filter_by( + guarantor_id=instance_id, + ) + guarantees = query.all() + if not guarantees: + return set() + guarantees_ids = set([g.guaranteed_id for g in guarantees]) + for gid in guarantees_ids: + guarantees_ids = guarantees_ids | get_guarantee_chain(gid) + return guarantees_ids diff --git a/overseer/lemmy.py b/overseer/lemmy.py index d779470..7c9fe24 100644 --- a/overseer/lemmy.py +++ b/overseer/lemmy.py @@ -10,15 +10,17 @@ 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\n\n{api_key}\n\nUse this to perform operations on the overseer." +def pm_instance(domain: str, message: str): 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: + pm = overctrl_lemmy.private_message(message,domain_user["person_view"]["person"]["id"]) + return pm + +def pm_new_api_key(domain: str): + api_key = secrets.token_urlsafe(16) + pm_content = f"The API Key for domain {domain} is\n\n{api_key}\n\nUse this to perform operations on the overseer." + if not pm_instance(domain, pm_content): raise e.BadRequest("API Key PM failed") return api_key - From 97e475157f66f738e6d60230c9bb863197a7bb6c Mon Sep 17 00:00:00 2001 From: db0 Date: Thu, 22 Jun 2023 16:03:10 +0200 Subject: [PATCH 08/11] inform about broken chains --- overseer/apis/v1/guarantees.py | 8 +++++++- overseer/database/functions.py | 6 ++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/overseer/apis/v1/guarantees.py b/overseer/apis/v1/guarantees.py index 5c2604d..776ca9a 100644 --- a/overseer/apis/v1/guarantees.py +++ b/overseer/apis/v1/guarantees.py @@ -100,6 +100,9 @@ class Guarantees(Resource): db.session.add(new_endorsement) db.session.commit() pm_instance(target_instance.domain, f"Congratulations! Your instance has just been guaranteed by {instance.domain}. This also comes with your first endorsement.") + orphan_ids = database.get_guarantee_chain(target_instance.id) + for orphan in database.get_instances_by_ids(orphan_ids): + pm_instance(orphan.domain, f"Phew! You guarantor chain has been repaired as {instance.domain} has guaranteed for {domain}.") logger.info(f"{instance.domain} Guaranteed for {domain}") return {"message":'Changed'}, 200 @@ -138,6 +141,9 @@ class Guarantees(Resource): db.session.delete(endorsement) db.session.delete(guarantee) db.session.commit() - pm_instance(target_instance.domain, f"Attention! You guarantor instance {instance.domain} has withdrawn their backing.\n\nIMPORTANT: All your endorsements and guarantees will be deleted unless you manage to find a new guarantor within 24hours!") + pm_instance(target_instance.domain, f"Attention! You guarantor instance {instance.domain} has withdrawn their backing.\n\nIMPORTANT: All your guarantees will be deleted unless you manage to find a new guarantor within 24hours!") + orphan_ids = database.get_guarantee_chain(target_instance.id) + for orphan in database.get_instances_by_ids(orphan_ids): + pm_instance(orphan.domain, f"Attention! You guarantor chain has been b broken because {instance.domain} has withdrawn their backing from {domain}.\n\nIMPORTANT: All your guarantees will be deleted unless the chain is repaired or you find a new guarantor within 24hours!") logger.info(f"{instance.domain} Withdrew guarantee from {domain}") return {"message":'Changed'}, 200 \ No newline at end of file diff --git a/overseer/database/functions.py b/overseer/database/functions.py index dda9012..18ed57a 100644 --- a/overseer/database/functions.py +++ b/overseer/database/functions.py @@ -144,3 +144,9 @@ def get_guarantee_chain(instance_id): for gid in guarantees_ids: guarantees_ids = guarantees_ids | get_guarantee_chain(gid) return guarantees_ids + +def get_instances_by_ids(instance_ids): + query = Instance.query.filter( + Instance.id.in_(instance_ids) + ) + return query \ No newline at end of file From 6978bcc5edaa19ef1d91d783a72479aff5ef4831 Mon Sep 17 00:00:00 2001 From: db0 Date: Thu, 22 Jun 2023 16:29:58 +0200 Subject: [PATCH 09/11] orphan control --- overseer/apis/v1/endorsements.py | 2 +- overseer/apis/v1/guarantees.py | 11 +++++++++-- overseer/classes/instance.py | 14 +++++++++++++- overseer/database/functions.py | 5 +++++ 4 files changed, 28 insertions(+), 4 deletions(-) diff --git a/overseer/apis/v1/endorsements.py b/overseer/apis/v1/endorsements.py index a3ae03f..0093ef6 100644 --- a/overseer/apis/v1/endorsements.py +++ b/overseer/apis/v1/endorsements.py @@ -38,7 +38,7 @@ class Endorsements(Resource): @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 + '''Display all endorsements received by a specific domain ''' self.args = self.get_parser.parse_args() instance = database.find_instance_by_domain(domain) diff --git a/overseer/apis/v1/guarantees.py b/overseer/apis/v1/guarantees.py index 776ca9a..daffa16 100644 --- a/overseer/apis/v1/guarantees.py +++ b/overseer/apis/v1/guarantees.py @@ -103,6 +103,7 @@ class Guarantees(Resource): orphan_ids = database.get_guarantee_chain(target_instance.id) for orphan in database.get_instances_by_ids(orphan_ids): pm_instance(orphan.domain, f"Phew! You guarantor chain has been repaired as {instance.domain} has guaranteed for {domain}.") + orphan.unset_as_orphan() logger.info(f"{instance.domain} Guaranteed for {domain}") return {"message":'Changed'}, 200 @@ -141,9 +142,15 @@ class Guarantees(Resource): db.session.delete(endorsement) db.session.delete(guarantee) db.session.commit() - pm_instance(target_instance.domain, f"Attention! You guarantor instance {instance.domain} has withdrawn their backing.\n\nIMPORTANT: All your guarantees will be deleted unless you manage to find a new guarantor within 24hours!") + pm_instance(target_instance.domain, + f"Attention! You guarantor instance {instance.domain} has withdrawn their backing.\n\n" + "IMPORTANT: You are still considered guaranteed for the next 24hours, but you cannot further endorse or guarantee others." + "If you find a new guarantor then your guarantees will be reactivated!.\n\n" + "Note that if you do not find a guarantor within 7 days, all your endorsements will be removed." + ) orphan_ids = database.get_guarantee_chain(target_instance.id) for orphan in database.get_instances_by_ids(orphan_ids): - pm_instance(orphan.domain, f"Attention! You guarantor chain has been b broken because {instance.domain} has withdrawn their backing from {domain}.\n\nIMPORTANT: All your guarantees will be deleted unless the chain is repaired or you find a new guarantor within 24hours!") + pm_instance(orphan.domain, f"Attention! You guarantor chain has been broken because {instance.domain} has withdrawn their backing from {domain}.\n\nIMPORTANT: All your guarantees will be deleted unless the chain is repaired or you find a new guarantor within 24hours!") + orphan.set_as_oprhan() logger.info(f"{instance.domain} Withdrew guarantee 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 66f3cd4..c88b21b 100644 --- a/overseer/classes/instance.py +++ b/overseer/classes/instance.py @@ -19,6 +19,7 @@ class Guarantee(db.Model): guarantor_instance = db.relationship("Instance", back_populates="guarantees", foreign_keys=[guarantor_id]) guaranteed_id = db.Column(db.Integer, db.ForeignKey("instances.id", ondelete="CASCADE"), unique=True, nullable=False) guaranteed_instance = db.relationship("Instance", back_populates="guarantors", foreign_keys=[guaranteed_id]) + created = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) class Endorsement(db.Model): @@ -29,6 +30,7 @@ class Endorsement(db.Model): approving_instance = db.relationship("Instance", back_populates="approvals", foreign_keys=[approving_id]) endorsed_id = db.Column(db.Integer, db.ForeignKey("instances.id", ondelete="CASCADE"), nullable=False) endorsed_instance = db.relationship("Instance", back_populates="endorsements", foreign_keys=[endorsed_id]) + created = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) class Instance(db.Model): @@ -39,6 +41,7 @@ class Instance(db.Model): 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) + oprhan_since = db.Column(db.DateTime, nullable=True) open_registrations = db.Column(db.Boolean, unique=False, nullable=False, index=True) email_verify = db.Column(db.Boolean, unique=False, nullable=False, index=True) @@ -80,4 +83,13 @@ class Instance(db.Model): def get_guarantor_domain(self): guarantor = self.get_guarantor() - return guarantor.domain if guarantor else None \ No newline at end of file + return guarantor.domain if guarantor else None + + def set_as_oprhan(self): + self.oprhan_since = datetime.utcnow() + db.session.commit() + + def unset_as_orphan(self): + self.oprhan_since = None + db.session.commit() + \ No newline at end of file diff --git a/overseer/database/functions.py b/overseer/database/functions.py index 18ed57a..04fb7de 100644 --- a/overseer/database/functions.py +++ b/overseer/database/functions.py @@ -21,6 +21,11 @@ def get_all_instances(min_endorsements = 0, min_guarantors = 1): joinedload(Instance.endorsements), ).group_by( Instance.id + ).filter( + or_( + Instance.oprhan_since == None, + Instance.oprhan_since > datetime.utcnow() - timedelta(hours=24) + ) ).having( db.func.count(Instance.endorsements) >= min_endorsements, ).having( From 14f50b979f75dc65198c35c46c4cf19da1e9a24b Mon Sep 17 00:00:00 2001 From: db0 Date: Thu, 22 Jun 2023 16:36:53 +0200 Subject: [PATCH 10/11] mail guarantor --- overseer/apis/v1/whitelist.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/overseer/apis/v1/whitelist.py b/overseer/apis/v1/whitelist.py index 8372ec4..4339585 100644 --- a/overseer/apis/v1/whitelist.py +++ b/overseer/apis/v1/whitelist.py @@ -57,9 +57,14 @@ class WhitelistDomain(Resource): That account will recieve the new API key via PM ''' self.args = self.put_parser.parse_args() - existing_instance = Instance.query.filter_by(domain=domain).first() + existing_instance = database.find_instance_by_domain(domain) if existing_instance: return existing_instance.get_details(),200 + guarantor_instance = None + if self.args.guarantor: + guarantor_instance = database.find_instance_by_domain(self.args.guarantor) + if not guarantor_instance: + raise e.BadRequest(f"Requested guarantor domain {self.args.guarantor} is not registered with the Overseer yet!") if domain.endswith("test.dbzer0.com"): requested_lemmy = Lemmy(f"https://{domain}") requested_lemmy._requestor.nodeinfo = {"software":{"name":"lemmy"}} @@ -80,6 +85,8 @@ class WhitelistDomain(Resource): software=requested_lemmy.nodeinfo['software']['name'], ) new_instance.create() + if guarantor_instance: + pm_instance(guarantor_instance.domain, f"New instance {domain} was just registered with the Overseer and have asked you to guarantee for them!") return new_instance.get_details(),200 patch_parser = reqparse.RequestParser() From 68de94c5c78a0194ba19397136a0fe71a6a5ad13 Mon Sep 17 00:00:00 2001 From: db0 Date: Thu, 22 Jun 2023 16:42:42 +0200 Subject: [PATCH 11/11] feat: max 20 guarantees --- overseer/apis/v1/guarantees.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/overseer/apis/v1/guarantees.py b/overseer/apis/v1/guarantees.py index daffa16..c41b764 100644 --- a/overseer/apis/v1/guarantees.py +++ b/overseer/apis/v1/guarantees.py @@ -76,6 +76,8 @@ class Guarantees(Resource): 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 guarantee others.") + if len(instance.guarantors) >= 20 and instance.id != 0: + raise e.Forbidden("You cannot guarantee for more than 20 instances") unbroken_chain, chainbreaker = database.has_unbroken_chain(instance.id) if not unbroken_chain: raise e.Forbidden(f"Guarantee chain for this instance has been broken. Chain ends at {chainbreaker.domain}!")