feat: Hesitations

pull/21/head
db0 2023-09-13 01:24:32 +02:00
parent 92bc2a66df
commit 861d9f72bd
10 changed files with 339 additions and 10 deletions

View File

@ -1,5 +1,8 @@
# Changelog
# 0.12.0
* Added hesitations, which signify mistrust against instances. A softer form of censure, to use in silencing or closer attention instead of blocking.
# 0.11.1
* Fixed censure filtering reasons using "AND" instead of "OR" as join

View File

@ -54,9 +54,19 @@ 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_censures_modify = api.model('ModifyCensure', {
'reason': fields.String(required=False, description="The reason for this censure. No profanity or hate speech allowed!", example="csam"),
'evidence': fields.String(required=False, description="The evidence for this censure. Typically URL but can be a long form of anything you feel appropriate.", example="https://link.to/your/evidence", max_length=1000),
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_evidence': fields.List(fields.String(description="Evidence justifying this hesitation, typically should be one or more URLs.")),
'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', {
'instances': fields.List(fields.Nested(self.response_model_dubious_instances)),
'domains': fields.List(fields.String(description="The instance domains as a list.")),
'csv': fields.String(description="The instance domains as a csv."),
})
self.input_censures_modify = api.model('ModifyCensureHesitations', {
'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),
})
self.response_model_api_key_reset = api.model('ApiKeyReset', {
"message": fields.String(default='OK',required=True, description="The result of this operation."),

View File

@ -2,6 +2,7 @@ import fediseer.apis.v1.base as base
import fediseer.apis.v1.whitelist as whitelist
import fediseer.apis.v1.endorsements as endorsements
import fediseer.apis.v1.censures as censures
import fediseer.apis.v1.hesitations as hesitations
import fediseer.apis.v1.guarantees as guarantees
import fediseer.apis.v1.activitypub as activitypub
import fediseer.apis.v1.badges as badges
@ -19,6 +20,8 @@ api.add_resource(endorsements.Endorsements, "/endorsements/<string:domain>")
api.add_resource(endorsements.Approvals, "/approvals/<string:domains_csv>")
api.add_resource(censures.Censures, "/censures/<string:domain>")
api.add_resource(censures.CensuresGiven, "/censures_given/<string:domains_csv>")
api.add_resource(hesitations.Hesitations, "/hesitations/<string:domain>")
api.add_resource(hesitations.HesitationsGiven, "/hesitations_given/<string:domains_csv>")
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")

View File

@ -1,5 +1,5 @@
from fediseer.apis.v1.base import *
from fediseer.classes.instance import Censure,Endorsement
from fediseer.classes.instance import Censure
from fediseer.utils import sanitize_string
from fediseer.classes.reports import Report
from fediseer import enums
@ -116,7 +116,7 @@ class Censures(Resource):
@api.expect(put_parser,models.input_censures_modify, validate=True)
@api.marshal_with(models.response_model_simple_response, code=200, description='Endorse Instance')
@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)
@ -181,7 +181,7 @@ class Censures(Resource):
@api.expect(patch_parser,models.input_censures_modify, validate=True)
@api.marshal_with(models.response_model_simple_response, code=200, description='Endorse Instance')
@api.marshal_with(models.response_model_simple_response, code=200, description='Modify Instance Censure')
@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)
@ -233,7 +233,7 @@ class Censures(Resource):
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.marshal_with(models.response_model_simple_response, code=200, description='Withdraw Instance Censure')
@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)

View File

