Start support for manually approving followers

This commit is contained in:
Thomas Sileo 2022-08-02 20:14:40 +02:00
parent 9f3956db67
commit a1a9ec3f7c
10 changed files with 272 additions and 10 deletions

View file

@ -0,0 +1,34 @@
"""Tweak notification model
Revision ID: 1702e88016db
Revises: 50d26a370a65
Create Date: 2022-08-02 15:19:57.221421+00:00
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = '1702e88016db'
down_revision = '50d26a370a65'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('notifications', schema=None) as batch_op:
batch_op.add_column(sa.Column('is_accepted', sa.Boolean(), nullable=True))
batch_op.add_column(sa.Column('is_rejected', sa.Boolean(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('notifications', schema=None) as batch_op:
batch_op.drop_column('is_rejected')
batch_op.drop_column('is_accepted')
# ### end Alembic commands ###

View file

@ -95,7 +95,7 @@ ME = {
+ "/inbox", + "/inbox",
}, },
"url": config.ID, "url": config.ID,
"manuallyApprovesFollowers": False, "manuallyApprovesFollowers": config.CONFIG.manually_approves_followers,
"attachment": [], "attachment": [],
"icon": { "icon": {
"mediaType": mimetypes.guess_type(config.CONFIG.icon_url)[0], "mediaType": mimetypes.guess_type(config.CONFIG.icon_url)[0],

View file

@ -218,6 +218,7 @@ async def get_actors_metadata(
select(models.OutboxObject.ap_object, models.OutboxObject.ap_id).where( select(models.OutboxObject.ap_object, models.OutboxObject.ap_id).where(
models.OutboxObject.ap_type == "Follow", models.OutboxObject.ap_type == "Follow",
models.OutboxObject.undone_by_outbox_object_id.is_(None), models.OutboxObject.undone_by_outbox_object_id.is_(None),
models.OutboxObject.activity_object_ap_id.in_(ap_actor_ids),
) )
) )
} }

View file

@ -616,6 +616,30 @@ async def admin_actions_delete(
return RedirectResponse(redirect_url, status_code=302) return RedirectResponse(redirect_url, status_code=302)
@router.post("/actions/accept_incoming_follow")
async def admin_actions_accept_incoming_follow(
request: Request,
notification_id: int = Form(),
redirect_url: str = Form(),
csrf_check: None = Depends(verify_csrf_token),
db_session: AsyncSession = Depends(get_db_session),
) -> RedirectResponse:
await boxes.send_accept(db_session, notification_id)
return RedirectResponse(redirect_url, status_code=302)
@router.post("/actions/reject_incoming_follow")
async def admin_actions_reject_incoming_follow(
request: Request,
notification_id: int = Form(),
redirect_url: str = Form(),
csrf_check: None = Depends(verify_csrf_token),
db_session: AsyncSession = Depends(get_db_session),
) -> RedirectResponse:
await boxes.send_reject(db_session, notification_id)
return RedirectResponse(redirect_url, status_code=302)
@router.post("/actions/like") @router.post("/actions/like")
async def admin_actions_like( async def admin_actions_like(
request: Request, request: Request,

View file

@ -27,6 +27,7 @@ from app.actor import save_actor
from app.ap_object import RemoteObject from app.ap_object import RemoteObject
from app.config import BASE_URL from app.config import BASE_URL
from app.config import ID from app.config import ID
from app.config import MANUALLY_APPROVES_FOLLOWERS
from app.database import AsyncSession from app.database import AsyncSession
from app.outgoing_activities import new_outgoing_activity from app.outgoing_activities import new_outgoing_activity
from app.source import markdownify from app.source import markdownify
@ -654,6 +655,22 @@ async def _get_followers_recipients(
} }
async def get_notification_by_id(
db_session: AsyncSession, notification_id: int
) -> models.Notification | None:
return (
await db_session.execute(
select(models.Notification)
.where(models.Notification.id == notification_id)
.options(
joinedload(models.Notification.inbox_object).options(
joinedload(models.InboxObject.actor)
),
)
)
).scalar_one_or_none() # type: ignore
async def get_inbox_object_by_ap_id( async def get_inbox_object_by_ap_id(
db_session: AsyncSession, ap_id: str db_session: AsyncSession, ap_id: str
) -> models.InboxObject | None: ) -> models.InboxObject | None:
@ -832,6 +849,57 @@ async def _handle_follow_follow_activity(
from_actor: models.Actor, from_actor: models.Actor,
inbox_object: models.InboxObject, inbox_object: models.InboxObject,
) -> None: ) -> None:
if MANUALLY_APPROVES_FOLLOWERS:
notif = models.Notification(
notification_type=models.NotificationType.PENDING_INCOMING_FOLLOWER,
actor_id=from_actor.id,
inbox_object_id=inbox_object.id,
)
db_session.add(notif)
return None
await _send_accept(db_session, from_actor, inbox_object)
async def _get_incoming_follow_from_notification_id(
db_session: AsyncSession,
notification_id: int,
) -> tuple[models.Notification, models.InboxObject]:
notif = await get_notification_by_id(db_session, notification_id)
if notif is None:
raise ValueError(f"Notification {notification_id=} not found")
if notif.inbox_object is None:
raise ValueError("Should never happen")
if ap_type := notif.inbox_object.ap_type != "Follow":
raise ValueError(f"Unexpected {ap_type=}")
return notif, notif.inbox_object
async def send_accept(
db_session: AsyncSession,
notification_id: int,
) -> None:
notif, incoming_follow_request = await _get_incoming_follow_from_notification_id(
db_session, notification_id
)
await _send_accept(
db_session, incoming_follow_request.actor, incoming_follow_request
)
notif.is_accepted = True
await db_session.commit()
async def _send_accept(
db_session: AsyncSession,
from_actor: models.Actor,
inbox_object: models.InboxObject,
) -> None:
follower = models.Follower( follower = models.Follower(
actor_id=from_actor.id, actor_id=from_actor.id,
inbox_object_id=inbox_object.id, inbox_object_id=inbox_object.id,
@ -852,7 +920,9 @@ async def _handle_follow_follow_activity(
"actor": ID, "actor": ID,
"object": inbox_object.ap_id, "object": inbox_object.ap_id,
} }
outbox_activity = await save_outbox_object(db_session, reply_id, reply) outbox_activity = await save_outbox_object(
db_session, reply_id, reply, relates_to_inbox_object_id=inbox_object.id
)
if not outbox_activity.id: if not outbox_activity.id:
raise ValueError("Should never happen") raise ValueError("Should never happen")
await new_outgoing_activity(db_session, from_actor.inbox_url, outbox_activity.id) await new_outgoing_activity(db_session, from_actor.inbox_url, outbox_activity.id)
@ -864,6 +934,49 @@ async def _handle_follow_follow_activity(
db_session.add(notif) db_session.add(notif)
async def send_reject(
db_session: AsyncSession,
notification_id: int,
) -> None:
notif, incoming_follow_request = await _get_incoming_follow_from_notification_id(
db_session, notification_id
)
await _send_reject(
db_session, incoming_follow_request.actor, incoming_follow_request
)
notif.is_rejected = True
await db_session.commit()
async def _send_reject(
db_session: AsyncSession,
from_actor: models.Actor,
inbox_object: models.InboxObject,
) -> None:
# Reply with an Accept
reply_id = allocate_outbox_id()
reply = {
"@context": ap.AS_CTX,
"id": outbox_object_id(reply_id),
"type": "Reject",
"actor": ID,
"object": inbox_object.ap_id,
}
outbox_activity = await save_outbox_object(
db_session, reply_id, reply, relates_to_inbox_object_id=inbox_object.id
)
if not outbox_activity.id:
raise ValueError("Should never happen")
await new_outgoing_activity(db_session, from_actor.inbox_url, outbox_activity.id)
notif = models.Notification(
notification_type=models.NotificationType.REJECTED_FOLLOWER,
actor_id=from_actor.id,
)
db_session.add(notif)
async def _handle_undo_activity( async def _handle_undo_activity(
db_session: AsyncSession, db_session: AsyncSession,
from_actor: models.Actor, from_actor: models.Actor,

View file

@ -42,6 +42,7 @@ class Config(pydantic.BaseModel):
secret: str secret: str
debug: bool = False debug: bool = False
trusted_hosts: list[str] = ["127.0.0.1"] trusted_hosts: list[str] = ["127.0.0.1"]
manually_approves_followers: bool = False
# Config items to make tests easier # Config items to make tests easier
sqlalchemy_database: str | None = None sqlalchemy_database: str | None = None
@ -82,6 +83,7 @@ DOMAIN = CONFIG.domain
_SCHEME = "https" if CONFIG.https else "http" _SCHEME = "https" if CONFIG.https else "http"
ID = f"{_SCHEME}://{DOMAIN}" ID = f"{_SCHEME}://{DOMAIN}"
USERNAME = CONFIG.username USERNAME = CONFIG.username
MANUALLY_APPROVES_FOLLOWERS = CONFIG.manually_approves_followers
BASE_URL = ID BASE_URL = ID
DEBUG = CONFIG.debug DEBUG = CONFIG.debug
DB_PATH = CONFIG.sqlalchemy_database or ROOT_DIR / "data" / "microblogpub.db" DB_PATH = CONFIG.sqlalchemy_database or ROOT_DIR / "data" / "microblogpub.db"

View file

@ -523,6 +523,8 @@ class PollAnswer(Base):
@enum.unique @enum.unique
class NotificationType(str, enum.Enum): class NotificationType(str, enum.Enum):
NEW_FOLLOWER = "new_follower" NEW_FOLLOWER = "new_follower"
PENDING_INCOMING_FOLLOWER = "pending_incoming_follower"
REJECTED_FOLLOWER = "rejected_follower"
UNFOLLOW = "unfollow" UNFOLLOW = "unfollow"
FOLLOW_REQUEST_ACCEPTED = "follow_request_accepted" FOLLOW_REQUEST_ACCEPTED = "follow_request_accepted"
@ -563,6 +565,9 @@ class Notification(Base):
) )
webmention = relationship(Webmention, uselist=False) webmention = relationship(Webmention, uselist=False)
is_accepted = Column(Boolean, nullable=True)
is_rejected = Column(Boolean, nullable=True)
outbox_fts = Table( outbox_fts = Table(
"outbox_fts", "outbox_fts",

View file

@ -22,6 +22,10 @@
{%- if notif.notification_type.value == "new_follower" %} {%- if notif.notification_type.value == "new_follower" %}
{{ notif_actor_action(notif, "followed you") }} {{ notif_actor_action(notif, "followed you") }}
{{ utils.display_actor(notif.actor, actors_metadata) }} {{ utils.display_actor(notif.actor, actors_metadata) }}
{%- elif notif.notification_type.value == "pending_incoming_follower" %}
{{ notif_actor_action(notif, "sent a follow request") }}
{{ utils.display_actor(notif.actor, actors_metadata, pending_incoming_follow_notif=notif) }}
{% elif notif.notification_type.value == "rejected_follower" %}
{% elif notif.notification_type.value == "unfollow" %} {% elif notif.notification_type.value == "unfollow" %}
{{ notif_actor_action(notif, "unfollowed you") }} {{ notif_actor_action(notif, "unfollowed you") }}
{{ utils.display_actor(notif.actor, actors_metadata) }} {{ utils.display_actor(notif.actor, actors_metadata) }}

View file

@ -33,6 +33,24 @@
</form> </form>
{% endmacro %} {% endmacro %}
{% macro admin_accept_incoming_follow_button(notif) %}
<form action="{{ request.url_for("admin_actions_accept_incoming_follow") }}" method="POST">
{{ embed_csrf_token() }}
{{ embed_redirect_url() }}
<input type="hidden" name="notification_id" value="{{ notif.id }}">
<input type="submit" value="accept follow">
</form>
{% endmacro %}
{% macro admin_reject_incoming_follow_button(notif) %}
<form action="{{ request.url_for("admin_actions_reject_incoming_follow") }}" method="POST">
{{ embed_csrf_token() }}
{{ embed_redirect_url() }}
<input type="hidden" name="notification_id" value="{{ notif.id }}">
<input type="submit" value="reject follow">
</form>
{% endmacro %}
{% macro admin_like_button(ap_object_id, permalink_id) %} {% macro admin_like_button(ap_object_id, permalink_id) %}
<form action="{{ request.url_for("admin_actions_like") }}" method="POST"> <form action="{{ request.url_for("admin_actions_like") }}" method="POST">
{{ embed_csrf_token() }} {{ embed_csrf_token() }}
@ -197,7 +215,7 @@
{% endmacro %} {% endmacro %}
{% macro display_actor(actor, actors_metadata={}, embedded=False, with_details=False) %} {% macro display_actor(actor, actors_metadata={}, embedded=False, with_details=False, pending_incoming_follow_notif=None) %}
{% set metadata = actors_metadata.get(actor.ap_id) %} {% set metadata = actors_metadata.get(actor.ap_id) %}
{% if not embedded %} {% if not embedded %}
@ -243,6 +261,20 @@
<li>{{ admin_block_button(actor) }}</li> <li>{{ admin_block_button(actor) }}</li>
{% endif %} {% endif %}
{% endif %} {% endif %}
{% if pending_incoming_follow_notif %}
{% if not pending_incoming_follow_notif.is_accepted and not pending_incoming_follow_notif.is_rejected %}
<li>
{{ admin_accept_incoming_follow_button(pending_incoming_follow_notif) }}
</li>
<li>
{{ admin_reject_incoming_follow_button(pending_incoming_follow_notif) }}
</li>
{% elif pending_incoming_follow_notif.is_accepted %}
<li>accepted</li>
{% else %}
<li>rejected</li>
{% endif %}
{% endif %}
</ul> </ul>
</nav> </nav>
</div> </div>

View file

@ -1,3 +1,4 @@
from unittest import mock
from uuid import uuid4 from uuid import uuid4
import httpx import httpx
@ -32,7 +33,7 @@ def test_inbox_requires_httpsig(
assert response.json()["detail"] == "Invalid HTTP sig" assert response.json()["detail"] == "Invalid HTTP sig"
def test_inbox_follow_request( def test_inbox_incoming_follow_request(
db: Session, db: Session,
client: TestClient, client: TestClient,
respx_mock: respx.MockRouter, respx_mock: respx.MockRouter,
@ -66,11 +67,11 @@ def test_inbox_follow_request(
run_async(process_next_incoming_activity) run_async(process_next_incoming_activity)
# And the actor was saved in DB # And the actor was saved in DB
saved_actor = db.query(models.Actor).one() saved_actor = db.execute(select(models.Actor)).scalar_one()
assert saved_actor.ap_id == ra.ap_id assert saved_actor.ap_id == ra.ap_id
# And the Follow activity was saved in the inbox # And the Follow activity was saved in the inbox
inbox_object = db.query(models.InboxObject).one() inbox_object = db.execute(select(models.InboxObject)).scalar_one()
assert inbox_object.ap_object == follow_activity.ap_object assert inbox_object.ap_object == follow_activity.ap_object
# And a follower was internally created # And a follower was internally created
@ -80,15 +81,61 @@ def test_inbox_follow_request(
assert follower.inbox_object_id == inbox_object.id assert follower.inbox_object_id == inbox_object.id
# And an Accept activity was created in the outbox # And an Accept activity was created in the outbox
outbox_object = db.query(models.OutboxObject).one() outbox_object = db.execute(select(models.OutboxObject)).scalar_one()
assert outbox_object.ap_type == "Accept" assert outbox_object.ap_type == "Accept"
assert outbox_object.activity_object_ap_id == follow_activity.ap_id assert outbox_object.activity_object_ap_id == follow_activity.ap_id
# And an outgoing activity was created to track the Accept activity delivery # And an outgoing activity was created to track the Accept activity delivery
outgoing_activity = db.query(models.OutgoingActivity).one() outgoing_activity = db.execute(select(models.OutgoingActivity)).scalar_one()
assert outgoing_activity.outbox_object_id == outbox_object.id assert outgoing_activity.outbox_object_id == outbox_object.id
def test_inbox_incoming_follow_request__manually_approves_followers(
db: Session,
client: TestClient,
respx_mock: respx.MockRouter,
) -> None:
# Given a remote actor
ra = factories.RemoteActorFactory(
base_url="https://example.com",
username="toto",
public_key="pk",
)
respx_mock.get(ra.ap_id).mock(return_value=httpx.Response(200, json=ra.ap_actor))
# When receiving a Follow activity
follow_activity = RemoteObject(
factories.build_follow_activity(
from_remote_actor=ra,
for_remote_actor=LOCAL_ACTOR,
),
ra,
)
with mock_httpsig_checker(ra):
response = client.post(
"/inbox",
headers={"Content-Type": ap.AS_CTX},
json=follow_activity.ap_object,
)
# Then the server returns a 204
assert response.status_code == 202
with mock.patch("app.boxes.MANUALLY_APPROVES_FOLLOWERS", True):
run_async(process_next_incoming_activity)
# And the actor was saved in DB
saved_actor = db.execute(select(models.Actor)).scalar_one()
assert saved_actor.ap_id == ra.ap_id
# And the Follow activity was saved in the inbox
inbox_object = db.execute(select(models.InboxObject)).scalar_one()
assert inbox_object.ap_object == follow_activity.ap_object
# And no follower was internally created
assert db.scalar(select(func.count(models.Follower.id))) == 0
def test_inbox_accept_follow_request( def test_inbox_accept_follow_request(
db: Session, db: Session,
client: TestClient, client: TestClient,
@ -133,13 +180,13 @@ def test_inbox_accept_follow_request(
run_async(process_next_incoming_activity) run_async(process_next_incoming_activity)
# And the Accept activity was saved in the inbox # And the Accept activity was saved in the inbox
inbox_activity = db.query(models.InboxObject).one() inbox_activity = db.execute(select(models.InboxObject)).scalar_one()
assert inbox_activity.ap_type == "Accept" assert inbox_activity.ap_type == "Accept"
assert inbox_activity.relates_to_outbox_object_id == outbox_object.id assert inbox_activity.relates_to_outbox_object_id == outbox_object.id
assert inbox_activity.actor_id == actor_in_db.id assert inbox_activity.actor_id == actor_in_db.id
# And a following entry was created internally # And a following entry was created internally
following = db.query(models.Following).one() following = db.execute(select(models.Following)).scalar_one()
assert following.ap_actor_id == actor_in_db.ap_id assert following.ap_actor_id == actor_in_db.ap_id