Feat: Instance flags (#36)

pull/39/head
Divided by Zer0 2023-09-26 12:06:30 +02:00 committed by GitHub
parent 3fb1647640
commit 85b19dc50c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 241 additions and 13 deletions

View File

@ -1,5 +1,9 @@
# Changelog
# 0.18.0
* Added instance flags
# 0.17.1
* Prevent endorsement PMs being sent when visibility is private

View File

@ -42,10 +42,15 @@ class Models:
'moderators': fields.Integer(required=False, default=None, description="The count of community moderators in this instance as reported by its admins."),
'state': fields.String(required=True, enum=[e.name for e in enums.InstanceState], description="The state of the instance as seen from the fediseer."),
})
self.response_model_flag_details = api.model('FlagDetails', {
'flag': fields.String(required=True, enum=[e.name for e in enums.InstanceFlags], description="The type of flag"),
'comment': fields.String(required=False, description="A comment explaining this flag", example="admin"),
})
self.response_model_instances_visibility = api.inherit('InstanceVisibilityDetails', self.response_model_instances, {
'visibility_endorsements': fields.String(required=True, enum=[e.name for e in enums.ListVisibility], description="If OPEN, this instance allows anyone to read this instance's endorsements. When set to ENDORSED, only endorsed instances can see their endorsements. If set to PRIVATE allow this instance's own admins can see their endorsements."),
'visibility_censures': fields.String(required=True, enum=[e.name for e in enums.ListVisibility], description="If OPEN, this instance allows anyone to read this instance's censures. When set to ENDORSED, only endorsed instances can see their censures. If set to PRIVATE allow this instance's own admins can see their censures."),
'visibility_hesitations': fields.String(required=True, enum=[e.name for e in enums.ListVisibility], description="If OPEN, this instance allows anyone to read this instance's hesitations. When set to ENDORSED, only endorsed instances can see their hesitations. If set to PRIVATE allow this instance's own admins can see their hesitations."),
'flags': fields.List(fields.Nested(self.response_model_flag_details)),
})
self.response_model_model_Whitelist_get = api.model('WhitelistedInstances', {
'instances': fields.List(fields.Nested(self.response_model_instances_visibility)),
@ -126,3 +131,7 @@ class Models:
'domains': fields.List(fields.String(description="The instance domains as a list.")),
'csv': fields.String(description="The instance domains as a csv."),
})
self.input_flag_modify = api.model('FlagModify', {
'flag': fields.String(required=True, enum=[e.name for e in enums.InstanceFlags], description="The type of flag to apply"),
'comment': fields.String(max_length=255, required=False, description="A comment explaining this flag", example="reasons"),
})

View File

@ -9,6 +9,7 @@ import fediseer.apis.v1.activitypub as activitypub
import fediseer.apis.v1.badges as badges
import fediseer.apis.v1.find as find
import fediseer.apis.v1.report as report
import fediseer.apis.v1.admin as admin
from fediseer.apis.v1.base import api
api.add_resource(base.Suspicions, "/instances")
@ -28,4 +29,5 @@ api.add_resource(guarantees.Guarantors, "/guarantors/<string:domain>")
api.add_resource(guarantees.Guarantees, "/guarantees/<string:domain>")
api.add_resource(badges.GuaranteeBadge, "/badges/guarantees/<string:domain>.svg")
api.add_resource(badges.EndorsementBadge, "/badges/endorsements/<string:domain>.svg")
api.add_resource(admin.Flag, "/admin/flags/<string:domain>")
api.add_resource(report.Report, "/reports")

View File

