From b3cbf1f6db74f843271144df115db94acfb34787 Mon Sep 17 00:00:00 2001 From: Thomas Sileo Date: Fri, 24 Jun 2022 22:41:43 +0200 Subject: [PATCH] First shot at parsing replies tree --- app/activitypub.py | 9 ++++++ app/admin.py | 2 +- app/ap_object.py | 2 +- app/boxes.py | 10 +++--- app/httpsig.py | 5 +++ app/main.py | 78 ++++++++++++++++++++++++++++++++++++++++++++-- tests/factories.py | 4 +-- 7 files changed, 99 insertions(+), 11 deletions(-) diff --git a/app/activitypub.py b/app/activitypub.py index 6869a0c..16dee4e 100644 --- a/app/activitypub.py +++ b/app/activitypub.py @@ -17,6 +17,10 @@ AS_PUBLIC = "https://www.w3.org/ns/activitystreams#Public" ACTOR_TYPES = ["Application", "Group", "Organization", "Person", "Service"] +class ObjectIsGoneError(Exception): + pass + + class VisibilityEnum(str, enum.Enum): PUBLIC = "public" UNLISTED = "unlisted" @@ -108,6 +112,11 @@ def fetch(url: str, params: dict[str, Any] | None = None) -> dict[str, Any]: params=params, follow_redirects=True, ) + + # Special handling for deleted object + if resp.status_code == 410: + raise ObjectIsGoneError(f"{url} is gone") + resp.raise_for_status() try: return resp.json() diff --git a/app/admin.py b/app/admin.py index b096583..e95fba1 100644 --- a/app/admin.py +++ b/app/admin.py @@ -244,7 +244,7 @@ def admin_actions_new( files: list[UploadFile], content: str = Form(), redirect_url: str = Form(), - in_reply_to: str | None = Form(), + in_reply_to: str | None = Form(None), csrf_check: None = Depends(verify_csrf_token), db: Session = Depends(get_db), ) -> RedirectResponse: diff --git a/app/ap_object.py b/app/ap_object.py index 13bea2a..c779f01 100644 --- a/app/ap_object.py +++ b/app/ap_object.py @@ -53,7 +53,7 @@ class Object: return ap.object_visibility(self.ap_object) @property - def context(self) -> str | None: + def ap_context(self) -> str | None: return self.ap_object.get("context") or self.ap_object.get("conversation") @property diff --git a/app/boxes.py b/app/boxes.py index 7922070..253791d 100644 --- a/app/boxes.py +++ b/app/boxes.py @@ -49,7 +49,7 @@ def save_outbox_object( public_id=public_id, ap_type=ra.ap_type, ap_id=ra.ap_id, - ap_context=ra.context, + ap_context=ra.ap_context, ap_object=ra.ap_object, visibility=ra.visibility, og_meta=ra.og_meta, @@ -233,9 +233,9 @@ def send_create( in_reply_to_object = get_anybox_object_by_ap_id(db, in_reply_to) if not in_reply_to_object: raise ValueError(f"Invalid in reply to {in_reply_to=}") - if not in_reply_to_object.context: + if not in_reply_to_object.ap_context: raise ValueError("Object has no context") - context = in_reply_to_object.context + context = in_reply_to_object.ap_context for (upload, filename) in uploads: attachments.append(upload_to_attachment(upload, filename)) @@ -544,7 +544,7 @@ def save_to_inbox(db: Session, raw_object: ap.RawObject) -> None: ap_actor_id=actor.ap_id, ap_type=ra.ap_type, ap_id=ra.ap_id, - ap_context=ra.context, + ap_context=ra.ap_context, ap_published_at=ap_published_at, ap_object=ra.ap_object, visibility=ra.visibility, @@ -651,7 +651,7 @@ def save_to_inbox(db: Session, raw_object: ap.RawObject) -> None: 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_context=announced_object.ap_context, ap_published_at=announced_object.ap_published_at, ap_object=announced_object.ap_object, visibility=announced_object.visibility, diff --git a/app/httpsig.py b/app/httpsig.py index 8bc9303..c77aaa7 100644 --- a/app/httpsig.py +++ b/app/httpsig.py @@ -19,6 +19,7 @@ from Crypto.Hash import SHA256 from Crypto.Signature import PKCS1_v1_5 from loguru import logger +from app import activitypub as ap from app import config from app.key import Key from app.key import get_key @@ -63,6 +64,7 @@ def _body_digest(body: bytes) -> str: @lru_cache(32) def _get_public_key(key_id: str) -> Key: + # TODO: use DB to use cache actor from app import activitypub as ap actor = ap.fetch(key_id) @@ -110,6 +112,9 @@ async def httpsig_checker( try: k = _get_public_key(hsig["keyId"]) + except ap.ObjectIsGoneError: + logger.info("Actor is gone") + return HTTPSigInfo(has_valid_signature=False) except Exception: logger.exception(f'Failed to fetch HTTP sig key {hsig["keyId"]}') return HTTPSigInfo(has_valid_signature=False) diff --git a/app/main.py b/app/main.py index ebbb819..c46fae6 100644 --- a/app/main.py +++ b/app/main.py @@ -2,6 +2,8 @@ import base64 import os import sys import time +from collections import defaultdict +from dataclasses import dataclass from datetime import datetime from io import BytesIO from typing import Any @@ -27,6 +29,7 @@ from starlette.responses import JSONResponse from app import activitypub as ap from app import admin +from app import boxes from app import config from app import httpsig from app import models @@ -368,6 +371,14 @@ def outbox( ) +@dataclass +class ReplyTreeNode: + ap_object: boxes.AnyboxObject + children: list["ReplyTreeNode"] + is_requested: bool = False + is_root: bool = False + + @app.get("/o/{public_id}") def outbox_by_public_id( public_id: str, @@ -385,7 +396,7 @@ def outbox_by_public_id( ) .filter( models.OutboxObject.public_id == public_id, - # models.OutboxObject.is_deleted.is_(False), + models.OutboxObject.is_deleted.is_(False), ) .one_or_none() ) @@ -395,6 +406,66 @@ def outbox_by_public_id( if is_activitypub_requested(request): return ActivityPubResponse(maybe_object.ap_object) + # TODO: handle visibility + tree_nodes: list[boxes.AnyboxObject] = [maybe_object] + tree_nodes.extend( + db.query(models.InboxObject) + .filter( + models.InboxObject.ap_context == maybe_object.ap_context, + ) + .all() + ) + tree_nodes.extend( + db.query(models.OutboxObject) + .filter( + models.OutboxObject.ap_context == maybe_object.ap_context, + models.OutboxObject.is_deleted.is_(False), + models.OutboxObject.id != maybe_object.id, + ) + .all() + ) + logger.info(f"root={maybe_object.ap_id}") + nodes_by_in_reply_to = defaultdict(list) + for node in tree_nodes: + nodes_by_in_reply_to[node.in_reply_to].append(node) + logger.info(f"in_reply_to={node.in_reply_to}") + logger.info(nodes_by_in_reply_to) + + # TODO: get oldest if we cannot get to root? + if len(nodes_by_in_reply_to.get(None, [])) != 1: + raise ValueError("Failed to compute replies tree") + + def _get_reply_node_children( + node: ReplyTreeNode, + index: defaultdict[str | None, list[boxes.AnyboxObject]], + ) -> list[ReplyTreeNode]: + children = [] + for child in index.get(node.ap_object.ap_id, []): # type: ignore + logger.info(f"{child=}") + child_node = ReplyTreeNode( + ap_object=child, + is_requested=child.ap_id == maybe_object.ap_id, # type: ignore + children=[], + ) + child_node.children = _get_reply_node_children(child_node, index) + children.append(child_node) + + return sorted( + children, + key=lambda node: node.ap_object.ap_published_at, # type: ignore + ) + + root_node = ReplyTreeNode( + ap_object=nodes_by_in_reply_to[None][0], + # ap_object=maybe_object, + is_root=True, + is_requested=nodes_by_in_reply_to[None][0].ap_id == maybe_object.ap_id, + children=[], + ) + root_node.children = _get_reply_node_children(root_node, nodes_by_in_reply_to) + logger.info(root_node.ap_object.ap_id) + logger.info(root_node) + return templates.render_template( db, request, @@ -414,7 +485,10 @@ def outbox_activity_by_public_id( # TODO: ACL? maybe_object = ( db.query(models.OutboxObject) - .filter(models.OutboxObject.public_id == public_id) + .filter( + models.OutboxObject.public_id == public_id, + models.OutboxObject.is_deleted.is_(False), + ) .one_or_none() ) if not maybe_object: diff --git a/tests/factories.py b/tests/factories.py index 2742b20..b12411b 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -156,7 +156,7 @@ class OutboxObjectFactory(factory.alchemy.SQLAlchemyModelFactory): public_id=public_id, ap_type=ro.ap_type, ap_id=ro.ap_id, - ap_context=ro.context, + ap_context=ro.ap_context, ap_object=ro.ap_object, visibility=ro.visibility, og_meta=ro.og_meta, @@ -194,7 +194,7 @@ class InboxObjectFactory(factory.alchemy.SQLAlchemyModelFactory): ap_actor_id=actor.ap_id, ap_type=ro.ap_type, ap_id=ro.ap_id, - ap_context=ro.context, + ap_context=ro.ap_context, ap_published_at=ap_published_at, ap_object=ro.ap_object, visibility=ro.visibility,