Start support for manually approving followers
This commit is contained in:
parent
9f3956db67
commit
a1a9ec3f7c
10 changed files with 272 additions and 10 deletions
|
@ -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 ###
|
|
@ -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],
|
||||
|
|
|
@ -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),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
24
app/admin.py
24
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,
|
||||
|
|
115
app/boxes.py
115
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,
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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) }}
|
||||
|
|
|
@ -33,6 +33,24 @@
|
|||
</form>
|
||||
{% 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) %}
|
||||
<form action="{{ request.url_for("admin_actions_like") }}" method="POST">
|
||||
{{ 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 @@
|
|||
<li>{{ admin_block_button(actor) }}</li>
|
||||
{% 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>
|
||||
</nav>
|
||||
</div>
|
||||
|
|
|
@ -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
|
||||
|
||||
|
||||
|
|
Loading…
Reference in a new issue