@ -0,0 +1,148 @@
from fediseer.apis.v1.base import *
from fediseer.messaging import activitypub_pm
from fediseer import enums
from fediseer.classes.reports import Report
from fediseer.register import ensure_instance_registered
from fediseer.classes.instance import InstanceFlag
class Flag(Resource):
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")
put_parser.add_argument("comment", required=False, type=str, help="provide a reasoning for this flag", location="json")
put_parser.add_argument("flag", required=True, type=str, help="The flag to apply", location="json")
@api.expect(put_parser,models.input_flag_modify, validate=True)
@api.marshal_with(models.response_model_simple_response, code=200, description='Action Result')
@api.response(400, 'Bad Request', models.response_model_error)
@api.response(401, 'Invalid API Key', models.response_model_error)
@api.response(403, 'Access Denied', models.response_model_error)
def put(self, domain):
'''Flag an 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 the admin account")
user = database.find_user_by_api_key(self.args.apikey)
if not user:
raise e.Forbidden("Only a fediseer admin can modify instance flags")
if user.account != "@fediseer@fediseer.com":
raise e.Forbidden("Only a fediseer admin can modify instance flags")
admin_instance = database.find_instance_by_user(user)
target_instance, instance_info = ensure_instance_registered(domain)
flag = enums.InstanceFlags[self.args.flag]
if database.instance_has_flag(target_instance.id,flag):
return {"message": "OK"},200
new_flag = InstanceFlag(
instance_id = target_instance.id,
flag = flag,
comment = self.args.comment,
)
db.session.add(new_flag)
if flag == enums.InstanceFlags.RESTRICTED and not database.instance_has_flag(target_instance.id,enums.InstanceFlags.MUTED):
muted_flag = InstanceFlag(
instance_id = target_instance.id,
flag = enums.InstanceFlags.MUTED,
comment = "Restricted with reason: " + self.args.comment,
)
db.session.add(muted_flag)
# Sactioned instances get no visibility
if flag in [enums.InstanceFlags.MUTED,enums.InstanceFlags.RESTRICTED]:
target_instance.visibility_censures = enums.ListVisibility.PRIVATE
target_instance.visibility_endorsements = enums.ListVisibility.PRIVATE
target_instance.visibility_hesitations = enums.ListVisibility.PRIVATE
new_report = Report(
source_domain=admin_instance.domain,
target_domain=target_instance.domain,
report_type=enums.ReportType.FLAG,
report_activity=enums.ReportActivity.ADDED,
)
db.session.add(new_report)
db.session.commit()
return {"message": "Changed"},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("comment", required=False, type=str, help="provide a reasoning for this flag", location="json")
patch_parser.add_argument("flag", required=False, type=str, help="The flag to apply", location="json")
@api.expect(patch_parser,models.input_flag_modify, validate=True)
@api.marshal_with(models.response_model_simple_response, code=200, description='Action Result')
@api.response(400, 'Bad Request', models.response_model_error)
@api.response(401, 'Invalid API Key', models.response_model_error)
@api.response(403, 'Access Denied', models.response_model_error)
@api.response(404, 'Instance or flag not found', models.response_model_error)
def patch(self, domain):
'''Modify an instance's flag
'''
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 the admin account")
user = database.find_user_by_api_key(self.args.apikey)
if not user:
raise e.Forbidden("Only a fediseer admin can modify instance flags")
if user.account != "@fediseer@fediseer.com":
raise e.Forbidden("Only a fediseer admin can modify instance flags")
admin_instance = database.find_instance_by_user(user)
target_instance, instance_info = ensure_instance_registered(domain)
flag = enums.InstanceFlags[self.args.flag]
existing_flag = database.get_instance_flag(target_instance.id,flag)
if not existing_flag:
raise e.NotFound(f"{flag.name} not found in {domain}")
if existing_flag.comment == self.args.comment:
return {"message": "OK"},200
existing_flag.comment = self.args.comment,
new_report = Report(
source_domain=admin_instance.domain,
target_domain=target_instance.domain,
report_type=enums.ReportType.FLAG,
report_activity=enums.ReportActivity.MODIFIED,
)
db.session.add(new_report)
db.session.commit()
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")
delete_parser.add_argument("flag", required=False, type=str, help="The flag to delete", 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, domain):
'''Delete an instance's flag
'''
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 the admin account")
user = database.find_user_by_api_key(self.args.apikey)
if not user:
raise e.Forbidden("Only a fediseer admin can delete instance flags")
if user.account != "@fediseer@fediseer.com":
raise e.Forbidden("Only a fediseer admin can delete instance flags")
admin_instance = database.find_instance_by_user(user)
target_instance, instance_info = ensure_instance_registered(domain)
flag = enums.InstanceFlags[self.args.flag]
existing_flag = database.get_instance_flag(target_instance.id,flag)
if not existing_flag:
return {"message": "OK"},200
db.session.delete(existing_flag)
new_report = Report(
source_domain=admin_instance.domain,
target_domain=target_instance.domain,
report_type=enums.ReportType.FLAG,
report_activity=enums.ReportActivity.DELETED,
)
db.session.add(new_report)
db.session.commit()
return {"message": "Changed"},200

View File

@ -165,7 +165,7 @@ class Censures(Resource):
@api.marshal_with(models.response_model_simple_response, code=200, description='Censure 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(403, 'Access Denied', models.response_model_error)
@api.response(404, 'Instance not registered', models.response_model_error)
def put(self, domain):
'''Censure an instance
@ -179,6 +179,8 @@ class Censures(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 censure others.")
if database.instance_has_flag(instance.id,enums.InstanceFlags.RESTRICTED):
raise e.Forbidden("You cannot take this action as your instance is restricted")
if instance.domain == domain:
raise e.BadRequest("You're a mad lad, but you can't censure yourself.")
if database.has_too_many_actions_per_min(instance.domain):

View File

@ -144,7 +144,7 @@ class Endorsements(Resource):
@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(403, 'Access Denied', models.response_model_error)
@api.response(404, 'Instance not registered', models.response_model_error)
def put(self, domain):
'''Endorse an instance
@ -158,6 +158,8 @@ class Endorsements(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 endorse others.")
if database.instance_has_flag(instance.id,enums.InstanceFlags.RESTRICTED):
raise e.Forbidden("You cannot take this action as your instance is restricted")
if instance.domain == domain:
raise e.BadRequest("Nice try, but you can't endorse yourself.")
if database.has_too_many_actions_per_min(instance.domain):

View File

@ -1,5 +1,5 @@
from fediseer.apis.v1.base import *
from fediseer.classes.instance import Guarantee, RejectionRecord, Solicitation
from fediseer.classes.instance import Guarantee, RejectionRecord, Solicitation, InstanceFlag
from fediseer.classes.reports import Report
from fediseer import enums
from fediseer.register import ensure_instance_registered
@ -69,7 +69,7 @@ class Guarantees(Resource):
@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(403, 'Access Denied', models.response_model_error)
@api.response(404, 'Instance not registered', models.response_model_error)
def put(self, domain):
'''Guarantee an instance
@ -87,6 +87,8 @@ class Guarantees(Resource):
raise e.Forbidden("Only guaranteed instances can guarantee others.")
if len(instance.guarantees) >= 20 and instance.id != 0:
raise e.Forbidden("You cannot guarantee for more than 20 instances")
if database.instance_has_flag(instance.id,enums.InstanceFlags.RESTRICTED):
raise e.Forbidden("You cannot take this action as your instance is restricted")
if database.has_too_many_actions_per_min(instance.domain):
raise e.TooManyRequests("Your instance is doing more than 20 actions per minute. Please slow down.")
unbroken_chain, chainbreaker = database.has_unbroken_chain(instance.id)
@ -105,6 +107,13 @@ class Guarantees(Resource):
guarantor_id=instance.id,
)
db.session.add(new_guarantee)
if database.instance_has_flag(instance.id,enums.InstanceFlags.MUTED):
muted_flag = InstanceFlag(
instance_id=target_instance.id,
flag=enums.InstanceFlags.MUTED,
comment=f"Inherited from guarantor {target_instance.domain}",
)
db.session.add(muted_flag)
database.delete_all_solicitation_by_source(target_instance.id)
new_report = Report(
source_domain=instance.domain,

View File

@ -151,7 +151,7 @@ class Hesitations(Resource):
@api.marshal_with(models.response_model_simple_response, code=200, description='Mistrust 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(403, 'Access Denied', models.response_model_error)
@api.response(404, 'Instance not registered', models.response_model_error)
def put(self, domain):
'''Hesitate against an instance
@ -165,6 +165,8 @@ class Hesitations(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 hesitation others.")
if database.instance_has_flag(instance.id,enums.InstanceFlags.RESTRICTED):
raise e.Forbidden("You cannot take this action as your instance is restricted")
if instance.domain == domain:
raise e.BadRequest("You're a mad lad, but you can't hesitation yourself.")
if database.has_too_many_actions_per_min(instance.domain):

View File

@ -159,7 +159,7 @@ class WhitelistDomain(Resource):
@api.marshal_with(models.response_model_api_key_reset, code=200, description='Instances', skip_none=True)
@api.response(401, 'Invalid API Key', models.response_model_error)
@api.response(403, 'Access Denied', models.response_model_error)
@api.response(403, 'Instance not claimed', models.response_model_error)
@api.response(404, 'Instance not claimed', models.response_model_error)
def patch(self, domain):
'''Regenerate API key for instance
'''
@ -174,7 +174,7 @@ class WhitelistDomain(Resource):
instance_to_reset = database.find_instance_by_domain(domain)
changed = False
new_key = None
if requestor_instance != instance_to_reset and user.username != "fediseer":
if requestor_instance != instance_to_reset and user.account != "@fediseer@fediseer.com":
raise e.Forbidden("Only an instance admin can modify the instance")
if self.args.sysadmins is not None and instance.sysadmins != self.args.sysadmins:
instance.sysadmins = self.args.sysadmins
@ -183,6 +183,8 @@ class WhitelistDomain(Resource):
instance.moderators = self.args.moderators
changed = True
if self.args.pm_proxy is not None:
if instance_to_reset is None:
raise e.NotFound(f"Instance {domain} has not been registered yet.")
proxy = enums.PMProxy[self.args.pm_proxy]
if instance_to_reset.software == "lemmy" and proxy == enums.PMProxy.MASTODON:
raise e.BadRequest("I'm sorry Dave, I can't let you do that. Lemmy is not capable of receiving mastodon PMs.")
@ -193,26 +195,32 @@ class WhitelistDomain(Resource):
if self.args.visibility_endorsements is not None:
visibility = enums.ListVisibility[self.args.visibility_endorsements]
if instance.visibility_endorsements != visibility:
if database.instance_has_flag(instance.id,enums.InstanceFlags.MUTED):
raise e.Forbidden("Muted instances cannot change their visibility away from private!")
instance.visibility_endorsements = visibility
changed = True
if self.args.visibility_censures is not None:
visibility = enums.ListVisibility[self.args.visibility_censures]
if instance.visibility_censures != visibility:
if database.instance_has_flag(instance.id,enums.InstanceFlags.MUTED):
raise e.Forbidden("Muted instances cannot change their visibility away from private!")
instance.visibility_censures = visibility
changed = True
if self.args.visibility_hesitations is not None:
visibility = enums.ListVisibility[self.args.visibility_hesitations]
if instance.visibility_hesitations != visibility:
if database.instance_has_flag(instance.id,enums.InstanceFlags.MUTED):
raise e.Forbidden("Muted instances cannot change their visibility away from private!")
instance.visibility_hesitations = visibility
changed = True
if self.args.admin_username:
requestor = None
if self.args.admin_username != user.username or user.username == "fediseer":
if self.args.admin_username != user.username or user.account == "@fediseer@fediseer.com":
requestor = user.username
instance_to_reset = database.find_instance_by_account(f"@{self.args.admin_username}@{domain}")
if instance_to_reset is None:
raise e.NotFound(f"No admin '{self.args.admin_username}' found in instance {domain}. Have you remembered to claim it as that admin?")
if instance != instance_to_reset and user.username != "fediseer":
if instance != instance_to_reset and user.account != "@fediseer@fediseer.com":
raise e.BadRequest("Only other admins of the same instance or the fediseer can request API key reset for others.")
instance = instance_to_reset

View File

@ -82,6 +82,16 @@ class Solicitation(db.Model):
target_instance = db.relationship("Instance", back_populates="solicitations_received", foreign_keys=[target_id])
created = db.Column(db.DateTime, default=datetime.utcnow, nullable=False, index=True)
class InstanceFlag(db.Model):
__tablename__ = "instance_flags"
__table_args__ = (UniqueConstraint('instance_id', 'flag', name='instance_flags_instance_id_flag'),)
id = db.Column(db.Integer, primary_key=True)
comment = db.Column(db.String(255), unique=False, nullable=True, index=False)
flag = db.Column(Enum(enums.InstanceFlags), nullable=False, index=True)
instance_id = db.Column(db.Integer, db.ForeignKey("instances.id", ondelete="CASCADE"), nullable=False, index=True)
instance = db.relationship("Instance", back_populates="flags")
created = db.Column(db.DateTime, default=datetime.utcnow, nullable=False, index=True)
class Instance(db.Model):
__tablename__ = "instances"
@ -117,6 +127,7 @@ class Instance(db.Model):
rejections = db.relationship("RejectionRecord", back_populates="rejector_instance", cascade="all, delete-orphan", foreign_keys=[RejectionRecord.rejector_id])
rejectors = db.relationship("RejectionRecord", back_populates="rejected_instance", cascade="all, delete-orphan", foreign_keys=[RejectionRecord.rejected_id])
admins = db.relationship("Claim", back_populates="instance", cascade="all, delete-orphan")
flags = db.relationship("InstanceFlag", back_populates="instance", cascade="all, delete-orphan")
def create(self):
db.session.add(self)
@ -147,6 +158,12 @@ class Instance(db.Model):
ret_dict["visibility_endorsements"] = self.visibility_endorsements.name
ret_dict["visibility_censures"] = self.visibility_censures.name
ret_dict["visibility_hesitations"] = self.visibility_hesitations.name
ret_dict["flags"] = []
for flag in self.flags:
ret_dict["flags"].append({
"flag": flag.flag.name,
"comment": flag.comment
})
return ret_dict
@ -197,4 +214,9 @@ class Instance(db.Model):
if self.poll_failures <= 30*POLLS_PER_DAY:
return enums.InstanceState.OFFLINE
return enums.InstanceState.DECIMMISSIONED
def has_flag(self, flag_enum):
for flag in self.flags:
if flag.flag == flag_enum:
return True
return False

View File

@ -1,4 +1,4 @@
FEDISEER_VERSION = "0.17.1"
FEDISEER_VERSION = "0.18.0"
SUPPORTED_SOFTWARE = {
"lemmy",
"mastodon",

View File

@ -8,7 +8,7 @@ from sqlalchemy.orm import noload
from fediseer.flask import db, SQLITE_MODE
from fediseer.utils import hash_api_key
from sqlalchemy.orm import joinedload
from fediseer.classes.instance import Instance, Endorsement, Guarantee, RejectionRecord, Censure, Hesitation, Solicitation
from fediseer.classes.instance import Instance, Endorsement, Guarantee, RejectionRecord, Censure, Hesitation, Solicitation, InstanceFlag
from fediseer.classes.user import Claim, User
from fediseer.classes.reports import Report
from fediseer import enums
@ -459,4 +459,18 @@ def has_too_many_actions_per_min(source_domain):
).filter(
Report.created > datetime.utcnow() - timedelta(minutes=1),
)
return query.count() > 20
return query.count() > 20
def get_instance_flag(instance_id, flag_enum):
query = InstanceFlag.query.filter(
InstanceFlag.instance_id == instance_id,
InstanceFlag.flag == flag_enum,
)
return query.first()
def instance_has_flag(instance_id, flag_enum):
query = InstanceFlag.query.filter(
InstanceFlag.instance_id == instance_id,
InstanceFlag.flag == flag_enum,
)
return query.count() == 1

View File

@ -7,6 +7,7 @@ class ReportType(enum.Enum):
HESITATION = 3
CLAIM = 4
SOLICITATION = 5
FLAG = 6
class ReportActivity(enum.Enum):
ADDED = 0
@ -27,3 +28,7 @@ class InstanceState(enum.Enum):
UNREACHABLE = 1
OFFLINE = 2
DECIMMISSIONED = 3
class InstanceFlags(enum.Enum):
RESTRICTED = 0
MUTED = 1

View File

@ -0,0 +1 @@
ALTER TYPE reporttype ADD VALUE 'FLAG';