microblog.pub/app/boxes.py

685 lines
23 KiB
Python

"""Actions related to the AP inbox/outbox."""
import uuid
from urllib.parse import urlparse
import httpx
from dateutil.parser import isoparse
from loguru import logger
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from sqlalchemy.orm import joinedload
from app import activitypub as ap
from app import config
from app import models
from app.actor import LOCAL_ACTOR
from app.actor import RemoteActor
from app.actor import fetch_actor
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.database import now
from app.process_outgoing_activities import new_outgoing_activity
from app.source import markdownify
def allocate_outbox_id() -> str:
return uuid.uuid4().hex
def outbox_object_id(outbox_id) -> str:
return f"{BASE_URL}/o/{outbox_id}"
def save_outbox_object(
db: Session,
public_id: str,
raw_object: ap.RawObject,
relates_to_inbox_object_id: int | None = None,
relates_to_outbox_object_id: int | None = None,
source: str | None = None,
) -> models.OutboxObject:
ra = RemoteObject(raw_object)
outbox_object = models.OutboxObject(
public_id=public_id,
ap_type=ra.ap_type,
ap_id=ra.ap_id,
ap_context=ra.context,
ap_object=ra.ap_object,
visibility=ra.visibility,
og_meta=ra.og_meta,
relates_to_inbox_object_id=relates_to_inbox_object_id,
relates_to_outbox_object_id=relates_to_outbox_object_id,
activity_object_ap_id=ra.activity_object_ap_id,
is_hidden_from_homepage=True if ra.in_reply_to else False,
)
db.add(outbox_object)
db.commit()
db.refresh(outbox_object)
return outbox_object
def send_like(db: Session, ap_object_id: str) -> None:
inbox_object = get_inbox_object_by_ap_id(db, ap_object_id)
if not inbox_object:
raise ValueError(f"{ap_object_id} not found in the inbox")
like_id = allocate_outbox_id()
like = {
"@context": ap.AS_CTX,
"id": outbox_object_id(like_id),
"type": "Like",
"actor": ID,
"object": ap_object_id,
}
outbox_object = save_outbox_object(
db, like_id, like, relates_to_inbox_object_id=inbox_object.id
)
if not outbox_object.id:
raise ValueError("Should never happen")
inbox_object.liked_via_outbox_object_ap_id = outbox_object.ap_id
db.commit()
new_outgoing_activity(db, inbox_object.actor.inbox_url, outbox_object.id)
def send_announce(db: Session, ap_object_id: str) -> None:
inbox_object = get_inbox_object_by_ap_id(db, ap_object_id)
if not inbox_object:
raise ValueError(f"{ap_object_id} not found in the inbox")
announce_id = allocate_outbox_id()
announce = {
"@context": ap.AS_CTX,
"id": outbox_object_id(announce_id),
"type": "Announce",
"actor": ID,
"object": ap_object_id,
"to": [ap.AS_PUBLIC],
"cc": [
f"{BASE_URL}/followers",
inbox_object.ap_actor_id,
],
}
outbox_object = save_outbox_object(
db, announce_id, announce, relates_to_inbox_object_id=inbox_object.id
)
if not outbox_object.id:
raise ValueError("Should never happen")
inbox_object.announced_via_outbox_object_ap_id = outbox_object.ap_id
db.commit()
recipients = _compute_recipients(db, announce)
for rcp in recipients:
new_outgoing_activity(db, rcp, outbox_object.id)
def send_follow(db: Session, ap_actor_id: str) -> None:
actor = fetch_actor(db, ap_actor_id)
follow_id = allocate_outbox_id()
follow = {
"@context": ap.AS_CTX,
"id": outbox_object_id(follow_id),
"type": "Follow",
"actor": ID,
"object": ap_actor_id,
}
outbox_object = save_outbox_object(db, follow_id, follow)
if not outbox_object.id:
raise ValueError("Should never happen")
new_outgoing_activity(db, actor.inbox_url, outbox_object.id)
def send_undo(db: Session, ap_object_id: str) -> None:
outbox_object_to_undo = get_outbox_object_by_ap_id(db, ap_object_id)
if not outbox_object_to_undo:
raise ValueError(f"{ap_object_id} not found in the outbox")
if outbox_object_to_undo.ap_type not in ["Follow", "Like", "Announce"]:
raise ValueError(
f"Cannot build Undo for {outbox_object_to_undo.ap_type} activity"
)
undo_id = allocate_outbox_id()
undo = {
"@context": ap.AS_CTX,
"id": outbox_object_id(undo_id),
"type": "Undo",
"actor": ID,
"object": ap.remove_context(outbox_object_to_undo.ap_object),
}
outbox_object = save_outbox_object(
db,
undo_id,
undo,
relates_to_outbox_object_id=outbox_object_to_undo.id,
)
if not outbox_object.id:
raise ValueError("Should never happen")
outbox_object_to_undo.undone_by_outbox_object_id = outbox_object.id
if outbox_object_to_undo.ap_type == "Follow":
if not outbox_object_to_undo.activity_object_ap_id:
raise ValueError("Should never happen")
followed_actor = fetch_actor(db, outbox_object_to_undo.activity_object_ap_id)
new_outgoing_activity(
db,
followed_actor.inbox_url,
outbox_object.id,
)
# Also remove the follow from the following collection
db.query(models.Following).filter(
models.Following.ap_actor_id == followed_actor.ap_id
).delete()
db.commit()
elif outbox_object_to_undo.ap_type == "Like":
liked_object_ap_id = outbox_object_to_undo.activity_object_ap_id
if not liked_object_ap_id:
raise ValueError("Should never happen")
liked_object = get_inbox_object_by_ap_id(db, liked_object_ap_id)
if not liked_object:
raise ValueError(f"Cannot find liked object {liked_object_ap_id}")
liked_object.liked_via_outbox_object_ap_id = None
# Send the Undo to the liked object's actor
new_outgoing_activity(
db,
liked_object.actor.inbox_url, # type: ignore
outbox_object.id,
)
elif outbox_object_to_undo.ap_type == "Announce":
announced_object_ap_id = outbox_object_to_undo.activity_object_ap_id
if not announced_object_ap_id:
raise ValueError("Should never happen")
announced_object = get_inbox_object_by_ap_id(db, announced_object_ap_id)
if not announced_object:
raise ValueError(f"Cannot find announced object {announced_object_ap_id}")
announced_object.announced_via_outbox_object_ap_id = None
# Send the Undo to the original recipients
recipients = _compute_recipients(db, outbox_object.ap_object)
for rcp in recipients:
new_outgoing_activity(db, rcp, outbox_object.id)
else:
raise ValueError("Should never happen")
def send_create(db: Session, source: str) -> str:
note_id = allocate_outbox_id()
published = now().replace(microsecond=0).isoformat().replace("+00:00", "Z")
context = f"{ID}/contexts/" + uuid.uuid4().hex
content, tags = markdownify(db, source)
note = {
"@context": ap.AS_CTX,
"type": "Note",
"id": outbox_object_id(note_id),
"attributedTo": ID,
"content": content,
"to": [ap.AS_PUBLIC],
"cc": [f"{BASE_URL}/followers"],
"published": published,
"context": context,
"conversation": context,
"url": outbox_object_id(note_id),
"tag": tags,
"summary": None,
"inReplyTo": None,
"sensitive": False,
}
outbox_object = save_outbox_object(db, note_id, note, source=source)
if not outbox_object.id:
raise ValueError("Should never happen")
for tag in tags:
if tag["type"] == "Hashtag":
tagged_object = models.TaggedOutboxObject(
tag=tag["name"][1:],
outbox_object_id=outbox_object.id,
)
db.add(tagged_object)
db.commit()
recipients = _compute_recipients(db, note)
for rcp in recipients:
new_outgoing_activity(db, rcp, outbox_object.id)
return note_id
def _compute_recipients(db: Session, ap_object: ap.RawObject) -> set[str]:
_recipients = []
for field in ["to", "cc", "bto", "bcc"]:
if field in ap_object:
_recipients.extend(ap.as_list(ap_object[field]))
recipients = set()
for r in _recipients:
if r in [ap.AS_PUBLIC, ID]:
continue
# If we got a local collection, assume it's a collection of actors
if r.startswith(BASE_URL):
for raw_actor in fetch_collection(db, r):
actor = RemoteActor(raw_actor)
recipients.add(actor.shared_inbox_url or actor.inbox_url)
continue
# Is it a known actor?
known_actor = (
db.query(models.Actor).filter(models.Actor.ap_id == r).one_or_none()
)
if known_actor:
recipients.add(known_actor.shared_inbox_url or actor.inbox_url)
continue
# Fetch the object
raw_object = ap.fetch(r)
if raw_object.get("type") in ap.ACTOR_TYPES:
saved_actor = save_actor(db, raw_object)
recipients.add(saved_actor.shared_inbox_url or saved_actor.inbox_url)
else:
# Assume it's a collection of actors
for raw_actor in ap.parse_collection(payload=raw_object):
actor = RemoteActor(raw_actor)
recipients.add(actor.shared_inbox_url or actor.inbox_url)
return recipients
def get_inbox_object_by_ap_id(db: Session, ap_id: str) -> models.InboxObject | None:
return (
db.query(models.InboxObject)
.filter(models.InboxObject.ap_id == ap_id)
.one_or_none()
)
def get_outbox_object_by_ap_id(db: Session, ap_id: str) -> models.OutboxObject | None:
return (
db.query(models.OutboxObject)
.filter(models.OutboxObject.ap_id == ap_id)
.one_or_none()
)
def _handle_delete_activity(
db: Session,
from_actor: models.Actor,
ap_object_to_delete: models.InboxObject,
) -> None:
if from_actor.ap_id != ap_object_to_delete.actor.ap_id:
logger.warning(
"Actor mismatch between the activity and the object: "
f"{from_actor.ap_id}/{ap_object_to_delete.actor.ap_id}"
)
return
# TODO(ts): do we need to delete related activities? should we keep
# bookmarked objects with a deleted flag?
logger.info(f"Deleting {ap_object_to_delete.ap_type}/{ap_object_to_delete.ap_id}")
db.delete(ap_object_to_delete)
db.flush()
def _handle_follow_follow_activity(
db: Session,
from_actor: models.Actor,
inbox_object: models.InboxObject,
) -> None:
follower = models.Follower(
actor_id=from_actor.id,
inbox_object_id=inbox_object.id,
ap_actor_id=from_actor.ap_id,
)
try:
db.add(follower)
db.flush()
except IntegrityError:
pass # TODO update the existing followe
# Reply with an Accept
reply_id = allocate_outbox_id()
reply = {
"@context": ap.AS_CTX,
"id": outbox_object_id(reply_id),
"type": "Accept",
"actor": ID,
"object": inbox_object.ap_id,
}
outbox_activity = save_outbox_object(db, reply_id, reply)
if not outbox_activity.id:
raise ValueError("Should never happen")
new_outgoing_activity(db, from_actor.inbox_url, outbox_activity.id)
notif = models.Notification(
notification_type=models.NotificationType.NEW_FOLLOWER,
actor_id=from_actor.id,
)
db.add(notif)
def _handle_undo_activity(
db: Session,
from_actor: models.Actor,
undo_activity: models.InboxObject,
ap_activity_to_undo: models.InboxObject,
) -> None:
if from_actor.ap_id != ap_activity_to_undo.actor.ap_id:
logger.warning(
"Actor mismatch between the activity and the object: "
f"{from_actor.ap_id}/{ap_activity_to_undo.actor.ap_id}"
)
return
ap_activity_to_undo.undone_by_inbox_object_id = undo_activity.id
if ap_activity_to_undo.ap_type == "Follow":
logger.info(f"Undo follow from {from_actor.ap_id}")
db.query(models.Follower).filter(
models.Follower.inbox_object_id == ap_activity_to_undo.id
).delete()
notif = models.Notification(
notification_type=models.NotificationType.UNFOLLOW,
actor_id=from_actor.id,
)
db.add(notif)
elif ap_activity_to_undo.ap_type == "Like":
if not ap_activity_to_undo.activity_object_ap_id:
raise ValueError("Like without object")
liked_obj = get_outbox_object_by_ap_id(
db,
ap_activity_to_undo.activity_object_ap_id,
)
if not liked_obj:
logger.warning(
"Cannot find liked object: "
f"{ap_activity_to_undo.activity_object_ap_id}"
)
return
liked_obj.likes_count = models.OutboxObject.likes_count - 1
notif = models.Notification(
notification_type=models.NotificationType.UNDO_LIKE,
actor_id=from_actor.id,
outbox_object_id=liked_obj.id,
inbox_object_id=ap_activity_to_undo.id,
)
db.add(notif)
elif ap_activity_to_undo.ap_type == "Announce":
if not ap_activity_to_undo.activity_object_ap_id:
raise ValueError("Announce witout object")
announced_obj_ap_id = ap_activity_to_undo.activity_object_ap_id
logger.info(
f"Undo for announce {ap_activity_to_undo.ap_id}/{announced_obj_ap_id}"
)
if announced_obj_ap_id.startswith(BASE_URL):
announced_obj_from_outbox = get_outbox_object_by_ap_id(
db, announced_obj_ap_id
)
if announced_obj_from_outbox:
logger.info("Found in the oubox")
announced_obj_from_outbox.announces_count = (
models.OutboxObject.announces_count - 1
)
notif = models.Notification(
notification_type=models.NotificationType.UNDO_ANNOUNCE,
actor_id=from_actor.id,
outbox_object_id=announced_obj_from_outbox.id,
inbox_object_id=ap_activity_to_undo.id,
)
db.add(notif)
# FIXME(ts): what to do with ap_activity_to_undo? flag? delete?
else:
logger.warning(f"Don't know how to undo {ap_activity_to_undo.ap_type} activity")
# commit will be perfomed in save_to_inbox
def _handle_create_activity(
db: Session,
from_actor: models.Actor,
created_object: models.InboxObject,
) -> None:
logger.info("Processing Create activity")
tags = created_object.ap_object.get("tag")
if not tags:
logger.info("No tags to process")
return None
if not isinstance(tags, list):
logger.info(f"Invalid tags: {tags}")
return None
for tag in tags:
if tag.get("name") == LOCAL_ACTOR.handle or tag.get("href") == LOCAL_ACTOR.url:
notif = models.Notification(
notification_type=models.NotificationType.MENTION,
actor_id=from_actor.id,
inbox_object_id=created_object.id,
)
db.add(notif)
def save_to_inbox(db: Session, raw_object: ap.RawObject) -> None:
try:
actor = fetch_actor(db, raw_object["actor"])
except httpx.HTTPStatusError:
logger.exception("Failed to fetch actor")
# XXX: Delete 410 when we never seen the actor
return
ap_published_at = now()
if "published" in raw_object:
ap_published_at = isoparse(raw_object["published"])
ra = RemoteObject(ap.unwrap_activity(raw_object), actor=actor)
relates_to_inbox_object: models.InboxObject | None = None
relates_to_outbox_object: models.OutboxObject | None = None
if ra.activity_object_ap_id:
if ra.activity_object_ap_id.startswith(BASE_URL):
relates_to_outbox_object = get_outbox_object_by_ap_id(
db,
ra.activity_object_ap_id,
)
else:
relates_to_inbox_object = get_inbox_object_by_ap_id(
db,
ra.activity_object_ap_id,
)
inbox_object = models.InboxObject(
server=urlparse(ra.ap_id).netloc,
actor_id=actor.id,
ap_actor_id=actor.ap_id,
ap_type=ra.ap_type,
ap_id=ra.ap_id,
ap_context=ra.context,
ap_published_at=ap_published_at,
ap_object=ra.ap_object,
visibility=ra.visibility,
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,
activity_object_ap_id=ra.activity_object_ap_id,
# Hide replies from the stream
is_hidden_from_stream=True if ra.in_reply_to else False,
)
db.add(inbox_object)
db.flush()
db.refresh(inbox_object)
if ra.ap_type == "Create":
_handle_create_activity(db, actor, inbox_object)
elif ra.ap_type == "Update":
pass
elif ra.ap_type == "Delete":
if relates_to_inbox_object:
_handle_delete_activity(db, actor, relates_to_inbox_object)
else:
# TODO(ts): handle delete actor
logger.info(
f"Received a Delete for an unknown object: {ra.activity_object_ap_id}"
)
elif ra.ap_type == "Follow":
_handle_follow_follow_activity(db, actor, inbox_object)
elif ra.ap_type == "Undo":
if relates_to_inbox_object:
_handle_undo_activity(db, actor, inbox_object, relates_to_inbox_object)
else:
logger.info("Received Undo for an unknown activity")
elif ra.ap_type in ["Accept", "Reject"]:
if not relates_to_outbox_object:
logger.info(
f"Received {raw_object['type']} for an unknown activity: "
f"{ra.activity_object_ap_id}"
)
else:
if relates_to_outbox_object.ap_type == "Follow":
following = models.Following(
actor_id=actor.id,
outbox_object_id=relates_to_outbox_object.id,
ap_actor_id=actor.ap_id,
)
db.add(following)
else:
logger.info(
"Received an Accept for an unsupported activity: "
f"{relates_to_outbox_object.ap_type}"
)
elif ra.ap_type == "Like":
if not relates_to_outbox_object:
logger.info(
f"Received a like for an unknown activity: {ra.activity_object_ap_id}"
)
else:
relates_to_outbox_object.likes_count = models.OutboxObject.likes_count + 1
notif = models.Notification(
notification_type=models.NotificationType.LIKE,
actor_id=actor.id,
outbox_object_id=relates_to_outbox_object.id,
inbox_object_id=inbox_object.id,
)
db.add(notif)
elif raw_object["type"] == "Announce":
if relates_to_outbox_object:
# This is an announce for a local object
relates_to_outbox_object.announces_count = (
models.OutboxObject.announces_count + 1
)
notif = models.Notification(
notification_type=models.NotificationType.ANNOUNCE,
actor_id=actor.id,
outbox_object_id=relates_to_outbox_object.id,
inbox_object_id=inbox_object.id,
)
db.add(notif)
else:
# This is announce for a maybe unknown object
if relates_to_inbox_object:
logger.info("Nothing to do, we already know about this object")
else:
# Save it as an inbox object
if not ra.activity_object_ap_id:
raise ValueError("Should never happen")
announced_raw_object = ap.fetch(ra.activity_object_ap_id)
announced_actor = fetch_actor(db, ap.get_actor_id(announced_raw_object))
announced_object = RemoteObject(announced_raw_object, announced_actor)
announced_inbox_object = models.InboxObject(
server=urlparse(announced_object.ap_id).netloc,
actor_id=announced_actor.id,
ap_actor_id=announced_actor.ap_id,
ap_type=announced_object.ap_type,
ap_id=announced_object.ap_id,
ap_context=announced_object.context,
ap_published_at=announced_object.ap_published_at,
ap_object=announced_object.ap_object,
visibility=announced_object.visibility,
is_hidden_from_stream=True,
)
db.add(announced_inbox_object)
db.flush()
inbox_object.relates_to_inbox_object_id = announced_inbox_object.id
elif ra.ap_type in ["Like", "Announce"]:
if not relates_to_outbox_object:
logger.info(
f"Received {ra.ap_type} for an unknown activity: "
f"{ra.activity_object_ap_id}"
)
else:
if ra.ap_type == "Like":
# TODO(ts): notification
relates_to_outbox_object.likes_count = (
models.OutboxObject.likes_count + 1
)
notif = models.Notification(
notification_type=models.NotificationType.LIKE,
actor_id=actor.id,
outbox_object_id=relates_to_outbox_object.id,
inbox_object_id=inbox_object.id,
)
db.add(notif)
elif raw_object["type"] == "Announce":
# TODO(ts): notification
relates_to_outbox_object.announces_count = (
models.OutboxObject.announces_count + 1
)
notif = models.Notification(
notification_type=models.NotificationType.ANNOUNCE,
actor_id=actor.id,
outbox_object_id=relates_to_outbox_object.id,
inbox_object_id=inbox_object.id,
)
db.add(notif)
else:
raise ValueError("Should never happpen")
else:
logger.warning(f"Received an unknown {inbox_object.ap_type} object")
db.commit()
def public_outbox_objects_count(db: Session) -> int:
return (
db.query(models.OutboxObject)
.filter(
models.OutboxObject.visibility == ap.VisibilityEnum.PUBLIC,
models.OutboxObject.is_deleted.is_(False),
)
.count()
)
def fetch_collection(db: Session, url: str) -> list[ap.RawObject]:
if url.startswith(config.BASE_URL):
if url == config.BASE_URL + "/followers":
q = db.query(models.Follower).options(joinedload(models.Follower.actor))
return [follower.actor.ap_actor for follower in q.all()]
else:
raise ValueError(f"internal collection for {url}) not supported")
return ap.parse_collection(url)