feat: Rebuttals

pull/54/head
db0 2023-10-15 17:54:17 +02:00
parent 4ab98ec12b
commit d380f34364
9 changed files with 211 additions and 13 deletions

View File

@ -62,6 +62,7 @@ class Models:
self.response_model_instances_censured = api.inherit('CensuredInstanceDetails', self.response_model_instances, { self.response_model_instances_censured = api.inherit('CensuredInstanceDetails', self.response_model_instances, {
'censure_reasons': fields.List(fields.String(description="The reasons instances have given for censuring this instance")), 'censure_reasons': fields.List(fields.String(description="The reasons instances have given for censuring this instance")),
'censure_evidence': fields.List(fields.String(description="Evidence justifying this censure, typically should be one or more URLs.")), 'censure_evidence': fields.List(fields.String(description="Evidence justifying this censure, typically should be one or more URLs.")),
'rebuttal': fields.List(fields.String(description="Counter argument by the target instance.", example="Nuh uh!")),
'censure_count': fields.Integer(description="The amount of censures this instance has received from the reference instances"), 'censure_count': fields.Integer(description="The amount of censures this instance has received from the reference instances"),
}) })
self.response_model_model_Censures_get = api.model('CensuredInstances', { self.response_model_model_Censures_get = api.model('CensuredInstances', {
@ -80,6 +81,7 @@ class Models:
self.response_model_dubious_instances = api.inherit('DubiousInstanceDetails', self.response_model_instances, { self.response_model_dubious_instances = api.inherit('DubiousInstanceDetails', self.response_model_instances, {
'hesitation_reasons': fields.List(fields.String(description="The reasons instances have given for hesitating against this instance")), 'hesitation_reasons': fields.List(fields.String(description="The reasons instances have given for hesitating against this instance")),
'hesitation_evidence': fields.List(fields.String(description="Evidence justifying this hesitation, typically should be one or more URLs.")), 'hesitation_evidence': fields.List(fields.String(description="Evidence justifying this hesitation, typically should be one or more URLs.")),
'rebuttal': fields.List(fields.String(description="Counter argument by the target instance.", example="Nuh uh!")),
'hesitation_count': fields.Integer(description="The amount of hesitations this instance has received from the reference instances"), 'hesitation_count': fields.Integer(description="The amount of hesitations this instance has received from the reference instances"),
}) })
self.response_model_model_Hesitations_get = api.model('DubiousInstances', { self.response_model_model_Hesitations_get = api.model('DubiousInstances', {
@ -94,6 +96,9 @@ class Models:
'reason': fields.String(required=False, description="The reason for this censure/hesitation. No profanity or hate speech allowed!", example="csam"), 'reason': fields.String(required=False, description="The reason for this censure/hesitation. No profanity or hate speech allowed!", example="csam"),
'evidence': fields.String(required=False, description="The evidence for this censure/hesitation. Typically URL but can be a long form of anything you feel appropriate.", example="https://link.to/your/evidence", max_length=1000), 'evidence': fields.String(required=False, description="The evidence for this censure/hesitation. Typically URL but can be a long form of anything you feel appropriate.", example="https://link.to/your/evidence", max_length=1000),
}) })
self.input_rebuttals_modify = api.model('ModifyRebuttals', {
'rebuttal': fields.String(required=False, description="The counter-argument for this censure/hesitation.", example="Nuh uh!", max_length=1000),
})
self.response_model_api_key_reset = api.model('ApiKeyReset', { self.response_model_api_key_reset = api.model('ApiKeyReset', {
"message": fields.String(default='OK',required=True, description="The result of this operation."), "message": fields.String(default='OK',required=True, description="The result of this operation."),
"new_key": fields.String(default=None,required=False, description="The new API key"), "new_key": fields.String(default=None,required=False, description="The new API key"),

View File

@ -4,6 +4,7 @@ import fediseer.apis.v1.solicitations as solicitations
import fediseer.apis.v1.endorsements as endorsements import fediseer.apis.v1.endorsements as endorsements
import fediseer.apis.v1.censures as censures import fediseer.apis.v1.censures as censures
import fediseer.apis.v1.hesitations as hesitations import fediseer.apis.v1.hesitations as hesitations
import fediseer.apis.v1.rebuttals as rebuttals
import fediseer.apis.v1.guarantees as guarantees import fediseer.apis.v1.guarantees as guarantees
import fediseer.apis.v1.activitypub as activitypub import fediseer.apis.v1.activitypub as activitypub
import fediseer.apis.v1.badges as badges import fediseer.apis.v1.badges as badges
@ -27,6 +28,7 @@ api.add_resource(endorsements.BatchEndorsements, "/batch/endorsements")
api.add_resource(censures.Censures, "/censures/<string:domain>") api.add_resource(censures.Censures, "/censures/<string:domain>")
api.add_resource(censures.CensuresGiven, "/censures_given/<string:domains_csv>") api.add_resource(censures.CensuresGiven, "/censures_given/<string:domains_csv>")
api.add_resource(censures.BatchCensures, "/batch/censures") api.add_resource(censures.BatchCensures, "/batch/censures")
api.add_resource(rebuttals.Rebuttals, "/rebuttals/<string:domain>")
api.add_resource(hesitations.Hesitations, "/hesitations/<string:domain>") api.add_resource(hesitations.Hesitations, "/hesitations/<string:domain>")
api.add_resource(hesitations.HesitationsGiven, "/hesitations_given/<string:domains_csv>") api.add_resource(hesitations.HesitationsGiven, "/hesitations_given/<string:domains_csv>")
api.add_resource(hesitations.BatchHesitations, "/batch/hesitations") api.add_resource(hesitations.BatchHesitations, "/batch/hesitations")

View File

@ -154,6 +154,7 @@ class Censures(Resource):
if p_instance != get_instance: if p_instance != get_instance:
continue continue
instances.append(p_instance) instances.append(p_instance)
rebuttals = database.get_all_rebuttals_from_source_instance_id(instance.id,[c.id for c in instances])
for c_instance in instances: for c_instance in instances:
censures = database.get_all_censure_reasons_for_censured_id(instance.id, [c_instance.id]) censures = database.get_all_censure_reasons_for_censured_id(instance.id, [c_instance.id])
censures = [c for c in censures if c.reason is not None] censures = [c for c in censures if c.reason is not None]
@ -161,6 +162,7 @@ class Censures(Resource):
if len(censures) > 0: if len(censures) > 0:
c_instance_details["censure_reasons"] = [censure.reason for censure in censures] c_instance_details["censure_reasons"] = [censure.reason for censure in censures]
c_instance_details["censure_evidence"] = [censure.evidence for censure in censures if censure.evidence is not None] c_instance_details["censure_evidence"] = [censure.evidence for censure in censures if censure.evidence is not None]
c_instance_details["rebuttal"] = [r.rebuttal for r in rebuttals if r.target_id == c_instance.id]
instance_details.append(c_instance_details) instance_details.append(c_instance_details)
if self.args.csv: if self.args.csv:
return {"csv": ",".join([instance["domain"] for instance in instance_details])},200 return {"csv": ",".join([instance["domain"] for instance in instance_details])},200

View File

@ -140,6 +140,7 @@ class Hesitations(Resource):
continue continue
instances.append(p_instance) instances.append(p_instance)
instance_details = [] instance_details = []
rebuttals = database.get_all_rebuttals_from_source_instance_id(instance.id,[c.id for c in instances])
for c_instance in instances: for c_instance in instances:
hesitations = database.get_all_hesitation_reasons_for_dubious_id(instance.id, [c_instance.id]) hesitations = database.get_all_hesitation_reasons_for_dubious_id(instance.id, [c_instance.id])
hesitations = [c for c in hesitations if c.reason is not None] hesitations = [c for c in hesitations if c.reason is not None]
@ -147,6 +148,7 @@ class Hesitations(Resource):
if len(hesitations) > 0: if len(hesitations) > 0:
c_instance_details["hesitation_reasons"] = [hesitation.reason for hesitation in hesitations] c_instance_details["hesitation_reasons"] = [hesitation.reason for hesitation in hesitations]
c_instance_details["hesitation_evidence"] = [hesitation.evidence for hesitation in hesitations if hesitation.evidence is not None] c_instance_details["hesitation_evidence"] = [hesitation.evidence for hesitation in hesitations if hesitation.evidence is not None]
c_instance_details["rebuttal"] = [r.rebuttal for r in rebuttals if r.target_id == c_instance.id]
instance_details.append(c_instance_details) instance_details.append(c_instance_details)
if self.args.csv: if self.args.csv:
return {"csv": ",".join([instance["domain"] for instance in instance_details])},200 return {"csv": ",".join([instance["domain"] for instance in instance_details])},200

View File

@ -0,0 +1,162 @@
from fediseer.apis.v1.base import *
from fediseer.classes.instance import Rebuttal
from fediseer.utils import sanitize_string
from fediseer.classes.reports import Report
from fediseer import enums
class Rebuttals(Resource):
decorators = [limiter.limit("45/minute"), limiter.limit("30/minute", key_func = get_request_path)]
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("rebuttal", default=None, type=str, required=True, location="json")
@api.expect(put_parser,models.input_rebuttals_modify, validate=True)
@api.marshal_with(models.response_model_simple_response, code=200, description='Rebut Censure or Hesitation against your instance')
@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 not registered', models.response_model_error)
def put(self, domain):
'''Rebut a Censure or Hesitation against your instance
Use this to provide evidence against, or to initiate discussion
'''
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 admin 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 rebut.")
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)
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)
if not target_instance:
raise e.NotFound(f"Instance {domain} is not registered on the fediseer.")
if database.get_rebuttal(target_instance.id,instance.id):
return {"message":'OK'}, 200
censure = database.get_censure(instance.id, target_instance.id)
if not censure or target_instance.visibility_censures != enums.ListVisibility.OPEN:
hesitation = database.get_hesitation(instance.id, target_instance.id)
if not hesitation or target_instance.visibility_hesitations != enums.ListVisibility.OPEN:
raise e.BadRequest(f"Either no censure or hesitation from {domain} found towards {instance.domain}, or they are not openly visible.")
rebuttal_value = self.args.rebuttal
if rebuttal_value is not None:
rebuttal_value = sanitize_string(rebuttal_value)
new_rebuttal = Rebuttal(
source_id=instance.id,
target_id=target_instance.id,
rebuttal=rebuttal_value,
)
db.session.add(new_rebuttal)
target_domain = target_instance.domain
new_report = Report(
source_domain=instance.domain,
target_domain=target_domain,
report_type=enums.ReportType.REBUTTAL,
report_activity=enums.ReportActivity.ADDED,
)
db.session.add(new_report)
db.session.commit()
logger.info(f"{instance.domain} Rebutted {domain}")
return {"message":'Changed'}, 200
decorators = [limiter.limit("20/minute", key_func = get_request_path)]
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("rebuttal", default=None, type=str, required=False, location="json")
@api.expect(patch_parser,models.input_rebuttals_modify, validate=True)
@api.marshal_with(models.response_model_simple_response, code=200, description='Modify Rebuttal')
@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 patch(self, domain):
'''Modify a Rebuttal
'''
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 admin 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 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.")
target_instance = database.find_instance_by_domain(domain=domain)
if not target_instance:
raise e.BadRequest("Instance from which to modify censure not found")
rebuttal = database.get_rebuttal(target_instance.id,instance.id)
if not rebuttal:
raise e.BadRequest(f"No Rebuttal found for {domain} from {instance.domain}")
changed = False
rebuttal_value = self.args.rebuttal
if rebuttal_value is not None:
rebuttal_value = sanitize_string(rebuttal_value)
if rebuttal.rebuttal != rebuttal_value:
rebuttal.rebuttal = rebuttal_value
changed = True
if changed is False:
return {"message":'OK'}, 200
target_domain = target_instance.domain
if instance.visibility_censures != enums.ListVisibility.OPEN:
target_domain = '[REDACTED]'
new_report = Report(
source_domain=instance.domain,
target_domain=target_domain,
report_type=enums.ReportType.REBUTTAL,
report_activity=enums.ReportActivity.MODIFIED,
)
db.session.add(new_report)
db.session.commit()
logger.info(f"{instance.domain} modified rebuttal about {domain}")
return {"message":'Changed'}, 200
decorators = [limiter.limit("20/minute", key_func = get_request_path)]
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='Delete Rebuttal')
@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):
'''Delete a rebuttal
'''
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 admin 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 censure not found")
rebuttal = database.get_rebuttal(target_instance.id,instance.id)
if not rebuttal:
return {"message":'OK'}, 200
db.session.delete(rebuttal)
target_domain = target_instance.domain
new_report = Report(
source_domain=instance.domain,
target_domain=target_domain,
report_type=enums.ReportType.REBUTTAL,
report_activity=enums.ReportActivity.DELETED,
)
db.session.add(new_report)
db.session.commit()
logger.info(f"{instance.domain} delete rebuttal about {domain}")
return {"message":'Changed'}, 200

View File

@ -72,6 +72,17 @@ class Hesitation(db.Model):
dubious_instance = db.relationship("Instance", back_populates="hesitations_received", foreign_keys=[dubious_id]) dubious_instance = db.relationship("Instance", back_populates="hesitations_received", foreign_keys=[dubious_id])
created = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) created = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
class Rebuttal(db.Model):
__tablename__ = "rebuttals"
__table_args__ = (UniqueConstraint('source_id', 'target_id', name='rebuttal_source_id_target_id'),)
id = db.Column(db.Integer, primary_key=True)
rebuttal = db.Column(db.Text, unique=False, nullable=False, index=False)
source_id = db.Column(db.Integer, db.ForeignKey("instances.id", ondelete="CASCADE"), nullable=False, index=True)
source_instance = db.relationship("Instance", back_populates="rebuttals_given", foreign_keys=[source_id])
target_id = db.Column(db.Integer, db.ForeignKey("instances.id", ondelete="CASCADE"), nullable=False, index=True)
target_instance = db.relationship("Instance", back_populates="rebuttals_received", foreign_keys=[target_id])
created = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
class Solicitation(db.Model): class Solicitation(db.Model):
__tablename__ = "solicitations" __tablename__ = "solicitations"
__table_args__ = (UniqueConstraint('source_id', 'target_id', name='solicitations_source_id_target_id'),) __table_args__ = (UniqueConstraint('source_id', 'target_id', name='solicitations_source_id_target_id'),)
@ -134,6 +145,8 @@ class Instance(db.Model):
censures_received = db.relationship("Censure", back_populates="censured_instance", cascade="all, delete-orphan", foreign_keys=[Censure.censured_id]) censures_received = db.relationship("Censure", back_populates="censured_instance", cascade="all, delete-orphan", foreign_keys=[Censure.censured_id])
hesitations_given = db.relationship("Hesitation", back_populates="hesitating_instance", cascade="all, delete-orphan", foreign_keys=[Hesitation.hesitant_id]) hesitations_given = db.relationship("Hesitation", back_populates="hesitating_instance", cascade="all, delete-orphan", foreign_keys=[Hesitation.hesitant_id])
hesitations_received = db.relationship("Hesitation", back_populates="dubious_instance", cascade="all, delete-orphan", foreign_keys=[Hesitation.dubious_id]) hesitations_received = db.relationship("Hesitation", back_populates="dubious_instance", cascade="all, delete-orphan", foreign_keys=[Hesitation.dubious_id])
rebuttals_given = db.relationship("Rebuttal", back_populates="source_instance", cascade="all, delete-orphan", foreign_keys=[Rebuttal.source_id])
rebuttals_received = db.relationship("Rebuttal", back_populates="target_instance", cascade="all, delete-orphan", foreign_keys=[Rebuttal.target_id])
solicitations_requested = db.relationship("Solicitation", back_populates="source_instance", cascade="all, delete-orphan", foreign_keys=[Solicitation.source_id]) solicitations_requested = db.relationship("Solicitation", back_populates="source_instance", cascade="all, delete-orphan", foreign_keys=[Solicitation.source_id])
solicitations_received = db.relationship("Solicitation", back_populates="target_instance", cascade="all, delete-orphan", foreign_keys=[Solicitation.target_id]) solicitations_received = db.relationship("Solicitation", back_populates="target_instance", cascade="all, delete-orphan", foreign_keys=[Solicitation.target_id])
guarantees = db.relationship("Guarantee", back_populates="guarantor_instance", cascade="all, delete-orphan", foreign_keys=[Guarantee.guarantor_id]) guarantees = db.relationship("Guarantee", back_populates="guarantor_instance", cascade="all, delete-orphan", foreign_keys=[Guarantee.guarantor_id])

View File

@ -4,7 +4,7 @@ from sqlalchemy import func, or_, and_, not_, Boolean
from fediseer.flask import db from fediseer.flask import db
from fediseer.utils import hash_api_key from fediseer.utils import hash_api_key
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload
from fediseer.classes.instance import Instance, Endorsement, Guarantee, RejectionRecord, Censure, Hesitation, Solicitation, InstanceFlag, InstanceTag from fediseer.classes.instance import Instance, Endorsement, Guarantee, RejectionRecord, Censure, Hesitation, Solicitation, InstanceFlag, InstanceTag, Rebuttal
from fediseer.classes.user import Claim, User from fediseer.classes.user import Claim, User
from fediseer.classes.reports import Report from fediseer.classes.reports import Report
from fediseer import enums from fediseer import enums
@ -93,10 +93,8 @@ def get_all_approving_instances_by_endorsed_id(endorsed_id):
def get_all_endorsement_reasons_for_endorsed_id(endorsed_id, approving_ids): def get_all_endorsement_reasons_for_endorsed_id(endorsed_id, approving_ids):
query = Endorsement.query.filter( query = Endorsement.query.filter(
and_(
Endorsement.endorsed_id == endorsed_id, Endorsement.endorsed_id == endorsed_id,
Endorsement.approving_id.in_(approving_ids), Endorsement.approving_id.in_(approving_ids),
)
).with_entities( ).with_entities(
Endorsement.reason, Endorsement.reason,
) )
@ -146,10 +144,8 @@ def get_all_censuring_instances_by_censured_id(censured_id):
def get_all_censure_reasons_for_censured_id(censured_id, censuring_ids): def get_all_censure_reasons_for_censured_id(censured_id, censuring_ids):
query = Censure.query.filter( query = Censure.query.filter(
and_(
Censure.censured_id == censured_id, Censure.censured_id == censured_id,
Censure.censuring_id.in_(censuring_ids), Censure.censuring_id.in_(censuring_ids),
)
).with_entities( ).with_entities(
Censure.reason, Censure.reason,
Censure.evidence, Censure.evidence,
@ -200,17 +196,31 @@ def get_all_hesitant_instances_by_dubious_id(dubious_id):
def get_all_hesitation_reasons_for_dubious_id(dubious_id, hesitant_ids): def get_all_hesitation_reasons_for_dubious_id(dubious_id, hesitant_ids):
query = Hesitation.query.filter( query = Hesitation.query.filter(
and_(
Hesitation.dubious_id == dubious_id, Hesitation.dubious_id == dubious_id,
Hesitation.hesitant_id.in_(hesitant_ids), Hesitation.hesitant_id.in_(hesitant_ids),
)
).with_entities( ).with_entities(
Hesitation.reason, Hesitation.reason,
Hesitation.evidence, Hesitation.evidence,
) )
return query.all() return query.all()
def get_rebuttal(target_instance_id, source_instance_id):
query = Rebuttal.query.filter_by(
target_id=target_instance_id,
source_id=source_instance_id,
)
return query.first()
def get_all_rebuttals_from_source_instance_id(source_instance_id, target_ids = None):
query = Rebuttal.query.filter(
Rebuttal.source_id == source_instance_id,
).with_entities(
Rebuttal.rebuttal,
Rebuttal.target_id,
)
if target_ids is not None:
query = query.filter(Rebuttal.target_id.in_(target_ids))
return query.all()
def get_all_guaranteed_instances_by_guarantor_id(guarantor_id): def get_all_guaranteed_instances_by_guarantor_id(guarantor_id):
query = db.session.query( query = db.session.query(

View File

@ -8,6 +8,7 @@ class ReportType(enum.Enum):
CLAIM = 4 CLAIM = 4
SOLICITATION = 5 SOLICITATION = 5
FLAG = 6 FLAG = 6
REBUTTAL = 7
class ReportActivity(enum.Enum): class ReportActivity(enum.Enum):
ADDED = 0 ADDED = 0

View File

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