foxhole/app/boxes.py

563 lines
16 KiB
Python
Raw Normal View History

2023-03-17 10:59:29 +01:00
#!/usr/bin/env python3
2023-03-18 13:02:38 +01:00
from typing import Any
import uuid
2023-03-20 10:54:54 +01:00
from sqlalchemy.orm import session
2023-03-17 10:59:29 +01:00
from app import models
2023-04-01 18:13:16 +02:00
from app import ldsig
2023-03-17 10:59:29 +01:00
from app.database import AsyncSession
2023-03-22 10:58:11 +01:00
from app.models import InboxObject, OutboxObject, now
2023-03-17 18:46:31 +01:00
from app.activitypub import ME
2023-03-20 10:54:54 +01:00
from app.activitypub import handle_visibility
2023-03-17 18:46:31 +01:00
from app.config import MANUALLY_APPROVES_FOLLOWERS
2023-04-01 18:13:16 +02:00
from app.config import BASE_URL, ID
2023-03-18 13:02:38 +01:00
from app.models import Actor
from app.actor import fetch_actor
2023-04-01 18:13:16 +02:00
from app.httpsig import k
2023-03-17 10:59:29 +01:00
2023-03-17 18:46:31 +01:00
import app.activitypub as ap
2023-03-18 13:02:38 +01:00
2023-03-20 10:54:54 +01:00
from urllib.parse import urlparse
2023-03-18 13:02:38 +01:00
from sqlalchemy import select
2023-03-20 16:33:10 +01:00
from sqlalchemy import delete
from sqlalchemy.orm import joinedload
2023-07-30 23:55:08 +02:00
from sqlalchemy.exc import IntegrityError
2023-03-18 13:02:38 +01:00
from loguru import logger
from uuid import uuid4
2023-04-01 18:13:16 +02:00
from datetime import datetime
2023-03-18 13:02:38 +01:00
2023-03-17 18:46:31 +01:00
def allocate_outbox_id() -> str:
return str(uuid.uuid4())
def build_object_id(id) -> str:
return f"{BASE_URL}/tail/{id}"
2023-03-17 10:59:29 +01:00
2023-03-20 10:54:54 +01:00
2023-04-03 12:14:46 +02:00
async def get_outbox_object(
db_session: AsyncSession,
ap_id: str,
) -> OutboxObject | None :
return (
(
await db_session.execute(
select(OutboxObject)
.where(OutboxObject.ap_id == ap_id)
.options(
2023-04-11 17:03:53 +02:00
joinedload(models.OutboxObject.relates_to_inbox_object).options(
joinedload(models.InboxObject.actor),
2023-04-03 12:14:46 +02:00
),
)
)
)
.unique()
.scalar_one_or_none()
)
2023-03-17 10:59:29 +01:00
async def save_incoming(
db_session: AsyncSession,
payload: dict,
) -> models.IncomingActivity | None:
ap_id: str
if "@context" not in payload:
logger.warning(f"invalid object: {payload}")
return None
if "id" in payload:
ap_id = payload["id"]
else:
ap_id = str(uuid4())
incoming_activity = models.IncomingActivity(
ap_id=ap_id,
ap_object=payload,
)
2023-03-20 16:33:10 +01:00
2023-03-21 02:32:26 +01:00
if await process_incoming(db_session, payload):
await db_session.commit()
await db_session.flush()
2023-03-18 09:12:44 +01:00
return incoming_activity
2023-03-20 16:33:10 +01:00
db_session.add(incoming_activity)
2023-03-17 10:59:29 +01:00
await db_session.commit()
await db_session.refresh(incoming_activity)
return incoming_activity
2023-03-17 18:46:31 +01:00
async def process_incoming(
db_session: AsyncSession,
2023-04-27 05:17:20 +02:00
ap_object: dict[str, Any],
2023-03-17 18:46:31 +01:00
) -> bool:
2023-03-21 06:58:14 +01:00
actor = await fetch_actor(db_session, ap_object["actor"])
2023-03-17 18:46:31 +01:00
2023-04-03 12:14:46 +02:00
relates_to_inbox_object = None
relates_to_outbox_object = None
2023-04-27 05:17:20 +02:00
if isinstance(ap_object["object"], dict):
if ap_object["object"]["id"].startswith(BASE_URL):
relates_to_outbox_object = await get_outbox_object(
db_session,
ap_object["object"]["id"],
)
else:
if ap_object["object"].startswith(BASE_URL):
relates_to_outbox_object = await get_outbox_object(
db_session,
ap_object["object"],
)
2023-04-03 12:14:46 +02:00
2023-03-27 12:18:04 +02:00
def build_object(
object,
2023-04-11 17:03:53 +02:00
relates_to_inbox_object = None,
relates_to_outbox_object = None,
2023-03-27 12:18:04 +02:00
) -> InboxObject:
2023-03-20 10:54:54 +01:00
inbox_object = models.InboxObject(
actor_id=actor.id,
server=urlparse(object["id"]).hostname,
is_hidden_from_stream=True,
ap_actor_id=actor.ap_id,
ap_type=object["type"],
ap_id=object["id"],
ap_published_at=now(),
ap_object=object,
visibility=handle_visibility(object),
2023-03-27 12:18:04 +02:00
activity_object_ap_id=object["actor"],
2023-04-11 17:03:53 +02:00
relates_to_inbox_object_id=relates_to_inbox_object.id
if relates_to_inbox_object
else None,
relates_to_outbox_object_id=relates_to_outbox_object.id
if relates_to_outbox_object
else None,
2023-03-20 10:54:54 +01:00
)
return inbox_object
2023-03-17 18:46:31 +01:00
if "Follow" == ap_object["type"]:
2023-03-20 16:33:10 +01:00
inbox_object = build_object(ap_object)
2023-03-20 10:54:54 +01:00
db_session.add(inbox_object)
await db_session.flush()
await db_session.refresh(inbox_object)
if await _handle_follow(db_session, actor, inbox_object):
2023-03-18 15:55:49 +01:00
return True
return False
2023-03-20 16:33:10 +01:00
elif "Undo" == ap_object["type"]:
#inbox_object = build_object(ap_object)
#db_session.add(inbox_object)
if await _handle_undo(db_session, ap_object):
return True
2023-03-27 12:18:04 +02:00
elif ap_object["type"] in ["Accept", "Rejact"]:
2023-07-30 22:59:23 +02:00
follow_id = ap_object["object"]
if isinstance(follow_id, dict):
follow_id = follow_id["id"]
2023-03-27 12:18:04 +02:00
relate_following_object = (await db_session.execute(
select(models.OutboxObject)
2023-07-30 22:59:23 +02:00
.where(models.OutboxObject.ap_id == follow_id)
2023-03-27 12:18:04 +02:00
.options(
joinedload(models.OutboxObject.relates_to_inbox_object).options(
joinedload(models.InboxObject.actor)
)
)
)
).unique().scalar_one_or_none()
if "Accept" == ap_object["type"]:
2023-07-30 23:55:08 +02:00
try:
inbox_object = build_object(ap_object)
db_session.add(inbox_object)
await db_session.flush()
except IntegrityError:
logger.warning("Duplicate accept respond?")
await db_session.rollback()
return True
2023-03-27 12:18:04 +02:00
await db_session.refresh(inbox_object)
following = models.Following(
2023-04-03 09:40:25 +02:00
actor_id=relate_following_object.relates_to_actor.id,
outbox_object_id=relate_following_object.id,
ap_actor_id=ap_object["actor"],
2023-03-27 12:18:04 +02:00
)
db_session.add(following)
await db_session.flush()
await db_session.refresh(following)
2023-03-27 17:22:11 +02:00
return True
2023-04-03 12:14:46 +02:00
elif "Announce" == ap_object["type"]:
inbox_object = build_object(
ap_object,
2023-04-11 17:03:53 +02:00
relates_to_inbox_object=relates_to_inbox_object,
relates_to_outbox_object=relates_to_outbox_object,
2023-04-03 12:14:46 +02:00
)
db_session.add(inbox_object)
await db_session.flush()
await db_session.refresh(inbox_object)
if await _handle_announce(
db_session,
inbox_object,
relates_to_outbox_object=relates_to_outbox_object,
relates_to_inbox_object=relates_to_inbox_object,
):
return True
2023-05-08 08:50:13 +02:00
elif "Like" == ap_object["type"]:
inbox_object = build_object(
ap_object,
relates_to_inbox_object=relates_to_inbox_object,
relates_to_outbox_object=relates_to_outbox_object,
)
db_session.add(inbox_object)
await db_session.flush()
await db_session.refresh(inbox_object)
if await _handle_like(
db_session,
inbox_object,
relates_to_outbox_object=relates_to_outbox_object,
relates_to_inbox_object=relates_to_inbox_object,
):
return True
2023-04-01 18:13:16 +02:00
2023-03-18 09:12:44 +01:00
return False
2023-03-17 18:46:31 +01:00
async def _handle_follow(
2023-03-18 13:02:38 +01:00
db_session : AsyncSession,
2023-03-20 10:54:54 +01:00
actor : Actor,
inbox_object : InboxObject,
2023-03-18 09:12:44 +01:00
) -> bool:
2023-03-20 10:54:54 +01:00
if ME["id"] != inbox_object.ap_object["object"]: #type: ignore
2023-03-17 18:46:31 +01:00
# await db_session.delete(ap_object)
2023-03-20 10:54:54 +01:00
logger.warning("no match follow object!" + inbox_object.ap_object["id"]) #type: ignore
2023-03-18 09:12:44 +01:00
return False
2023-03-17 18:46:31 +01:00
if MANUALLY_APPROVES_FOLLOWERS:
2023-03-18 09:12:44 +01:00
return False
2023-03-17 18:46:31 +01:00
2023-03-20 10:54:54 +01:00
await _send_accept(db_session, actor, inbox_object)
2023-03-18 09:12:44 +01:00
return True
2023-03-17 18:46:31 +01:00
async def _send_accept(
db_session: AsyncSession,
2023-03-20 10:54:54 +01:00
actor : Actor,
inbox_object : InboxObject,
2023-03-17 18:46:31 +01:00
) -> None :
2023-03-20 10:54:54 +01:00
follower = models.Follower(
actor_id=inbox_object.actor_id,
inbox_object_id=inbox_object.id,
ap_actor_id=inbox_object.ap_object["actor"], #type: ignore
)
try:
db_session.add(follower)
await db_session.flush()
except IntegrityError:
await db_session.rollback()
logger.warning("existing follower in db!")
2023-03-17 18:46:31 +01:00
2023-04-01 18:13:16 +02:00
try:
reply_id = allocate_outbox_id()
url = actor.inbox_url # type: ignore
out = {
"@context": ap.AS_CTX,
"id": build_object_id(reply_id),
"type": "Accept",
"actor": ME["id"],
"object": inbox_object.ap_object["id"], #type: ignore
}
#TODO outcoming
2023-04-07 05:36:02 +02:00
await save_to_outbox(
db_session,
reply_id,
out,
relates_to_actor_id=actor.id, # type: ignore
)
2023-04-01 18:13:16 +02:00
await ap.post(url, out) # type: ignore
except Exception as e:
logger.error(e)
2023-03-20 16:33:10 +01:00
async def _handle_undo(
db_session : AsyncSession,
inbox_object : dict
) -> bool:
if inbox_object["object"]["object"] != ME["id"]:
logger.warning("Wrong undo object! "
+ inbox_object["object"]["actor"])
return False
if "Follow" == inbox_object["object"]["type"]:
relate_object = (await db_session.execute(
select(models.InboxObject)
.where(models.InboxObject.ap_id == inbox_object["object"]["id"])
.options(
joinedload(models.InboxObject.actor),
joinedload(models.InboxObject.relates_to_inbox_object),
joinedload(models.InboxObject.relates_to_outbox_object),
)
)
).scalar_one_or_none() # type: ignore
if relate_object:
relate_object.undo_id=inbox_object["object"]["id"]
relate_object.is_deleted=True
await db_session.execute(
delete(models.Follower).where(
models.Follower.ap_actor_id == inbox_object["actor"]
)
)
logger.info("undo follow " + inbox_object["actor"])
return True
return False
2023-03-21 06:58:14 +01:00
async def send_follow(
db_session : AsyncSession,
acct : str
):
await _send_follow(db_session, acct)
await db_session.commit()
await db_session.flush()
2023-03-22 10:58:11 +01:00
2023-04-03 12:14:46 +02:00
async def _handle_announce(
db_session: AsyncSession,
inbox_object: InboxObject,
relates_to_outbox_object: models.OutboxObject | None,
relates_to_inbox_object: models.InboxObject | None,
) -> bool :
if relates_to_outbox_object:
2023-05-08 08:50:13 +02:00
relates_to_outbox_object.announces_count = ( # type: ignore
2023-04-03 12:14:46 +02:00
models.OutboxObject.announces_count + 1
)
logger.info(f"announces +1 {relates_to_outbox_object.ap_id}")
return True
else:
return False
2023-05-08 08:50:13 +02:00
async def _handle_like(
db_session: AsyncSession,
inbox_object: InboxObject,
relates_to_outbox_object: models.OutboxObject | None,
relates_to_inbox_object: models.InboxObject | None,
) -> bool :
if relates_to_outbox_object:
relates_to_outbox_object.likes_count = ( # type: ignore
models.OutboxObject.likes_count + 1
)
logger.info(f"likes +1 {relates_to_outbox_object.ap_id}")
return True
else:
return False
2023-03-21 06:58:14 +01:00
async def _send_follow(
db_session : AsyncSession,
actor_url : str,
):
actor = await fetch_actor(db_session, actor_url)
2023-03-27 12:18:04 +02:00
follow_id = build_object_id(allocate_outbox_id())
2023-03-21 06:58:14 +01:00
out = {
"@context": ap.AS_CTX,
2023-03-27 12:18:04 +02:00
"id": follow_id,
2023-03-21 06:58:14 +01:00
"type": "Follow",
"actor": ME["id"],
"object": actor.ap_id,
}
2023-03-22 10:58:11 +01:00
2023-03-27 12:18:04 +02:00
await save_to_outbox(
db_session,
follow_id,
out,
relates_to_actor_id=actor.id, # type: ignore
activity_object_ap_id=actor.ap_id
2023-03-27 12:18:04 +02:00
)
2023-03-22 10:58:11 +01:00
2023-03-21 06:58:14 +01:00
await ap.post(
2023-03-21 07:24:57 +01:00
actor.inbox_url,
2023-03-21 06:58:14 +01:00
out,
)
2023-03-22 10:58:11 +01:00
2023-04-01 18:13:16 +02:00
async def _send_create(
db_session: AsyncSession,
ap_type: str,
content: str,
visibility: ap.VisibilityEnum,
published: str | None = None,
) -> bool:
object_id = allocate_outbox_id()
2023-04-01 18:13:16 +02:00
if not published:
published = now().replace(microsecond=0).isoformat().replace("+00:00", "Z")
to = []
cc = []
if visibility == ap.VisibilityEnum.PUBLIC:
to = [ap.AS_PUBLIC]
cc = [f"{BASE_URL}/followers"]
else:
raise ValueError(f"Unsupport visibility {visibility}")
ap_object = {
"@context": ap.AS_EXTENDED_CTX,
"type": ap_type,
"id": build_object_id(object_id),
2023-04-01 18:13:16 +02:00
"attributedTo": ID,
"content": content,
"to": to,
"cc": cc,
"published": published,
# "context": context,
# "conversation": context,
"url": build_object_id(object_id),
2023-04-01 18:13:16 +02:00
"tag": [],
"summary": None,
"inReplyTo": None,
"sensitive": False,
"attachment": [],
}
outbox_object = await save_to_outbox(
db_session,
object_id,
ap_object,
)
recipients = await _compute_recipients(db_session, ap_object)
ap_object = ap.wrap_ap_object(ap_object)
if ap_object["type"] == "Create":
if ap.VisibilityEnum.PUBLIC == outbox_object.visibility:
ldsig.generate_signature(ap_object, k)
for r in recipients:
await ap.post(
r,
ap_object,
)
return True
async def _compute_recipients(
db_session: AsyncSession,
ap_object: dict,
) -> set[str]:
async def process_collection(
db_session,
url) -> list[Actor]:
if url == BASE_URL + "/followers":
followers = (
(
await db_session.scalars(
select(models.Follower).options(
joinedload(models.Follower.actor)
)
)
)
.unique()
.all()
)
else:
raise ValueError(f"{url}) not supported")
return [follower.actor for follower in followers]
_recipients = []
for field in ["to", "cc", "bcc", "bto"]:
if field in ap_object:
_recipients.extend(ap_object[field])
recipients = set()
logger.info(f"{_recipients}")
for r in _recipients:
if r in [ap.AS_PUBLIC, ID]:
continue
if r.startswith(BASE_URL):
for actor in await process_collection(db_session, r):
recipients.add(actor.share_inbox_url)
continue
return recipients
2023-03-27 12:18:04 +02:00
async def save_to_inbox(
db_session : AsyncSession,
inbox_id : str,
ap_object : dict,
relates_to_inbox_object_id: int | None = None,
relates_to_outbox_object_id: int | None = None,
relates_to_actor_id: int | None = None,
) -> InboxObject:
ap_type = ap_object["type"]
ap_id = ap_object["id"]
visibility = handle_visibility(ap_object)
inbox_object = OutboxObject(
public_id=inbox_id,
ap_object=ap_object,
ap_id=ap_id,
ap_type=ap_type,
visibility =visibility,
relates_to_inbox_object_id=relates_to_inbox_object_id,
relates_to_outbox_object_id=relates_to_outbox_object_id,
relates_to_actor_id=relates_to_actor_id,
)
db_session.add(inbox_object)
await db_session.flush()
await db_session.refresh(inbox_object)
return inbox_object
2023-03-22 10:58:11 +01:00
async def save_to_outbox(
db_session : AsyncSession,
outbox_id : str,
ap_object : dict,
activity_object_ap_id: str | None = None,
2023-03-22 10:58:11 +01:00
relates_to_inbox_object_id: int | None = None,
relates_to_outbox_object_id: int | None = None,
relates_to_actor_id: int | None = None,
) -> OutboxObject:
2023-03-27 12:18:04 +02:00
ap_type = ap_object["type"]
ap_id = ap_object["id"]
visibility = handle_visibility(ap_object)
2023-03-22 10:58:11 +01:00
outbox_object = OutboxObject(
public_id=outbox_id,
ap_object=ap_object,
2023-03-27 12:18:04 +02:00
ap_id=ap_id,
ap_type=ap_type,
visibility =visibility,
activity_object_ap_id=activity_object_ap_id,
2023-03-22 10:58:11 +01:00
relates_to_inbox_object_id=relates_to_inbox_object_id,
relates_to_outbox_object_id=relates_to_outbox_object_id,
relates_to_actor_id=relates_to_actor_id,
)
db_session.add(outbox_object)
await db_session.flush()
await db_session.refresh(outbox_object)
2023-04-01 18:40:03 +02:00
await db_session.commit()
2023-03-22 10:58:11 +01:00
return outbox_object