diff --git a/alembic/versions/2022_08_02_1519-1702e88016db_tweak_notification_model.py b/alembic/versions/2022_08_02_1519-1702e88016db_tweak_notification_model.py new file mode 100644 index 0000000..f4d6f48 --- /dev/null +++ b/alembic/versions/2022_08_02_1519-1702e88016db_tweak_notification_model.py @@ -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 ### diff --git a/app/activitypub.py b/app/activitypub.py index 512c19b..4b83efb 100644 --- a/app/activitypub.py +++ b/app/activitypub.py @@ -95,7 +95,7 @@ ME = { + "/inbox", }, "url": config.ID, - "manuallyApprovesFollowers": False, + "manuallyApprovesFollowers": config.CONFIG.manually_approves_followers, "attachment": [], "icon": { "mediaType": mimetypes.guess_type(config.CONFIG.icon_url)[0], diff --git a/app/actor.py b/app/actor.py index b6878ac..beac556 100644 --- a/app/actor.py +++ b/app/actor.py @@ -218,6 +218,7 @@ async def get_actors_metadata( select(models.OutboxObject.ap_object, models.OutboxObject.ap_id).where( models.OutboxObject.ap_type == "Follow", models.OutboxObject.undone_by_outbox_object_id.is_(None), + models.OutboxObject.activity_object_ap_id.in_(ap_actor_ids), ) ) } diff --git a/app/admin.py b/app/admin.py index 112e3cb..6fb8906 100644 --- a/app/admin.py +++ b/app/admin.py @@ -616,6 +616,30 @@ async def admin_actions_delete( 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") async def admin_actions_like( request: Request, diff --git a/app/boxes.py b/app/boxes.py index c590bf0..b049dc4 100644 --- a/app/boxes.py +++ b/app/boxes.py @@ -27,6 +27,7 @@ from app.actor import save_actor from app.ap_object import RemoteObject from app.config import BASE_URL from app.config import ID +from app.config import MANUALLY_APPROVES_FOLLOWERS from app.database import AsyncSession from app.outgoing_activities import new_outgoing_activity 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( db_session: AsyncSession, ap_id: str ) -> models.InboxObject | None: @@ -832,6 +849,57 @@ async def _handle_follow_follow_activity( from_actor: models.Actor, inbox_object: models.InboxObject, ) -> 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( actor_id=from_actor.id, inbox_object_id=inbox_object.id, @@ -852,7 +920,9 @@ async def _handle_follow_follow_activity( "actor": 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: raise ValueError("Should never happen") 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) +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( db_session: AsyncSession, from_actor: models.Actor, diff --git a/app/config.py b/app/config.py index b55850e..d567e8f 100644 --- a/app/config.py +++ b/app/config.py @@ -42,6 +42,7 @@ class Config(pydantic.BaseModel): secret: str debug: bool = False trusted_hosts: list[str] = ["127.0.0.1"] + manually_approves_followers: bool = False # Config items to make tests easier sqlalchemy_database: str | None = None @@ -82,6 +83,7 @@ DOMAIN = CONFIG.domain _SCHEME = "https" if CONFIG.https else "http" ID = f"{_SCHEME}://{DOMAIN}" USERNAME = CONFIG.username +MANUALLY_APPROVES_FOLLOWERS = CONFIG.manually_approves_followers BASE_URL = ID DEBUG = CONFIG.debug DB_PATH = CONFIG.sqlalchemy_database or ROOT_DIR / "data" / "microblogpub.db" diff --git a/app/models.py b/app/models.py index 5d23245..b2472c7 100644 --- a/app/models.py +++ b/app/models.py @@ -523,6 +523,8 @@ class PollAnswer(Base): @enum.unique class NotificationType(str, enum.Enum): NEW_FOLLOWER = "new_follower" + PENDING_INCOMING_FOLLOWER = "pending_incoming_follower" + REJECTED_FOLLOWER = "rejected_follower" UNFOLLOW = "unfollow" FOLLOW_REQUEST_ACCEPTED = "follow_request_accepted" @@ -563,6 +565,9 @@ class Notification(Base): ) webmention = relationship(Webmention, uselist=False) + is_accepted = Column(Boolean, nullable=True) + is_rejected = Column(Boolean, nullable=True) + outbox_fts = Table( "outbox_fts", diff --git a/app/templates/notifications.html b/app/templates/notifications.html index 3eac45f..3f35fe8 100644 --- a/app/templates/notifications.html +++ b/app/templates/notifications.html @@ -22,6 +22,10 @@ {%- if notif.notification_type.value == "new_follower" %} {{ notif_actor_action(notif, "followed you") }} {{ 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" %} {{ notif_actor_action(notif, "unfollowed you") }} {{ utils.display_actor(notif.actor, actors_metadata) }} diff --git a/app/templates/utils.html b/app/templates/utils.html index 5a7640d..37fb1c9 100644 --- a/app/templates/utils.html +++ b/app/templates/utils.html @@ -33,6 +33,24 @@ {% endmacro %} +{% macro admin_accept_incoming_follow_button(notif) %} +
+ {{ embed_csrf_token() }} + {{ embed_redirect_url() }} + + +
+{% endmacro %} + +{% macro admin_reject_incoming_follow_button(notif) %} +
+ {{ embed_csrf_token() }} + {{ embed_redirect_url() }} + + +
+{% endmacro %} + {% macro admin_like_button(ap_object_id, permalink_id) %}
{{ embed_csrf_token() }} @@ -197,7 +215,7 @@ {% 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) %} {% if not embedded %} @@ -243,6 +261,20 @@
  • {{ admin_block_button(actor) }}
  • {% endif %} {% endif %} + {% if pending_incoming_follow_notif %} + {% if not pending_incoming_follow_notif.is_accepted and not pending_incoming_follow_notif.is_rejected %} +
  • + {{ admin_accept_incoming_follow_button(pending_incoming_follow_notif) }} +
  • +
  • + {{ admin_reject_incoming_follow_button(pending_incoming_follow_notif) }} +
  • + {% elif pending_incoming_follow_notif.is_accepted %} +
  • accepted
  • + {% else %} +
  • rejected
  • + {% endif %} + {% endif %} diff --git a/tests/test_inbox.py b/tests/test_inbox.py index 415db41..c3efc1e 100644 --- a/tests/test_inbox.py +++ b/tests/test_inbox.py @@ -1,3 +1,4 @@ +from unittest import mock from uuid import uuid4 import httpx @@ -32,7 +33,7 @@ def test_inbox_requires_httpsig( assert response.json()["detail"] == "Invalid HTTP sig" -def test_inbox_follow_request( +def test_inbox_incoming_follow_request( db: Session, client: TestClient, respx_mock: respx.MockRouter, @@ -66,11 +67,11 @@ def test_inbox_follow_request( run_async(process_next_incoming_activity) # 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 # 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 # And a follower was internally created @@ -80,15 +81,61 @@ def test_inbox_follow_request( assert follower.inbox_object_id == inbox_object.id # 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.activity_object_ap_id == follow_activity.ap_id # 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 +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( db: Session, client: TestClient, @@ -133,13 +180,13 @@ def test_inbox_accept_follow_request( run_async(process_next_incoming_activity) # 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.relates_to_outbox_object_id == outbox_object.id assert inbox_activity.actor_id == actor_in_db.id # 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