@ -0,0 +1,250 @@
from fediseer.apis.v1.base import *
from fediseer.classes.instance import Hesitation
from fediseer.utils import sanitize_string
from fediseer.classes.reports import Report
from fediseer import enums
class HesitationsGiven(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")
get_parser.add_argument("min_hesitations", required=False, default=1, type=int, help="Limit to this amount of hesitations of more", location="args")
get_parser.add_argument("reasons_csv", required=False, type=str, help="Only retrieve hesitations where their reasons include any of the text in this csv", location="args")
@api.expect(get_parser)
@cache.cached(timeout=10, query_string=True)
@api.marshal_with(models.response_model_model_Hesitations_get, code=200, description='Instances', skip_none=True)
@api.response(404, 'Instance not registered', models.response_model_error)
def get(self, domains_csv):
'''Display all hesitations given out by one or more domains
You can pass a comma-separated list of domain names
and the results will be a set of all their hesitations together.
'''
self.args = self.get_parser.parse_args()
domains_list = domains_csv.split(',')
instances = database.find_multiple_instance_by_domains(domains_list)
if not instances:
raise e.NotFound(f"No Instances found matching any of the provided domains. Have you remembered to register them?")
if self.args.min_hesitations > len(domains_list):
raise e.BadRequest(f"You cannot request more hesitations than the amount of reference domains")
instance_details = []
for c_instance in database.get_all_dubious_instances_by_hesitant_id([instance.id for instance in instances]):
hesitations = database.get_all_hesitation_reasons_for_dubious_id(c_instance.id, [instance.id for instance in instances])
hesitation_count = len(hesitations)
hesitations = [c for c in hesitations if c.reason is not None]
c_instance_details = c_instance.get_details()
skip_instance = True
if self.args.reasons_csv:
reasons_filter = [r.strip().lower() for r in self.args.reasons_csv.split(',')]
reasons_filter = set(reasons_filter)
for r in reasons_filter:
reason_filter_counter = 0
for hesitation in hesitations:
if r in hesitation.reason.lower():
reason_filter_counter += 1
if reason_filter_counter >= self.args.min_hesitations:
skip_instance = False
break
elif hesitation_count >= self.args.min_hesitations:
skip_instance = False
if skip_instance:
continue
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_count"] = hesitation_count
instance_details.append(c_instance_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 Hesitations(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_Hesitations_get, code=200, description='Instances', skip_none=True)
@api.response(404, 'Instance not registered', models.response_model_error)
def get(self, domain):
'''Display all hesitations received 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 c_instance in database.get_all_hesitant_instances_by_dubious_id(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]
c_instance_details = c_instance.get_details()
if len(hesitations) > 0:
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]
instance_details.append(c_instance_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")
put_parser.add_argument("reason", default=None, type=str, required=False, location="json")
put_parser.add_argument("evidence", default=None, type=str, required=False, location="json")
@api.expect(put_parser,models.input_censures_modify, validate=True)
@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(404, 'Instance not registered', models.response_model_error)
def put(self, domain):
'''Hesitate against an instance
A hesitation signifies a mistrust from your instance to how that instance is being run.
'''
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 hesitation others.")
if instance.domain == domain:
raise e.BadRequest("You're a mad lad, but you can't hesitation 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, nodeinfo, admin_usernames = ensure_instance_registered(domain, allow_unreachable=True)
if not target_instance:
raise e.NotFound(f"Something went wrong trying to register this instance.")
if not target_instance:
raise e.BadRequest("Instance to hesitation not found")
if database.get_endorsement(target_instance.id,instance.id):
raise e.BadRequest("You can't hesitate against an instance you've endorsed! Please withdraw the endorsement first.")
if database.get_hesitation(target_instance.id,instance.id):
return {"message":'OK'}, 200
reason = self.args.reason
if reason is not None:
reason = sanitize_string(reason)
evidence = self.args.evidence
if evidence is not None:
evidence = sanitize_string(evidence)
new_hesitation = Hesitation(
hesitant_id=instance.id,
dubious_id=target_instance.id,
reason=reason,
evidence=evidence,
)
db.session.add(new_hesitation)
new_report = Report(
source_domain=instance.domain,
target_domain=target_instance.domain,
report_type=enums.ReportType.HESITATION,
report_activity=enums.ReportActivity.ADDED,
)
db.session.add(new_report)
db.session.commit()
logger.info(f"{instance.domain} hesitated against {domain}")
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("reason", default=None, type=str, required=False, location="json")
patch_parser.add_argument("evidence", default=None, type=str, required=False, location="json")
@api.expect(patch_parser,models.input_censures_modify, validate=True)
@api.marshal_with(models.response_model_simple_response, code=200, description='Modify Instance Hesitation')
@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 an instance's Hesitation
'''
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?")
target_instance = database.find_instance_by_domain(domain=domain)
if not target_instance:
raise e.BadRequest("Instance from which to modify hesitation not found")
hesitation = database.get_hesitation(target_instance.id,instance.id)
if not hesitation:
raise e.BadRequest(f"No hesitation found for {domain} from {instance.domain}")
changed = False
reason = self.args.reason
if reason is not None:
reason = sanitize_string(reason)
if hesitation.reason != reason:
hesitation.reason = reason
changed = True
evidence = self.args.evidence
if evidence is not None:
evidence = sanitize_string(evidence)
if hesitation.evidence != evidence:
hesitation.evidence = evidence
changed = True
if changed is False:
return {"message":'OK'}, 200
new_report = Report(
source_domain=instance.domain,
target_domain=target_instance.domain,
report_type=enums.ReportType.HESITATION,
report_activity=enums.ReportActivity.MODIFIED,
)
db.session.add(new_report)
db.session.commit()
logger.info(f"{instance.domain} Modfied hesitation 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 Hesitation')
@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 hesitation
'''
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 hesitation not found")
hesitation = database.get_hesitation(target_instance.id,instance.id)
if not hesitation:
return {"message":'OK'}, 200
db.session.delete(hesitation)
new_report = Report(
source_domain=instance.domain,
target_domain=target_instance.domain,
report_type=enums.ReportType.HESITATION,
report_activity=enums.ReportActivity.DELETED,
)
db.session.add(new_report)
db.session.commit()
logger.info(f"{instance.domain} Withdrew hesitation from {domain}")
return {"message":'Changed'}, 200

View File

@ -54,5 +54,4 @@ class Report(Resource):
'created': r.created.isoformat() + 'Z',
}
)
logger.debug(report_response)
return report_response,200

View File

@ -60,6 +60,18 @@ class Censure(db.Model):
censured_instance = db.relationship("Instance", back_populates="censures_received", foreign_keys=[censured_id])
created = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
class Hesitation(db.Model):
__tablename__ = "hesitations"
__table_args__ = (UniqueConstraint('hesitant_id', 'dubious_id', name='hesitations_hesitant_id_dubious_id'),)
id = db.Column(db.Integer, primary_key=True)
reason = db.Column(db.String(255), unique=False, nullable=True, index=False)
evidence = db.Column(db.Text, unique=False, nullable=True, index=False)
hesitant_id = db.Column(db.Integer, db.ForeignKey("instances.id", ondelete="CASCADE"), nullable=False)
hesitating_instance = db.relationship("Instance", back_populates="hesitations_given", foreign_keys=[hesitant_id])
dubious_id = db.Column(db.Integer, db.ForeignKey("instances.id", ondelete="CASCADE"), nullable=False)
dubious_instance = db.relationship("Instance", back_populates="hesitations_received", foreign_keys=[dubious_id])
created = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
class Instance(db.Model):
__tablename__ = "instances"
@ -80,6 +92,8 @@ class Instance(db.Model):
endorsements = db.relationship("Endorsement", back_populates="endorsed_instance", cascade="all, delete-orphan", foreign_keys=[Endorsement.endorsed_id])
censures_given = db.relationship("Censure", back_populates="censuring_instance", cascade="all, delete-orphan", foreign_keys=[Censure.censuring_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_received = db.relationship("Hesitation", back_populates="dubious_instance", cascade="all, delete-orphan", foreign_keys=[Hesitation.dubious_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])
rejections = db.relationship("RejectionRecord", back_populates="rejector_instance", cascade="all, delete-orphan", foreign_keys=[RejectionRecord.rejector_id])

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
from fediseer.classes.instance import Instance, Endorsement, Guarantee, RejectionRecord, Censure, Hesitation
from fediseer.classes.user import Claim, User
from fediseer.classes.reports import Report
from fediseer import enums
@ -106,6 +106,47 @@ def get_all_censure_reasons_for_censured_id(censured_id, censuring_ids):
return query.all()
def get_all_dubious_instances_by_hesitant_id(hesitant_ids):
query = db.session.query(
Instance
).outerjoin(
Instance.hesitations_received,
).options(
joinedload(Instance.hesitations_received),
).filter(
Hesitation.hesitant_id.in_(hesitant_ids)
).group_by(
Instance.id
)
return query.all()
def get_all_hesitant_instances_by_dubious_id(dubious_id):
query = db.session.query(
Instance
).outerjoin(
Instance.hesitations_given,
).options(
joinedload(Instance.hesitations_given),
).filter(
Hesitation.dubious_id == dubious_id
).group_by(
Instance.id
)
return query.all()
def get_all_hesitation_reasons_for_dubious_id(dubious_id, hesitant_ids):
query = Hesitation.query.filter(
and_(
Hesitation.dubious_id == dubious_id,
Hesitation.hesitant_id.in_(hesitant_ids),
)
).with_entities(
Hesitation.reason,
Hesitation.evidence,
)
return query.all()
def get_all_guaranteed_instances_by_guarantor_id(guarantor_id):
query = db.session.query(
@ -231,6 +272,13 @@ def get_censure(instance_id, censuring_instance_id):
)
return query.first()
def get_hesitation(instance_id, hesitant_instance_id):
query = Hesitation.query.filter_by(
dubious_id=instance_id,
hesitant_id=hesitant_instance_id,
)
return query.first()
def has_recent_endorsement(instance_id):
query = Endorsement.query.filter(
Endorsement.endorsed_id == instance_id,

View File

@ -4,7 +4,7 @@ class ReportType(enum.Enum):
GUARANTEE = 0
ENDORSEMENT = 1
CENSURE = 2
RESTRICTION = 3
HESITATION = 3
class ReportActivity(enum.Enum):
ADDED = 0

View File

@ -0,0 +1,2 @@
ALTER TYPE reporttype
RENAME VALUE 'RESTRICTION' TO 'HESITATION';